Added more functionality

This commit is contained in:
2026-08-26 23:40:31 +03:00
parent d1807a424e
commit c37f7bb6ef
6 changed files with 53 additions and 6 deletions
+2
View File
@@ -12,6 +12,8 @@ Open the web dashboard at [http://localhost:3000](http://localhost:3000).
The dashboard's **Vehicles** view provides a dedicated vehicle register containing make, model, model year, engine, transmission, fuel type, color, VIN, license plate, mileage, owner, and service-record count. The **Service records** form stores these vehicle details and can autofill them, along with the customer, when an existing VIN or license plate is entered. Customer contact information can also be autofilled by customer email or by a unique customer name. Names are not unique, so duplicate names are allowed; when a name matches multiple customers, the form asks for an email instead of choosing one incorrectly. It supports adding, listing, and editing service records with service date, VIN, license plate number, car mileage, customer, technician, complaint, and status. Adding a record creates or updates the linked customer and vehicle, technician, and service order in one transaction.
Service records can also include optional used parts and labor lines. Parts support a part number, quantity, and unit price. Labor supports used time in hours and an hourly price. Line totals and the service-record total are calculated automatically and stored with the service order.
The **Accounts** view supports reading, adding, editing, and deleting users with `read`, `add`, and `delete` rights. A default `admin` account is created automatically, and the last administrator cannot be deleted. These permissions are currently stored as staff access profiles; login/session enforcement can be added when authentication is required.
Check that the database is ready:
+2
View File
@@ -64,6 +64,8 @@ CREATE TABLE IF NOT EXISTS service_order_items (
service_order_id UUID NOT NULL REFERENCES service_orders(service_order_id) ON DELETE CASCADE,
item_type TEXT NOT NULL CHECK (item_type IN ('labor', 'part')),
description TEXT NOT NULL,
part_number TEXT,
labor_time_minutes INTEGER CHECK (labor_time_minutes IS NULL OR labor_time_minutes > 0),
quantity NUMERIC(10, 2) NOT NULL DEFAULT 1 CHECK (quantity > 0),
unit_price NUMERIC(12, 2) NOT NULL CHECK (unit_price >= 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+19 -4
View File
@@ -1,4 +1,4 @@
const state = { orders: [], users: [], vehicles: [] };
const state = { orders: [], users: [], vehicles: [], recordItems: [] };
const $ = (selector) => document.querySelector(selector);
async function request(url, options) {
@@ -53,7 +53,7 @@ function renderOrders() {
const serviceDate = String(order.service_date || '').slice(0, 10);
return (!vehicleFilter || vehicleText.includes(vehicleFilter)) && (!customerFilter || customerText.includes(customerFilter)) && (!technicianFilter || technicianText.includes(technicianFilter)) && (!filters.dateFrom || serviceDate >= filters.dateFrom) && (!filters.dateTo || serviceDate <= filters.dateTo) && (!filters.status || order.status === filters.status);
});
$('#orders-table').innerHTML = visibleOrders.length ? visibleOrders.map((order) => `<tr><td>${escapeHtml(order.service_date)}</td><td>${escapeHtml(order.vin || '—')}</td><td><strong>${escapeHtml(order.license_plate)}</strong></td><td>${escapeHtml(`${order.make || ''} ${order.model || ''}`.trim())}</td><td>${Number(order.mileage).toLocaleString()} km</td><td>${escapeHtml(order.customer_name)}</td><td>${escapeHtml(order.technician)}</td><td>${escapeHtml(order.complaint)}</td><td><select class="status-select" data-order-id="${order.service_order_id}"><option value="${order.status}" selected>${titleCase(order.status)}</option><option value="open">Open</option><option value="in_progress">In progress</option><option value="waiting_for_parts">Waiting for parts</option><option value="ready">Ready</option><option value="closed">Closed</option></select></td><td><button class="edit-button" data-record-id="${order.service_order_id}">Edit</button></td></tr>`).join('') : `<tr><td colspan="10">${emptyMessage(state.orders.length ? 'No service records match these filters.' : 'No service records yet.')}</td></tr>`;
$('#orders-table').innerHTML = visibleOrders.length ? visibleOrders.map((order) => `<tr><td>${escapeHtml(order.service_date)}</td><td>${escapeHtml(order.vin || '—')}</td><td><strong>${escapeHtml(order.license_plate)}</strong></td><td>${escapeHtml(`${order.make || ''} ${order.model || ''}`.trim())}</td><td>${Number(order.mileage).toLocaleString()} km</td><td>${escapeHtml(order.customer_name)}</td><td>${escapeHtml(order.technician)}</td><td>${escapeHtml(order.complaint)}</td><td>€${Number(order.total || 0).toFixed(2)}</td><td><select class="status-select" data-order-id="${order.service_order_id}"><option value="${order.status}" selected>${titleCase(order.status)}</option><option value="open">Open</option><option value="in_progress">In progress</option><option value="waiting_for_parts">Waiting for parts</option><option value="ready">Ready</option><option value="closed">Closed</option></select></td><td><button class="edit-button" data-record-id="${order.service_order_id}">Edit</button></td></tr>`).join('') : `<tr><td colspan="11">${emptyMessage(state.orders.length ? 'No service records match these filters.' : 'No service records yet.')}</td></tr>`;
document.querySelectorAll('.status-select').forEach((select) => select.addEventListener('change', updateOrderStatus));
document.querySelectorAll('.edit-button[data-record-id]').forEach((button) => button.addEventListener('click', () => editRecord(state.orders.find((order) => order.service_order_id === button.dataset.recordId))));
}
@@ -85,9 +85,21 @@ function editUser(user) {
function editRecord(record) {
const form = $('#record-form'); form.dataset.editId = record.service_order_id;
for (const name of ['serviceDate', 'vin', 'licensePlate', 'make', 'model', 'modelYear', 'engine', 'transmission', 'fuelType', 'color', 'customerName', 'customerEmail', 'customerPhone', 'mileage', 'technicianName', 'complaint']) form.elements[name].value = record[name === 'licensePlate' ? 'license_plate' : name === 'modelYear' ? 'model_year' : name === 'customerName' ? 'customer_name' : name === 'customerEmail' ? 'customer_email' : name === 'customerPhone' ? 'customer_phone' : name === 'technicianName' ? 'technician' : name] || '';
state.recordItems = (record.items || []).map((item) => ({ itemType: item.item_type, description: item.description, partNumber: item.part_number || '', usedTimeHours: item.labor_time_minutes ? Number(item.labor_time_minutes) / 60 : '', quantity: item.quantity || 1, unitPrice: item.unit_price || 0 }));
renderServiceItems();
$('#record-dialog-title').textContent = 'Edit service record'; $('#record-submit-button').textContent = 'Save changes'; $('#record-dialog').showModal();
}
function renderServiceItems() {
$('#service-items').innerHTML = state.recordItems.map((item, index) => `<div class="service-item" data-item-index="${index}"><div class="item-row-heading"><strong>${item.itemType === 'part' ? 'Part' : 'Labor'}</strong><button class="remove-item-button" type="button" data-item-index="${index}">Remove</button></div><label>Description<input data-item-field="description" value="${escapeHtml(item.description)}" /></label>${item.itemType === 'part' ? `<label>Part number<input data-item-field="partNumber" value="${escapeHtml(item.partNumber)}" /></label><label>Quantity<input data-item-field="quantity" type="number" min="0.01" step="0.01" value="${item.quantity}" /></label>` : `<label>Used time (hours)<input data-item-field="usedTimeHours" type="number" min="0.01" step="0.25" value="${item.usedTimeHours}" /></label>`}<label>${item.itemType === 'part' ? 'Price each' : 'Labor price per hour'}<input data-item-field="unitPrice" type="number" min="0" step="0.01" value="${item.unitPrice}" /></label><span class="item-line-total">Line total: €${calculateItemTotal(item).toFixed(2)}</span></div>`).join('');
$('#service-items-total').textContent = `€${state.recordItems.reduce((total, item) => total + calculateItemTotal(item), 0).toFixed(2)}`;
document.querySelectorAll('.service-item input').forEach((input) => input.addEventListener('input', (event) => { const row = event.target.closest('.service-item'); state.recordItems[row.dataset.itemIndex][event.target.dataset.itemField] = event.target.value; updateServiceItemTotals(); }));
document.querySelectorAll('.remove-item-button').forEach((button) => button.addEventListener('click', () => { state.recordItems.splice(Number(button.dataset.itemIndex), 1); renderServiceItems(); }));
}
function calculateItemTotal(item) { return item.itemType === 'labor' ? (Number(item.usedTimeHours) || 0) * (Number(item.unitPrice) || 0) : (Number(item.quantity) || 0) * (Number(item.unitPrice) || 0); }
function updateServiceItemTotals() { document.querySelectorAll('.item-line-total').forEach((total, index) => { total.textContent = `Line total: €${calculateItemTotal(state.recordItems[index]).toFixed(2)}`; }); $('#service-items-total').textContent = `€${state.recordItems.reduce((total, item) => total + calculateItemTotal(item), 0).toFixed(2)}`; }
async function deleteUser(event) {
if (!window.confirm(`Delete ${event.target.dataset.userName}?`)) return;
try { await request(`/api/users/${event.target.dataset.userId}`, { method: 'DELETE' }); await loadUsers(); } catch (error) { showError(error); }
@@ -115,7 +127,9 @@ $('#close-customer-button').addEventListener('click', () => $('#customer-dialog'
$('#new-user-button').addEventListener('click', () => { const form = $('#user-form'); delete form.dataset.editId; form.reset(); form.elements.username.disabled = false; $('#user-dialog-title').textContent = 'Add user'; $('#user-submit-button').textContent = 'Create user'; $('#user-dialog').showModal(); });
$('#cancel-user-button').addEventListener('click', () => $('#user-dialog').close());
$('#close-user-button').addEventListener('click', () => $('#user-dialog').close());
$('#new-record-button').addEventListener('click', () => { const form = $('#record-form'); delete form.dataset.editId; form.reset(); $('#record-dialog-title').textContent = 'Add service record'; $('#record-submit-button').textContent = 'Create service record'; $('#record-dialog').showModal(); });
$('#new-record-button').addEventListener('click', () => { const form = $('#record-form'); delete form.dataset.editId; form.reset(); state.recordItems = []; renderServiceItems(); $('#record-dialog-title').textContent = 'Add service record'; $('#record-submit-button').textContent = 'Create service record'; $('#record-dialog').showModal(); });
$('#add-part-button').addEventListener('click', () => { state.recordItems.push({ itemType: 'part', description: '', partNumber: '', quantity: 1, unitPrice: 0 }); renderServiceItems(); });
$('#add-labor-button').addEventListener('click', () => { state.recordItems.push({ itemType: 'labor', description: '', usedTimeHours: 1, unitPrice: 0 }); renderServiceItems(); });
$('#new-vehicle-button').addEventListener('click', () => $('#vehicle-dialog').showModal());
$('#cancel-vehicle-button').addEventListener('click', () => $('#vehicle-dialog').close());
$('#close-vehicle-button').addEventListener('click', () => $('#vehicle-dialog').close());
@@ -173,9 +187,10 @@ $('#record-form').addEventListener('submit', async (event) => {
event.preventDefault();
try {
const form = Object.fromEntries(new FormData(event.target));
form.items = state.recordItems;
const editId = event.target.dataset.editId;
await request(editId ? `/api/service-records/${editId}` : '/api/service-records', { method: editId ? 'PATCH' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form) });
event.target.reset(); delete event.target.dataset.editId; $('#record-dialog-title').textContent = 'Add service record'; $('#record-submit-button').textContent = 'Create service record'; $('#record-dialog').close(); await loadOrders(); await loadOverview();
event.target.reset(); state.recordItems = []; delete event.target.dataset.editId; $('#record-dialog-title').textContent = 'Add service record'; $('#record-submit-button').textContent = 'Create service record'; $('#record-dialog').close(); await loadOrders(); await loadOverview();
} catch (error) { showError(error); }
});
+2 -2
View File
@@ -41,7 +41,7 @@
<section id="vehicles-view" class="view"><div class="section-heading"><div><p class="eyebrow">Vehicle register</p><h2>Vehicles</h2><p class="subheading">Find a vehicle by plate or VIN and keep its specifications together.</p></div><button class="primary-button" id="new-vehicle-button">+ Add vehicle</button></div><div class="panel table-panel"><div class="table-wrap"><table><thead><tr><th>Vehicle</th><th>Identifiers</th><th>Specifications</th><th>Owner</th><th>Mileage</th><th>Records</th></tr></thead><tbody id="vehicles-table"></tbody></table></div></div></section>
<section id="orders-view" class="view"><div class="section-heading"><div><p class="eyebrow">Workshop floor</p><h2>Service records</h2><p class="subheading">VIN, plate, vehicle, mileage, date, customer, and technician in one view.</p></div><button class="primary-button" id="new-record-button">+ Add service record</button></div><form class="filter-bar" id="order-filters"><label>Vehicle<input name="vehicle" placeholder="VIN, plate, make, or model" /></label><label>Customer<input name="customer" placeholder="Customer name or email" /></label><label>Technician<input name="technician" placeholder="Technician name" /></label><label>From<input name="dateFrom" type="date" /></label><label>To<input name="dateTo" type="date" /></label><label>Status<select name="status"><option value="">All statuses</option><option value="open">Open</option><option value="in_progress">In progress</option><option value="waiting_for_parts">Waiting for parts</option><option value="ready">Ready</option><option value="closed">Closed</option><option value="cancelled">Cancelled</option></select></label><button class="secondary-button filter-clear" type="reset">Clear filters</button></form><div class="panel table-panel"><div class="table-wrap"><table><thead><tr><th>Service date</th><th>VIN</th><th>License plate</th><th>Vehicle</th><th>Mileage</th><th>Customer</th><th>Technician</th><th>Complaint</th><th>Status</th><th>Actions</th></tr></thead><tbody id="orders-table"></tbody></table></div></div></section>
<section id="orders-view" class="view"><div class="section-heading"><div><p class="eyebrow">Workshop floor</p><h2>Service records</h2><p class="subheading">VIN, plate, vehicle, mileage, date, customer, and technician in one view.</p></div><button class="primary-button" id="new-record-button">+ Add service record</button></div><form class="filter-bar" id="order-filters"><label>Vehicle<input name="vehicle" placeholder="VIN, plate, make, or model" /></label><label>Customer<input name="customer" placeholder="Customer name or email" /></label><label>Technician<input name="technician" placeholder="Technician name" /></label><label>From<input name="dateFrom" type="date" /></label><label>To<input name="dateTo" type="date" /></label><label>Status<select name="status"><option value="">All statuses</option><option value="open">Open</option><option value="in_progress">In progress</option><option value="waiting_for_parts">Waiting for parts</option><option value="ready">Ready</option><option value="closed">Closed</option><option value="cancelled">Cancelled</option></select></label><button class="secondary-button filter-clear" type="reset">Clear filters</button></form><div class="panel table-panel"><div class="table-wrap"><table><thead><tr><th>Service date</th><th>VIN</th><th>License plate</th><th>Vehicle</th><th>Mileage</th><th>Customer</th><th>Technician</th><th>Complaint</th><th>Total</th><th>Status</th><th>Actions</th></tr></thead><tbody id="orders-table"></tbody></table></div></div></section>
<section id="users-view" class="view"><div class="section-heading"><div><p class="eyebrow">Access control</p><h2>User accounts</h2><p class="subheading">Manage workshop staff and their database rights.</p></div><button class="primary-button" id="new-user-button">+ Add user</button></div><div class="panel table-panel"><div class="table-wrap"><table><thead><tr><th>User</th><th>Role</th><th>Read</th><th>Add</th><th>Delete</th><th>Actions</th></tr></thead><tbody id="users-table"></tbody></table></div></div></section>
</main>
</div>
@@ -49,7 +49,7 @@
<dialog id="customer-dialog"><form method="dialog" id="customer-form"><div class="dialog-heading"><div><p class="eyebrow">Directory</p><h2>Add customer</h2></div><button class="close-button" type="button" id="close-customer-button" aria-label="Close">×</button></div><label>First name<input name="firstName" required /></label><label>Last name<input name="lastName" required /></label><label>Email<input name="email" type="email" /></label><label>Phone<input name="phone" type="tel" /></label><div class="dialog-actions"><button class="secondary-button" type="button" id="cancel-customer-button">Cancel</button><button class="primary-button" value="default">Save customer</button></div></form></dialog>
<dialog id="vehicle-dialog"><form method="dialog" id="vehicle-form"><div class="dialog-heading"><div><p class="eyebrow">Vehicle register</p><h2>Add vehicle</h2></div><button class="close-button" type="button" id="close-vehicle-button" aria-label="Close">×</button></div><p class="form-hint">Use an existing customer email to link this vehicle to that customer.</p><label>Customer name<input name="customerName" required /></label><label>Customer email<input name="customerEmail" type="email" /></label><label>Customer phone<input name="customerPhone" type="tel" /></label><label>VIN<input name="vin" minlength="17" maxlength="17" required /></label><label>License plate number<input name="licensePlate" required /></label><label>Make<input name="make" required /></label><label>Model<input name="model" required /></label><label>Model year<input name="modelYear" type="number" min="1886" max="2100" /></label><label>Engine<input name="engine" /></label><label>Transmission<input name="transmission" /></label><label>Fuel type<input name="fuelType" /></label><label>Color<input name="color" /></label><label>Car mileage<input name="mileage" type="number" min="0" step="1" value="0" required /></label><div class="dialog-actions"><button class="secondary-button" type="button" id="cancel-vehicle-button">Cancel</button><button class="primary-button" value="default">Create vehicle</button></div></form></dialog>
<dialog id="user-dialog"><form method="dialog" id="user-form"><div class="dialog-heading"><div><p class="eyebrow">Access control</p><h2 id="user-dialog-title">Add user</h2></div><button class="close-button" type="button" id="close-user-button" aria-label="Close">×</button></div><label>Username<input name="username" required /></label><label>Display name<input name="displayName" required /></label><label>Email<input name="email" type="email" /></label><label>Role<select name="role"><option value="viewer">Viewer</option><option value="technician">Technician</option><option value="service_advisor">Service advisor</option><option value="admin">Administrator</option></select></label><fieldset><legend>Rights</legend><label class="check-label"><input name="canRead" type="checkbox" checked /> Read records</label><label class="check-label"><input name="canAdd" type="checkbox" /> Add records</label><label class="check-label"><input name="canDelete" type="checkbox" /> Delete records</label></fieldset><div class="dialog-actions"><button class="secondary-button" type="button" id="cancel-user-button">Cancel</button><button class="primary-button" id="user-submit-button" value="default">Create user</button></div></form></dialog>
<dialog id="record-dialog"><form method="dialog" id="record-form"><div class="dialog-heading"><div><p class="eyebrow">Workshop floor</p><h2 id="record-dialog-title">Add service record</h2></div><button class="close-button" type="button" id="close-record-button" aria-label="Close">×</button></div><p class="form-hint">Enter a known VIN or license plate to autofill the vehicle and customer. Use customer email when names are shared.</p><label>Service date<input name="serviceDate" type="date" required /></label><label>VIN<input name="vin" minlength="17" maxlength="17" /></label><label>License plate number<input name="licensePlate" /></label><label>Make<input name="make" required /></label><label>Model<input name="model" required /></label><label>Model year<input name="modelYear" type="number" min="1886" max="2100" /></label><label>Engine<input name="engine" placeholder="e.g. 2.0 TDI" /></label><label>Transmission<input name="transmission" placeholder="e.g. Automatic" /></label><label>Fuel type<input name="fuelType" placeholder="e.g. Petrol, diesel, electric" /></label><label>Color<input name="color" /></label><label>Customer name<input name="customerName" placeholder="First and last name" required /></label><label>Customer email<input name="customerEmail" type="email" /></label><label>Customer phone<input name="customerPhone" type="tel" /></label><label>Car mileage<input name="mileage" type="number" min="0" step="1" required /></label><label>Technician<input name="technicianName" required /></label><label>Complaint or service requested<textarea name="complaint" rows="3" required></textarea></label><div class="dialog-actions"><button class="secondary-button" type="button" id="cancel-record-button">Cancel</button><button class="primary-button" id="record-submit-button" value="default">Create service record</button></div></form></dialog>
<dialog id="record-dialog"><form method="dialog" id="record-form"><div class="dialog-heading"><div><p class="eyebrow">Workshop floor</p><h2 id="record-dialog-title">Add service record</h2></div><button class="close-button" type="button" id="close-record-button" aria-label="Close">×</button></div><p class="form-hint">Enter a known VIN or license plate to autofill the vehicle and customer. Use customer email when names are shared.</p><label>Service date<input name="serviceDate" type="date" required /></label><label>VIN<input name="vin" minlength="17" maxlength="17" /></label><label>License plate number<input name="licensePlate" /></label><label>Make<input name="make" required /></label><label>Model<input name="model" required /></label><label>Model year<input name="modelYear" type="number" min="1886" max="2100" /></label><label>Engine<input name="engine" placeholder="e.g. 2.0 TDI" /></label><label>Transmission<input name="transmission" placeholder="e.g. Automatic" /></label><label>Fuel type<input name="fuelType" placeholder="e.g. Petrol, diesel, electric" /></label><label>Color<input name="color" /></label><label>Customer name<input name="customerName" placeholder="First and last name" required /></label><label>Customer email<input name="customerEmail" type="email" /></label><label>Customer phone<input name="customerPhone" type="tel" /></label><label>Car mileage<input name="mileage" type="number" min="0" step="1" required /></label><label>Technician<input name="technicianName" required /></label><label>Complaint or service requested<textarea name="complaint" rows="3" required></textarea></label><section class="items-section"><div class="items-heading"><div><p class="eyebrow">Optional billing</p><h3>Used parts and labor</h3></div><div class="item-add-actions"><button class="secondary-button" type="button" id="add-part-button">+ Part</button><button class="secondary-button" type="button" id="add-labor-button">+ Labor</button></div></div><div id="service-items"></div><strong class="items-total">Total: <span id="service-items-total">€0.00</span></strong></section><div class="dialog-actions"><button class="secondary-button" type="button" id="cancel-record-button">Cancel</button><button class="primary-button" id="record-submit-button" value="default">Create service record</button></div></form></dialog>
<script src="/app.js"></script>
</body>
</html>
+1
View File
@@ -23,6 +23,7 @@ nav { display: grid; gap: 8px; margin-top: 76px; }
.dialog-actions { display: flex; gap: 10px; justify-content: flex-end; }.secondary-button { background: transparent; border: 1px solid var(--line); color: var(--ink); padding: 12px 17px; font-weight: 700; }.secondary-button:hover { background: var(--paper); }
.edit-button { background: transparent; border: 1px solid var(--line); color: var(--ink); padding: 7px 10px; }.edit-button:hover { background: var(--paper); }
.form-hint { margin: -4px 0 4px; color: var(--muted); font-size: 0.85rem; }
.items-section { border-top: 1px solid var(--line); padding-top: 18px; }.items-heading { align-items: flex-start; display: flex; justify-content: space-between; gap: 12px; }.items-heading h3 { font: 600 18px 'Space Grotesk', sans-serif; margin: 0; }.item-add-actions { display: flex; gap: 6px; }.item-add-actions .secondary-button { padding: 8px 10px; }.service-item { background: var(--paper); border: 1px solid var(--line); display: grid; gap: 10px; margin-top: 12px; padding: 14px; }.item-row-heading { display: flex; justify-content: space-between; }.remove-item-button { background: transparent; border: 0; color: #a15b4e; cursor: pointer; font-size: 12px; font-weight: 700; }.item-line-total { color: var(--ink); font-size: 12px; font-weight: 700; }.items-total { display: block; font: 600 18px 'Space Grotesk', sans-serif; margin-top: 16px; text-align: right; }
.filter-bar { display: grid; grid-template-columns: minmax(180px, 1.5fr) minmax(160px, 1.2fr) minmax(150px, 1fr) repeat(2, minmax(130px, .8fr)) minmax(145px, .9fr) auto; gap: 10px; align-items: end; margin-bottom: 16px; }
.filter-bar label { font-size: 11px; }.filter-bar input, .filter-bar select { width: 100%; padding: 10px; }.filter-clear { white-space: nowrap; }
@media (max-width: 1100px) { .filter-bar { grid-template-columns: repeat(3, 1fr); }.filter-clear { justify-self: start; } }
+27
View File
@@ -6,6 +6,23 @@ const app = express();
const port = Number(process.env.PORT || 3000);
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
function normalizeItems(items) {
if (!Array.isArray(items)) return [];
return items.map((item) => {
const itemType = item.itemType === 'labor' ? 'labor' : 'part';
const description = String(item.description || '').trim();
const quantity = Number(item.quantity || 1);
const unitPrice = Number(item.unitPrice || 0);
const laborTimeMinutes = itemType === 'labor' ? Math.round(Number(item.usedTimeHours || 0) * 60) : null;
if (!description || !Number.isFinite(quantity) || quantity <= 0 || !Number.isFinite(unitPrice) || unitPrice < 0 || (itemType === 'labor' && laborTimeMinutes <= 0)) throw new Error('Each service item needs a description, valid price, and valid quantity or labor time.');
return { itemType, description, partNumber: itemType === 'part' ? String(item.partNumber || '').trim() || null : null, laborTimeMinutes, quantity, unitPrice };
});
}
async function insertItems(client, serviceOrderId, items) {
for (const item of items) await client.query(`INSERT INTO service_order_items (service_order_id, item_type, description, part_number, labor_time_minutes, quantity, unit_price) VALUES ($1, $2, $3, $4, $5, $6, $7)`, [serviceOrderId, item.itemType, item.description, item.partNumber, item.laborTimeMinutes, item.quantity, item.unitPrice]);
}
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
@@ -36,6 +53,8 @@ app.get('/api/service-records', async (_request, response) => {
SELECT so.service_order_id, so.status, so.complaint, so.opened_at, so.service_date,
v.vehicle_id, btrim(v.vin) AS vin, v.registration_number AS license_plate, v.make, v.model, v.model_year, v.engine, v.transmission, v.fuel_type, v.color, v.mileage,
c.first_name || ' ' || c.last_name AS customer_name, c.email AS customer_email, c.phone AS customer_phone,
COALESCE((SELECT SUM(CASE WHEN soi.item_type = 'labor' THEN soi.labor_time_minutes / 60.0 * soi.unit_price ELSE soi.quantity * soi.unit_price END) FROM service_order_items soi WHERE soi.service_order_id = so.service_order_id), 0)::numeric(12,2) AS total,
COALESCE((SELECT json_agg(json_build_object('item_type', soi.item_type, 'description', soi.description, 'part_number', soi.part_number, 'labor_time_minutes', soi.labor_time_minutes, 'quantity', soi.quantity, 'unit_price', soi.unit_price, 'line_total', CASE WHEN soi.item_type = 'labor' THEN soi.labor_time_minutes / 60.0 * soi.unit_price ELSE soi.quantity * soi.unit_price END) ORDER BY soi.created_at) FROM service_order_items soi WHERE soi.service_order_id = so.service_order_id), '[]') AS items,
COALESCE(e.first_name || ' ' || e.last_name, 'Unassigned') AS technician
FROM service_orders so
JOIN vehicles v ON v.vehicle_id = so.vehicle_id
@@ -114,6 +133,8 @@ app.post('/api/vehicles', async (request, response) => {
app.post('/api/service-records', async (request, response) => {
const { customerName, customerEmail, customerPhone, vin, licensePlate, mileage, serviceDate, technicianName, complaint, make, model, modelYear, engine, transmission, fuelType, color } = request.body;
let items;
try { items = normalizeItems(request.body.items); } catch (error) { return response.status(400).json({ error: error.message }); }
const parsedMileage = Number(mileage);
if (!customerName || !vin || !licensePlate || !technicianName || !complaint || !serviceDate || !Number.isInteger(parsedMileage) || parsedMileage < 0) {
return response.status(400).json({ error: 'Customer, VIN, license plate, non-negative mileage, service date, technician, and complaint are required.' });
@@ -156,6 +177,7 @@ app.post('/api/service-records', async (request, response) => {
`INSERT INTO service_orders (vehicle_id, assigned_employee_id, complaint, service_date) VALUES ($1, $2, $3, $4) RETURNING service_order_id`,
[vehicle.rows[0].vehicle_id, technician.rows[0].employee_id, complaint.trim(), serviceDate]
);
await insertItems(client, record.rows[0].service_order_id, items);
await client.query('COMMIT');
response.status(201).json({ service_order_id: record.rows[0].service_order_id });
} catch (error) {
@@ -168,6 +190,8 @@ app.post('/api/service-records', async (request, response) => {
app.patch('/api/service-records/:id', async (request, response) => {
const { customerName, customerEmail, customerPhone, vin, licensePlate, mileage, serviceDate, technicianName, complaint, make, model, modelYear, engine, transmission, fuelType, color } = request.body;
let items;
try { items = normalizeItems(request.body.items); } catch (error) { return response.status(400).json({ error: error.message }); }
const parsedMileage = Number(mileage);
if (!customerName || !vin || !licensePlate || !technicianName || !complaint || !serviceDate || !Number.isInteger(parsedMileage) || parsedMileage < 0) return response.status(400).json({ error: 'Customer, VIN, license plate, non-negative mileage, service date, technician, and complaint are required.' });
const customerParts = customerName.trim().split(/\s+/);
@@ -183,6 +207,8 @@ app.patch('/api/service-records/:id', async (request, response) => {
await client.query('UPDATE employees SET first_name = $1, last_name = $2 WHERE employee_id = $3', [technicianFirst, technicianParts.join(' ') || technicianFirst, current.rows[0].assigned_employee_id]);
await client.query(`UPDATE vehicles SET vin = $1, registration_number = $2, make = NULLIF($3, ''), model = NULLIF($4, ''), model_year = NULLIF($5, '')::integer, engine = NULLIF($6, ''), transmission = NULLIF($7, ''), fuel_type = NULLIF($8, ''), color = NULLIF($9, ''), mileage = $10 WHERE vehicle_id = $11`, [vin.trim(), licensePlate.trim(), make?.trim() || '', model?.trim() || '', modelYear || '', engine?.trim() || '', transmission?.trim() || '', fuelType?.trim() || '', color?.trim() || '', parsedMileage, current.rows[0].vehicle_id]);
await client.query('UPDATE service_orders SET complaint = $1, service_date = $2 WHERE service_order_id = $3', [complaint.trim(), serviceDate, request.params.id]);
await client.query('DELETE FROM service_order_items WHERE service_order_id = $1', [request.params.id]);
await insertItems(client, request.params.id, items);
await client.query('COMMIT');
response.json({ service_order_id: request.params.id });
} catch (error) {
@@ -276,6 +302,7 @@ app.use((_request, response) => response.sendFile(path.join(__dirname, 'public',
async function start() {
await pool.query(`ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS engine TEXT, ADD COLUMN IF NOT EXISTS transmission TEXT, ADD COLUMN IF NOT EXISTS fuel_type TEXT, ADD COLUMN IF NOT EXISTS color TEXT`);
await pool.query(`ALTER TABLE service_order_items ADD COLUMN IF NOT EXISTS part_number TEXT, ADD COLUMN IF NOT EXISTS labor_time_minutes INTEGER`);
await pool.query(`ALTER TABLE service_orders ADD COLUMN IF NOT EXISTS service_date DATE`);
await pool.query(`UPDATE service_orders SET service_date = opened_at::date WHERE service_date IS NULL`);
await pool.query(`ALTER TABLE service_orders ALTER COLUMN service_date SET DEFAULT CURRENT_DATE`);