diff --git a/README.md b/README.md index 9f2d45e..feb52f4 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/init.sql b/init.sql index fabab93..0f9a5ee 100644 --- a/init.sql +++ b/init.sql @@ -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() diff --git a/public/app.js b/public/app.js index 1dc38f9..912f9d3 100644 --- a/public/app.js +++ b/public/app.js @@ -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) => `${escapeHtml(order.service_date)}${escapeHtml(order.vin || '—')}${escapeHtml(order.license_plate)}${escapeHtml(`${order.make || ''} ${order.model || ''}`.trim())}${Number(order.mileage).toLocaleString()} km${escapeHtml(order.customer_name)}${escapeHtml(order.technician)}${escapeHtml(order.complaint)}`).join('') : `${emptyMessage(state.orders.length ? 'No service records match these filters.' : 'No service records yet.')}`; + $('#orders-table').innerHTML = visibleOrders.length ? visibleOrders.map((order) => `${escapeHtml(order.service_date)}${escapeHtml(order.vin || '—')}${escapeHtml(order.license_plate)}${escapeHtml(`${order.make || ''} ${order.model || ''}`.trim())}${Number(order.mileage).toLocaleString()} km${escapeHtml(order.customer_name)}${escapeHtml(order.technician)}${escapeHtml(order.complaint)}€${Number(order.total || 0).toFixed(2)}`).join('') : `${emptyMessage(state.orders.length ? 'No service records match these filters.' : 'No service records yet.')}`; 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) => `
${item.itemType === 'part' ? 'Part' : 'Labor'}
${item.itemType === 'part' ? `` : ``}Line total: €${calculateItemTotal(item).toFixed(2)}
`).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); } }); diff --git a/public/index.html b/public/index.html index 38bace1..7d27610 100644 --- a/public/index.html +++ b/public/index.html @@ -41,7 +41,7 @@

Vehicle register

Vehicles

Find a vehicle by plate or VIN and keep its specifications together.

VehicleIdentifiersSpecificationsOwnerMileageRecords
-

Workshop floor

Service records

VIN, plate, vehicle, mileage, date, customer, and technician in one view.

Service dateVINLicense plateVehicleMileageCustomerTechnicianComplaintStatusActions
+

Workshop floor

Service records

VIN, plate, vehicle, mileage, date, customer, and technician in one view.

Service dateVINLicense plateVehicleMileageCustomerTechnicianComplaintTotalStatusActions

Access control

User accounts

Manage workshop staff and their database rights.

UserRoleReadAddDeleteActions
@@ -49,7 +49,7 @@

Directory

Add customer

Vehicle register

Add vehicle

Use an existing customer email to link this vehicle to that customer.

Access control

Add user

Rights
-

Workshop floor

Add service record

Enter a known VIN or license plate to autofill the vehicle and customer. Use customer email when names are shared.

+

Workshop floor

Add service record

Enter a known VIN or license plate to autofill the vehicle and customer. Use customer email when names are shared.

Optional billing

Used parts and labor

Total: €0.00
\ No newline at end of file diff --git a/public/styles.css b/public/styles.css index e7c0097..5673c86 100644 --- a/public/styles.css +++ b/public/styles.css @@ -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; } } diff --git a/server.js b/server.js index b2d7f54..671164e 100644 --- a/server.js +++ b/server.js @@ -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`);