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)}
Open `).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)} ${titleCase(order.status)} Open In progress Waiting for parts Ready Closed Edit `).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)} ${titleCase(order.status)} Open In progress Waiting for parts Ready Closed Edit `).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.
+ New customer Customer Contact Vehicles Added
-
Workshop floor
Service records VIN, plate, mileage, date, customer, and technician in one view.
+ Add service record Service date VIN License plate Mileage Customer Technician Complaint Status
+
Vehicle register
Vehicles Find a vehicle by plate or VIN and keep its specifications together.
+ Add vehicle Vehicle Identifiers Specifications Owner Mileage Records
+
+
Workshop floor
Service records VIN, plate, vehicle, mileage, date, customer, and technician in one view.
+ Add service record Service date VIN License plate Vehicle Mileage Customer Technician Complaint Status Actions
Access control
User accounts Manage workshop staff and their database rights.
+ Add user User Role Read Add Delete Actions
-
+
+
-
+ 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.
Service date VIN License plate number Make Model Model year Engine Transmission Fuel type Color Customer name Customer email Customer phone Car mileage Technician Complaint or service requested Cancel Create service record