const state = { orders: [], users: [], vehicles: [], recordItems: [] }; const $ = (selector) => document.querySelector(selector); async function request(url, options) { const response = await fetch(url, options); const data = await response.json(); if (!response.ok) throw new Error(data.error || 'Request failed.'); return data; } function showError(error) { $('#notice').textContent = error.message; } function formatTime(value) { return new Date(value).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } function titleCase(value) { return value.replaceAll('_', ' '); } function emptyMessage(message) { return `
${message}
`; } function escapeHtml(value) { return String(value ?? '').replace(/[&<>'"]/g, (character) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[character])); } async function loadOverview() { try { const [summary, appointments, orders] = await Promise.all([request('/api/summary'), request('/api/appointments'), request('/api/orders')]); $('#today-appointments').textContent = summary.today_appointments; $('#active-orders').textContent = summary.active_orders; $('#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) => `
${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); } } async function loadCustomers() { try { const customers = await request('/api/customers'); $('#customers-table').innerHTML = customers.length ? customers.map((customer) => `${customer.first_name} ${customer.last_name}${customer.email || '—'}
${customer.phone || ''}${customer.vehicle_count}${new Date(customer.created_at || Date.now()).toLocaleDateString()}`).join('') : `${emptyMessage('No customers yet.')}`; } catch (error) { showError(error); } } async function loadOrders() { try { state.orders = await request('/api/service-records'); 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)}€${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)))); } 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); } } async function loadUsers() { try { state.users = await request('/api/users'); $('#users-table').innerHTML = state.users.map((user) => `${escapeHtml(user.display_name)}${escapeHtml(user.username)}
${escapeHtml(user.email || '')}${escapeHtml(titleCase(user.role))}${user.can_read ? 'Yes' : 'No'}${user.can_add ? 'Yes' : 'No'}${user.can_delete ? 'Yes' : 'No'} `).join(''); document.querySelectorAll('.delete-button').forEach((button) => button.addEventListener('click', deleteUser)); document.querySelectorAll('.edit-button[data-user-id]').forEach((button) => button.addEventListener('click', () => editUser(state.users.find((user) => user.user_id === button.dataset.userId)))); } 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] || ''; 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); } } async function updateOrderStatus(event) { try { await request(`/api/orders/${event.target.dataset.orderId}/status`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: event.target.value }) }); await loadOrders(); await loadOverview(); } catch (error) { showError(error); } } 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)); $('#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.' : viewName === 'users' ? 'Manage workshop access.' : 'Keep your workshop data portable.'; 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', () => { 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()); $('#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); $('#export-data-button').addEventListener('click', async () => { try { const response = await fetch('/api/data/export'); if (!response.ok) throw new Error('Unable to export database data.'); const blob = await response.blob(); const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = `car-service-backup-${new Date().toISOString().slice(0, 10)}.json`; link.click(); URL.revokeObjectURL(link.href); $('#notice').textContent = 'Database backup downloaded.'; } catch (error) { showError(error); } }); $('#import-data-input').addEventListener('change', async (event) => { const file = event.target.files[0]; if (!file) return; $('#import-file-name').textContent = file.name; if (!window.confirm('Importing a backup replaces all current database data. Continue?')) { event.target.value = ''; $('#import-file-name').textContent = 'No file selected.'; return; } try { const result = await request('/api/data/import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: await file.text() }); $('#notice').textContent = `Backup imported: ${result.imported} records restored.`; await loadOverview(); await loadCustomers(); await loadVehicles(); await loadOrders(); await loadUsers(); } catch (error) { showError(error); } finally { event.target.value = ''; } }); ['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 { const form = new FormData(event.target); const editId = event.target.dataset.editId; await request(editId ? `/api/users/${editId}` : '/api/users', { method: editId ? 'PATCH' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: form.get('username'), displayName: form.get('displayName'), email: form.get('email'), role: form.get('role'), canRead: form.has('canRead'), canAdd: form.has('canAdd'), canDelete: form.has('canDelete') }) }); event.target.reset(); delete event.target.dataset.editId; event.target.elements.username.disabled = false; $('#user-dialog-title').textContent = 'Add user'; $('#user-submit-button').textContent = 'Create user'; $('#user-dialog').close(); await loadUsers(); } catch (error) { showError(error); } }); $('#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(); 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); } }); loadOverview();