From d1807a424e2cc03c96e9b6a3ede36b5a9d53c5b7 Mon Sep 17 00:00:00 2001 From: Tero Huttunen Date: Wed, 26 Aug 2026 23:34:22 +0300 Subject: [PATCH] Added more functionality --- README.md | 2 +- init.sql | 4 ++ public/app.js | 102 ++++++++++++++++++++++++++++++-------- public/index.html | 18 ++++--- public/styles.css | 9 +++- server.js | 122 ++++++++++++++++++++++++++++++++++++++++------ 6 files changed, 212 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index c666e5e..9f2d45e 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ docker compose up -d Open the web dashboard at [http://localhost:3000](http://localhost:3000). -The dashboard's **Service records** view 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 the linked customer, vehicle, technician, and service order in one transaction. +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. 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. diff --git a/init.sql b/init.sql index 1151d03..fabab93 100644 --- a/init.sql +++ b/init.sql @@ -16,6 +16,10 @@ CREATE TABLE IF NOT EXISTS vehicles ( make TEXT NOT NULL, model TEXT NOT NULL, model_year INTEGER CHECK (model_year BETWEEN 1886 AND EXTRACT(YEAR FROM CURRENT_DATE)::INTEGER + 1), + engine TEXT, + transmission TEXT, + fuel_type TEXT, + color TEXT, vin CHAR(17) UNIQUE, mileage INTEGER NOT NULL DEFAULT 0 CHECK (mileage >= 0), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() diff --git a/public/app.js b/public/app.js index 7fd944d..1dc38f9 100644 --- a/public/app.js +++ b/public/app.js @@ -1,4 +1,4 @@ -const state = { orders: [], users: [] }; +const state = { orders: [], users: [], vehicles: [] }; const $ = (selector) => document.querySelector(selector); async function request(url, options) { @@ -22,7 +22,8 @@ async function loadOverview() { $('#customer-count').textContent = summary.customers; $('#vehicle-count').textContent = summary.vehicles; $('#appointments-list').innerHTML = appointments.length ? appointments.map((appointment) => `
${formatTime(appointment.scheduled_at)}
${appointment.customer_name}
${appointment.make} ${appointment.model} · ${appointment.reason}
${titleCase(appointment.status)}
`).join('') : emptyMessage('No appointments scheduled for today.'); - $('#orders-list').innerHTML = orders.length ? orders.slice(0, 5).map((order) => `
${order.registration_number}
${order.make} ${order.model}
${order.complaint}
${titleCase(order.status)}
`).join('') : emptyMessage('No active service orders.'); + $('#orders-list').innerHTML = orders.length ? orders.slice(0, 5).map((order) => `
${escapeHtml(order.registration_number)}
${escapeHtml(`${order.make} ${order.model}`)}
${escapeHtml(order.complaint)}
`).join('') : emptyMessage('No active service orders.'); + document.querySelectorAll('.open-order-button').forEach((button) => button.addEventListener('click', async () => { switchView('orders'); await loadOrders(); const record = state.orders.find((order) => order.service_order_id === button.dataset.orderId); if (record) editRecord(record); })); } catch (error) { showError(error); } } @@ -36,9 +37,31 @@ async function loadCustomers() { async function loadOrders() { try { state.orders = await request('/api/service-records'); - $('#orders-table').innerHTML = state.orders.length ? state.orders.map((order) => `${escapeHtml(order.service_date)}${escapeHtml(order.vin || '—')}${escapeHtml(order.license_plate)}${Number(order.mileage).toLocaleString()} km${escapeHtml(order.customer_name)}${escapeHtml(order.technician)}${escapeHtml(order.complaint)}`).join('') : `${emptyMessage('No service records yet.')}`; + renderOrders(); + } catch (error) { showError(error); } +} + +function renderOrders() { + const filters = Object.fromEntries(new FormData($('#order-filters'))); + const vehicleFilter = filters.vehicle.trim().toLowerCase(); + const customerFilter = filters.customer.trim().toLowerCase(); + const technicianFilter = filters.technician.trim().toLowerCase(); + const visibleOrders = state.orders.filter((order) => { + const vehicleText = `${order.vin || ''} ${order.license_plate || ''} ${order.make || ''} ${order.model || ''}`.toLowerCase(); + const customerText = `${order.customer_name || ''} ${order.customer_email || ''}`.toLowerCase(); + const technicianText = (order.technician || '').toLowerCase(); + 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.')}`; 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)))); +} + +async function loadVehicles() { + try { + state.vehicles = await request('/api/vehicles'); + $('#vehicles-table').innerHTML = state.vehicles.length ? state.vehicles.map((vehicle) => `${escapeHtml(`${vehicle.make || ''} ${vehicle.model || ''}`.trim())}
${vehicle.model_year || 'Year not set'}${escapeHtml(vehicle.license_plate)}
${escapeHtml(vehicle.vin || 'VIN not set')}${escapeHtml([vehicle.engine, vehicle.transmission, vehicle.fuel_type, vehicle.color].filter(Boolean).join(' · ') || 'Specifications not set')}${escapeHtml(vehicle.customer_name)}${Number(vehicle.mileage).toLocaleString()} km${vehicle.service_record_count}`).join('') : `${emptyMessage('No vehicles registered yet.')}`; } catch (error) { showError(error); } } @@ -51,22 +74,21 @@ async function loadUsers() { } catch (error) { showError(error); } } +function editUser(user) { + const form = $('#user-form'); form.dataset.editId = user.user_id; + form.elements.username.value = user.username; form.elements.username.disabled = true; + form.elements.displayName.value = user.display_name; form.elements.email.value = user.email || ''; + form.elements.role.value = user.role; form.elements.canRead.checked = user.can_read; form.elements.canAdd.checked = user.can_add; form.elements.canDelete.checked = user.can_delete; + $('#user-dialog-title').textContent = 'Edit user'; $('#user-submit-button').textContent = 'Save changes'; $('#user-dialog').showModal(); +} + +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] || ''; + $('#record-dialog-title').textContent = 'Edit service record'; $('#record-submit-button').textContent = 'Save changes'; $('#record-dialog').showModal(); +} + async function deleteUser(event) { - - function editUser(user) { - const form = $('#user-form'); form.dataset.editId = user.user_id; - form.elements.username.value = user.username; form.elements.username.disabled = true; - form.elements.displayName.value = user.display_name; form.elements.email.value = user.email || ''; - form.elements.role.value = user.role; form.elements.canRead.checked = user.can_read; form.elements.canAdd.checked = user.can_add; form.elements.canDelete.checked = user.can_delete; - $('#user-dialog-title').textContent = 'Edit user'; $('#user-submit-button').textContent = 'Save changes'; $('#user-dialog').showModal(); - } - - function editRecord(record) { - const form = $('#record-form'); form.dataset.editId = record.service_order_id; - form.elements.serviceDate.value = record.service_date.slice(0, 10); form.elements.customerName.value = record.customer_name; form.elements.customerEmail.value = record.customer_email || ''; - form.elements.vin.value = record.vin || ''; form.elements.licensePlate.value = record.license_plate; form.elements.mileage.value = record.mileage; form.elements.technicianName.value = record.technician; form.elements.complaint.value = record.complaint; - $('#record-dialog-title').textContent = 'Edit service record'; $('#record-submit-button').textContent = 'Save changes'; $('#record-dialog').showModal(); - } 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); } } @@ -78,25 +100,65 @@ async function updateOrderStatus(event) { function switchView(viewName) { document.querySelectorAll('.view').forEach((view) => view.classList.toggle('active-view', view.id === `${viewName}-view`)); document.querySelectorAll('.nav-item').forEach((item) => item.classList.toggle('active', item.dataset.view === viewName)); - $('#page-title').textContent = viewName === 'overview' ? 'Good morning, workshop.' : viewName === 'customers' ? 'Your customer directory.' : viewName === 'orders' ? 'Keep the bays moving.' : 'Manage workshop access.'; + $('#new-customer-button').hidden = viewName === 'customers' || viewName === 'vehicles' || viewName === 'orders'; + $('#page-title').textContent = viewName === 'overview' ? 'Good morning, workshop.' : viewName === 'customers' ? 'Your customer directory.' : viewName === 'vehicles' ? 'Know every vehicle.' : viewName === 'orders' ? 'Keep the bays moving.' : 'Manage workshop access.'; if (viewName === 'customers') loadCustomers(); + if (viewName === 'vehicles') loadVehicles(); if (viewName === 'orders') loadOrders(); if (viewName === 'users') loadUsers(); } document.querySelectorAll('[data-view]').forEach((button) => button.addEventListener('click', () => switchView(button.dataset.view))); document.querySelectorAll('#new-customer-button, #new-customer-button-secondary').forEach((button) => button.addEventListener('click', () => $('#customer-dialog').showModal())); +$('#cancel-customer-button').addEventListener('click', () => $('#customer-dialog').close()); +$('#close-customer-button').addEventListener('click', () => $('#customer-dialog').close()); $('#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', () => $('#record-dialog').showModal()); +$('#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-vehicle-button').addEventListener('click', () => $('#vehicle-dialog').showModal()); +$('#cancel-vehicle-button').addEventListener('click', () => $('#vehicle-dialog').close()); +$('#close-vehicle-button').addEventListener('click', () => $('#vehicle-dialog').close()); $('#cancel-record-button').addEventListener('click', () => $('#record-dialog').close()); $('#close-record-button').addEventListener('click', () => $('#record-dialog').close()); +$('#order-filters').addEventListener('input', renderOrders); +$('#order-filters').addEventListener('change', renderOrders); +['vin', 'licensePlate'].forEach((name) => $('#record-form').elements[name].addEventListener('blur', async (event) => { + const identifier = event.target.value.trim(); + if (!identifier) return; + try { + const vehicle = await request(`/api/vehicles/lookup?identifier=${encodeURIComponent(identifier)}`); + const form = $('#record-form'); + for (const field of ['vin', 'licensePlate', 'make', 'model', 'modelYear', 'engine', 'transmission', 'fuelType', 'color', 'customerName', 'customerEmail', 'customerPhone', 'mileage']) { + const value = vehicle[field === 'licensePlate' ? 'license_plate' : field === 'modelYear' ? 'model_year' : field === 'customerName' ? 'customer_name' : field === 'customerEmail' ? 'customer_email' : field === 'customerPhone' ? 'customer_phone' : field] || ''; + if (value) form.elements[field].value = value; + } + $('#notice').textContent = `Vehicle found: ${vehicle.make} ${vehicle.model}.`; + } catch (error) { if (!error.message.startsWith('No vehicle found')) showError(error); } +})); +['customerName', 'customerEmail'].forEach((name) => $('#record-form').elements[name].addEventListener('blur', async (event) => { + const value = event.target.value.trim(); + if (!value) return; + const parameter = name === 'customerEmail' ? 'email' : 'name'; + try { + const customer = await request(`/api/customers/lookup?${parameter}=${encodeURIComponent(value)}`); + const form = $('#record-form'); + form.elements.customerName.value = `${customer.first_name} ${customer.last_name}`; + form.elements.customerEmail.value = customer.email || ''; + form.elements.customerPhone.value = customer.phone || ''; + $('#notice').textContent = `Customer found: ${customer.first_name} ${customer.last_name}.`; + } catch (error) { if (!error.message.startsWith('No customer found') && !error.message.startsWith('More than one customer')) showError(error); } +})); $('#customer-form').addEventListener('submit', async (event) => { event.preventDefault(); try { await request('/api/customers', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(Object.fromEntries(new FormData(event.target))) }); event.target.reset(); $('#customer-dialog').close(); await loadOverview(); await loadCustomers(); } catch (error) { showError(error); } }); +$('#vehicle-form').addEventListener('submit', async (event) => { + event.preventDefault(); + try { await request('/api/vehicles', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(Object.fromEntries(new FormData(event.target))) }); event.target.reset(); $('#vehicle-dialog').close(); await loadVehicles(); await loadOverview(); } catch (error) { showError(error); } +}); + $('#user-form').addEventListener('submit', async (event) => { event.preventDefault(); try { diff --git a/public/index.html b/public/index.html index 2080f7e..38bace1 100644 --- a/public/index.html +++ b/public/index.html @@ -3,18 +3,19 @@ - Bayline Service Desk + eternityproject.fi | Service Desk
@@ -38,14 +39,17 @@

Directory

Customers

People and vehicles connected to your workshop.

CustomerContactVehiclesAdded
-

Workshop floor

Service records

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

Service dateVINLicense plateMileageCustomerTechnicianComplaintStatus
+

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

Access control

User accounts

Manage workshop staff and their database rights.

UserRoleReadAddDeleteActions
-

Directory

Add customer

+

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

+

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.

\ No newline at end of file diff --git a/public/styles.css b/public/styles.css index 7bc3d86..e7c0097 100644 --- a/public/styles.css +++ b/public/styles.css @@ -15,10 +15,15 @@ nav { display: grid; gap: 8px; margin-top: 76px; } .nav-item small { color: #6d8c7e; }.nav-item.active, .nav-item:hover { background: #294542; border-left-color: var(--mint); color: white; } .sidebar-footer { margin-top: auto; border-top: 1px solid #34504a; padding: 18px 4px 0; color: #a9c0b6; font-size: 12px; display: grid; grid-template-columns: 10px 1fr; gap: 4px 7px; }.sidebar-footer small { grid-column: 2; color: #6d8c7e; }.status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--mint-deep); margin-top: 4px; } .main-content { max-width: 1450px; width: 100%; padding: 48px 5.5%; margin: auto; }.topbar, .section-heading, .panel-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; }.eyebrow { color: #84918d; font-size: 11px; font-weight: 700; letter-spacing: .12em; margin: 0 0 7px; text-transform: uppercase; }.topbar h1, h2 { font: 600 32px 'Space Grotesk', sans-serif; letter-spacing: -.055em; margin: 0; }.section-heading h2, .panel-heading h2 { font-size: 23px; }.subheading { color: var(--muted); margin: 8px 0 0; } -.primary-button { background: var(--ink); border: 0; color: white; padding: 12px 17px; font-weight: 700; }.primary-button:hover { background: #30423f; }.text-button { background: transparent; border: 0; color: #71847b; padding: 5px 0; }.text-button.light { color: #bad9cc; }.notice { min-height: 20px; color: #b15b27; padding-top: 20px; }.notice:empty { padding-top: 0; } +.primary-button { background: var(--ink); border: 0; color: white; padding: 12px 17px; font-weight: 700; }.primary-button:hover { background: #30423f; }.text-button { background: transparent; border: 0; color: #71847b; padding: 5px 0; }.text-button.light { color: var(--mint); }.open-order-button { border: 1px solid var(--mint); padding: 7px 12px; font-weight: 700; white-space: nowrap; }.open-order-button:hover { background: var(--mint); color: var(--navy); }.notice { min-height: 20px; color: #b15b27; padding-top: 20px; }.notice:empty { padding-top: 0; } .metric-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin: 42px 0 38px; }.metric-card { background: var(--white); border: 1px solid var(--line); min-height: 155px; padding: 20px; display: flex; flex-direction: column; }.metric-card.accent { background: var(--mint); border-color: var(--mint); }.metric-label { color: #6c7b75; font-size: 12px; font-weight: 700; }.metric-card strong { font: 600 44px 'Space Grotesk', sans-serif; letter-spacing: -.08em; margin: 18px 0 10px; }.metric-note { color: #85918d; font-size: 12px; }.content-grid { display: grid; grid-template-columns: 1.05fr .95fr; gap: 16px; }.panel { background: var(--white); border: 1px solid var(--line); padding: 25px; }.dark-panel { background: var(--navy); border-color: var(--navy); color: white; }.panel-heading { align-items: flex-start; }.list-container { margin-top: 22px; }.list-row { border-top: 1px solid var(--line); display: grid; grid-template-columns: 74px 1fr auto; gap: 14px; padding: 15px 0; align-items: center; }.dark-panel .list-row { border-color: #34504a; }.time { color: #71847b; font-weight: 700; font-size: 12px; }.dark-panel .time { color: var(--mint); }.item-title { font-weight: 700; }.item-meta { color: var(--muted); font-size: 12px; margin-top: 4px; }.dark-panel .item-meta { color: #9bb3aa; }.status { display: inline-block; background: #edf2ee; color: #60706a; font-size: 11px; font-weight: 700; padding: 5px 8px; text-transform: capitalize; white-space: nowrap; }.status.ready, .status.confirmed { background: #dff5e8; color: #27784a; }.status.in_progress { background: #fff0dc; color: #a36218; }.empty-state { color: var(--muted); padding: 30px 0; text-align: center; }.view { display: none; }.active-view { display: block; }.section-heading { align-items: flex-start; margin: 25px 0 32px; }.table-panel { padding: 0; }.table-wrap { overflow-x: auto; }table { border-collapse: collapse; width: 100%; }th, td { border-bottom: 1px solid var(--line); padding: 17px 20px; text-align: left; white-space: nowrap; }th { color: #8a9691; font-size: 11px; letter-spacing: .1em; text-transform: uppercase; }td { color: #465550; }td strong { color: var(--ink); display: block; }.dialog-heading { display: flex; justify-content: space-between; margin-bottom: 25px; }.close-button { background: transparent; border: 0; font-size: 28px; line-height: 1; color: var(--muted); }.dialog-heading h2 { font-size: 24px; }dialog { border: 0; padding: 0; width: min(420px, calc(100% - 32px)); box-shadow: 0 18px 60px #13272533; }dialog::backdrop { background: #122b2a99; }.dialog-heading, dialog form { padding: 26px; }dialog form { display: grid; gap: 15px; }dialog form .dialog-heading { margin: -26px -26px 0; }label { color: #64726d; font-size: 12px; font-weight: 700; display: grid; gap: 7px; }input { border: 1px solid var(--line); padding: 11px; color: var(--ink); outline-color: var(--mint-deep); } @media (max-width: 900px) { .app-shell { grid-template-columns: 1fr; }.sidebar { padding: 18px; }.sidebar nav { display: flex; margin-top: 24px; overflow-x: auto; }.nav-item { flex: 1; min-width: 145px; }.sidebar-footer { display: none; }.main-content { padding: 30px 20px; }.metric-grid { grid-template-columns: repeat(2, 1fr); margin-top: 30px; }.content-grid { grid-template-columns: 1fr; } } @media (max-width: 520px) { .topbar { align-items: flex-start; flex-direction: column; }.topbar h1 { font-size: 27px; }.metric-grid { grid-template-columns: 1fr 1fr; gap: 8px; }.metric-card { min-height: 135px; padding: 15px; }.metric-card strong { font-size: 34px; }.panel { padding: 18px; }.list-row { grid-template-columns: 58px 1fr; }.list-row .status { grid-column: 2; justify-self: start; }.section-heading { flex-direction: column; } } .permission { color: #a15b4e; font-size: 12px; font-weight: 700; }.permission.allowed { color: #27784a; }.delete-button { background: transparent; border: 1px solid #e5c9c4; color: #a15b4e; padding: 7px 10px; }.delete-button:hover { background: #fff1ee; }select { border: 1px solid var(--line); padding: 10px; background: white; color: var(--ink); }fieldset { border: 1px solid var(--line); display: grid; gap: 10px; padding: 12px; }legend { color: var(--muted); font-size: 11px; font-weight: 700; text-transform: uppercase; }.check-label { display: flex; align-items: center; gap: 8px; }.check-label input { accent-color: var(--navy); } .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); } \ No newline at end of file +.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; } +.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; } } +@media (max-width: 520px) { .filter-bar { grid-template-columns: 1fr; } } \ No newline at end of file diff --git a/server.js b/server.js index 63dfba5..b2d7f54 100644 --- a/server.js +++ b/server.js @@ -34,8 +34,8 @@ app.get('/api/service-records', async (_request, response) => { try { const result = await pool.query(` SELECT so.service_order_id, so.status, so.complaint, so.opened_at, so.service_date, - btrim(v.vin) AS vin, v.registration_number AS license_plate, v.mileage, - c.first_name || ' ' || c.last_name AS customer_name, c.email AS customer_email, + 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(e.first_name || ' ' || e.last_name, 'Unassigned') AS technician FROM service_orders so JOIN vehicles v ON v.vehicle_id = so.vehicle_id @@ -48,8 +48,72 @@ app.get('/api/service-records', async (_request, response) => { } catch (error) { response.status(500).json({ error: 'Unable to load service records.' }); } }); +app.get('/api/vehicles', async (_request, response) => { + try { + const result = await pool.query(` + SELECT v.vehicle_id, v.registration_number AS license_plate, btrim(v.vin) AS vin, + 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, + COUNT(so.service_order_id)::int AS service_record_count + FROM vehicles v + JOIN customers c ON c.customer_id = v.customer_id + LEFT JOIN service_orders so ON so.vehicle_id = v.vehicle_id + GROUP BY v.vehicle_id, c.customer_id + ORDER BY v.created_at DESC + `); + response.json(result.rows); + } catch (error) { response.status(500).json({ error: 'Unable to load vehicles.' }); } +}); + +app.get('/api/vehicles/lookup', async (request, response) => { + const identifier = String(request.query.identifier || '').trim(); + if (!identifier) return response.status(400).json({ error: 'Enter a VIN or license plate.' }); + try { + const result = await pool.query(` + SELECT v.vehicle_id, v.registration_number AS license_plate, btrim(v.vin) AS vin, + 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 + FROM vehicles v JOIN customers c ON c.customer_id = v.customer_id + WHERE UPPER(v.registration_number) = UPPER($1) OR btrim(v.vin) = UPPER($1) + LIMIT 1 + `, [identifier]); + if (!result.rowCount) return response.status(404).json({ error: 'No vehicle found for that VIN or license plate.' }); + response.json(result.rows[0]); + } catch (error) { response.status(500).json({ error: 'Unable to look up vehicle.' }); } +}); + +app.post('/api/vehicles', async (request, response) => { + const { customerName, customerEmail, customerPhone, vin, licensePlate, make, model, modelYear, engine, transmission, fuelType, color, mileage } = request.body; + const parsedMileage = Number(mileage || 0); + if (!customerName || !vin || !licensePlate || !make || !model || !Number.isInteger(parsedMileage) || parsedMileage < 0) return response.status(400).json({ error: 'Customer, VIN, license plate, make, model, and non-negative mileage are required.' }); + const customerParts = customerName.trim().split(/\s+/); + const firstName = customerParts.shift(); + const lastName = customerParts.join(' ') || firstName; + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const existingCustomer = customerEmail?.trim() ? await client.query(`SELECT customer_id FROM customers WHERE LOWER(email) = LOWER($1) LIMIT 1`, [customerEmail.trim()]) : { rowCount: 0, rows: [] }; + let customerId; + if (existingCustomer.rowCount) { + customerId = existingCustomer.rows[0].customer_id; + await client.query(`UPDATE customers SET first_name = $1, last_name = $2, phone = NULLIF($3, '') WHERE customer_id = $4`, [firstName, lastName, customerPhone?.trim() || '', customerId]); + } else { + const customer = await client.query(`INSERT INTO customers (first_name, last_name, email, phone) VALUES ($1, $2, NULLIF($3, ''), NULLIF($4, '')) RETURNING customer_id`, [firstName, lastName, customerEmail?.trim() || '', customerPhone?.trim() || '']); + customerId = customer.rows[0].customer_id; + } + const vehicle = await client.query(`INSERT INTO vehicles (customer_id, registration_number, make, model, model_year, engine, transmission, fuel_type, color, vin, mileage) VALUES ($1, $2, $3, $4, NULLIF($5, '')::integer, NULLIF($6, ''), NULLIF($7, ''), NULLIF($8, ''), NULLIF($9, ''), $10, $11) RETURNING vehicle_id`, [customerId, licensePlate.trim(), make.trim(), model.trim(), modelYear || '', engine?.trim() || '', transmission?.trim() || '', fuelType?.trim() || '', color?.trim() || '', vin.trim(), parsedMileage]); + await client.query('COMMIT'); + response.status(201).json({ vehicle_id: vehicle.rows[0].vehicle_id }); + } catch (error) { + await client.query('ROLLBACK'); + const status = error.code === '23505' ? 409 : 500; + const message = error.constraint === 'vehicles_vin_key' ? 'That VIN is already registered.' : error.constraint === 'vehicles_registration_number_key' ? 'That license plate is already registered.' : status === 409 ? 'A customer with that email already exists.' : 'Unable to create vehicle.'; + response.status(status).json({ error: message }); + } finally { client.release(); } +}); + app.post('/api/service-records', async (request, response) => { - const { customerName, customerEmail, vin, licensePlate, mileage, serviceDate, technicianName, complaint } = request.body; + const { customerName, customerEmail, customerPhone, vin, licensePlate, mileage, serviceDate, technicianName, complaint, make, model, modelYear, engine, transmission, fuelType, color } = request.body; 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.' }); @@ -64,18 +128,30 @@ app.post('/api/service-records', async (request, response) => { const client = await pool.connect(); try { await client.query('BEGIN'); - const customer = await client.query( - `INSERT INTO customers (first_name, last_name, email) VALUES ($1, $2, NULLIF($3, '')) RETURNING customer_id`, - [firstName, lastName, customerEmail?.trim() || ''] - ); + const existingVehicle = await client.query(`SELECT vehicle_id, customer_id FROM vehicles WHERE UPPER(registration_number) = UPPER($1) OR btrim(vin) = UPPER($2) LIMIT 1`, [licensePlate.trim(), vin.trim()]); + let customerId; + if (existingVehicle.rowCount) { + customerId = existingVehicle.rows[0].customer_id; + await client.query(`UPDATE customers SET first_name = $1, last_name = $2, email = NULLIF($3, ''), phone = NULLIF($4, '') WHERE customer_id = $5`, [firstName, lastName, customerEmail?.trim() || '', customerPhone?.trim() || '', customerId]); + } else { + const existingCustomer = customerEmail?.trim() + ? await client.query(`SELECT customer_id FROM customers WHERE LOWER(email) = LOWER($1) LIMIT 1`, [customerEmail.trim()]) + : { rowCount: 0, rows: [] }; + if (existingCustomer.rowCount) { + customerId = existingCustomer.rows[0].customer_id; + await client.query(`UPDATE customers SET first_name = $1, last_name = $2, phone = NULLIF($3, '') WHERE customer_id = $4`, [firstName, lastName, customerPhone?.trim() || '', customerId]); + } else { + const customer = await client.query(`INSERT INTO customers (first_name, last_name, email, phone) VALUES ($1, $2, NULLIF($3, ''), NULLIF($4, '')) RETURNING customer_id`, [firstName, lastName, customerEmail?.trim() || '', customerPhone?.trim() || '']); + customerId = customer.rows[0].customer_id; + } + } const technician = await client.query( `INSERT INTO employees (first_name, last_name, role) VALUES ($1, $2, 'technician') RETURNING employee_id`, [technicianFirstName, technicianLastName] ); - const vehicle = await client.query( - `INSERT INTO vehicles (customer_id, registration_number, make, model, vin, mileage) VALUES ($1, $2, 'Not specified', 'Not specified', $3, $4) RETURNING vehicle_id`, - [customer.rows[0].customer_id, licensePlate.trim(), vin.trim(), parsedMileage] - ); + const vehicle = existingVehicle.rowCount + ? await client.query(`UPDATE vehicles SET customer_id = $1, registration_number = $2, make = COALESCE(NULLIF($3, ''), make), model = COALESCE(NULLIF($4, ''), model), model_year = NULLIF($5, '')::integer, engine = NULLIF($6, ''), transmission = NULLIF($7, ''), fuel_type = NULLIF($8, ''), color = NULLIF($9, ''), vin = $10, mileage = $11 WHERE vehicle_id = $12 RETURNING vehicle_id`, [customerId, licensePlate.trim(), make?.trim() || '', model?.trim() || '', modelYear || '', engine?.trim() || '', transmission?.trim() || '', fuelType?.trim() || '', color?.trim() || '', vin.trim(), parsedMileage, existingVehicle.rows[0].vehicle_id]) + : await client.query(`INSERT INTO vehicles (customer_id, registration_number, make, model, model_year, engine, transmission, fuel_type, color, vin, mileage) VALUES ($1, $2, NULLIF($3, ''), NULLIF($4, ''), NULLIF($5, '')::integer, NULLIF($6, ''), NULLIF($7, ''), NULLIF($8, ''), NULLIF($9, ''), $10, $11) RETURNING vehicle_id`, [customerId, licensePlate.trim(), make?.trim() || 'Not specified', model?.trim() || 'Not specified', modelYear || '', engine?.trim() || '', transmission?.trim() || '', fuelType?.trim() || '', color?.trim() || '', vin.trim(), parsedMileage]); const record = await client.query( `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] @@ -85,12 +161,13 @@ app.post('/api/service-records', async (request, response) => { } catch (error) { await client.query('ROLLBACK'); const status = error.code === '23505' ? 409 : 500; - response.status(status).json({ error: status === 409 ? 'That VIN or license plate is already registered.' : 'Unable to create service record.' }); + const message = error.constraint === 'vehicles_vin_key' ? 'That VIN is already registered.' : error.constraint === 'vehicles_registration_number_key' ? 'That license plate is already registered.' : status === 409 ? 'A record with one of these values already exists.' : 'Unable to create service record.'; + response.status(status).json({ error: message }); } finally { client.release(); } }); app.patch('/api/service-records/:id', async (request, response) => { - const { customerName, customerEmail, vin, licensePlate, mileage, serviceDate, technicianName, complaint } = request.body; + const { customerName, customerEmail, customerPhone, vin, licensePlate, mileage, serviceDate, technicianName, complaint, make, model, modelYear, engine, transmission, fuelType, color } = request.body; 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+/); @@ -102,9 +179,9 @@ app.patch('/api/service-records/:id', async (request, response) => { if (!current.rowCount) { await client.query('ROLLBACK'); return response.status(404).json({ error: 'Service record not found.' }); } const customerFirst = customerParts.shift(); const technicianFirst = technicianParts.shift(); - await client.query('UPDATE customers SET first_name = $1, last_name = $2, email = NULLIF($3, \'\') WHERE customer_id = $4', [customerFirst, customerParts.join(' ') || customerFirst, customerEmail?.trim() || '', current.rows[0].customer_id]); + await client.query('UPDATE customers SET first_name = $1, last_name = $2, email = NULLIF($3, \'\'), phone = NULLIF($4, \'\') WHERE customer_id = $5', [customerFirst, customerParts.join(' ') || customerFirst, customerEmail?.trim() || '', customerPhone?.trim() || '', current.rows[0].customer_id]); 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, mileage = $3 WHERE vehicle_id = $4', [vin.trim(), licensePlate.trim(), parsedMileage, current.rows[0].vehicle_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('COMMIT'); response.json({ service_order_id: request.params.id }); @@ -121,6 +198,20 @@ app.get('/api/customers', async (_request, response) => { } catch (error) { response.status(500).json({ error: 'Unable to load customers.' }); } }); +app.get('/api/customers/lookup', async (request, response) => { + const email = String(request.query.email || '').trim(); + const name = String(request.query.name || '').trim(); + if (!email && !name) return response.status(400).json({ error: 'Enter a customer email or name.' }); + try { + const result = email + ? await pool.query(`SELECT customer_id, first_name, last_name, email, phone FROM customers WHERE LOWER(email) = LOWER($1) LIMIT 1`, [email]) + : await pool.query(`SELECT customer_id, first_name, last_name, email, phone FROM customers WHERE LOWER(first_name || ' ' || last_name) = LOWER($1) LIMIT 2`, [name]); + if (!result.rowCount) return response.status(404).json({ error: 'No customer found.' }); + if (!email && result.rowCount > 1) return response.status(409).json({ error: 'More than one customer has that name. Use email to identify the customer.' }); + response.json(result.rows[0]); + } catch (error) { response.status(500).json({ error: 'Unable to look up customer.' }); } +}); + app.post('/api/customers', async (request, response) => { const { firstName, lastName, email, phone } = request.body; if (!firstName || !lastName) return response.status(400).json({ error: 'First name and last name are required.' }); @@ -184,6 +275,7 @@ app.delete('/api/users/:id', async (request, response) => { app.use((_request, response) => response.sendFile(path.join(__dirname, 'public', 'index.html'))); 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_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`);