Added more functionality
This commit is contained in:
@@ -10,7 +10,7 @@ docker compose up -d
|
|||||||
|
|
||||||
Open the web dashboard at [http://localhost:3000](http://localhost:3000).
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ CREATE TABLE IF NOT EXISTS vehicles (
|
|||||||
make TEXT NOT NULL,
|
make TEXT NOT NULL,
|
||||||
model TEXT NOT NULL,
|
model TEXT NOT NULL,
|
||||||
model_year INTEGER CHECK (model_year BETWEEN 1886 AND EXTRACT(YEAR FROM CURRENT_DATE)::INTEGER + 1),
|
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,
|
vin CHAR(17) UNIQUE,
|
||||||
mileage INTEGER NOT NULL DEFAULT 0 CHECK (mileage >= 0),
|
mileage INTEGER NOT NULL DEFAULT 0 CHECK (mileage >= 0),
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
|||||||
+82
-20
@@ -1,4 +1,4 @@
|
|||||||
const state = { orders: [], users: [] };
|
const state = { orders: [], users: [], vehicles: [] };
|
||||||
const $ = (selector) => document.querySelector(selector);
|
const $ = (selector) => document.querySelector(selector);
|
||||||
|
|
||||||
async function request(url, options) {
|
async function request(url, options) {
|
||||||
@@ -22,7 +22,8 @@ async function loadOverview() {
|
|||||||
$('#customer-count').textContent = summary.customers;
|
$('#customer-count').textContent = summary.customers;
|
||||||
$('#vehicle-count').textContent = summary.vehicles;
|
$('#vehicle-count').textContent = summary.vehicles;
|
||||||
$('#appointments-list').innerHTML = appointments.length ? appointments.map((appointment) => `<div class="list-row"><span class="time">${formatTime(appointment.scheduled_at)}</span><div><div class="item-title">${appointment.customer_name}</div><div class="item-meta">${appointment.make} ${appointment.model} · ${appointment.reason}</div></div><span class="status ${appointment.status}">${titleCase(appointment.status)}</span></div>`).join('') : emptyMessage('No appointments scheduled for today.');
|
$('#appointments-list').innerHTML = appointments.length ? appointments.map((appointment) => `<div class="list-row"><span class="time">${formatTime(appointment.scheduled_at)}</span><div><div class="item-title">${appointment.customer_name}</div><div class="item-meta">${appointment.make} ${appointment.model} · ${appointment.reason}</div></div><span class="status ${appointment.status}">${titleCase(appointment.status)}</span></div>`).join('') : emptyMessage('No appointments scheduled for today.');
|
||||||
$('#orders-list').innerHTML = orders.length ? orders.slice(0, 5).map((order) => `<div class="list-row"><span class="time">${order.registration_number}</span><div><div class="item-title">${order.make} ${order.model}</div><div class="item-meta">${order.complaint}</div></div><span class="status ${order.status}">${titleCase(order.status)}</span></div>`).join('') : emptyMessage('No active service orders.');
|
$('#orders-list').innerHTML = orders.length ? orders.slice(0, 5).map((order) => `<div class="list-row"><span class="time">${escapeHtml(order.registration_number)}</span><div><div class="item-title">${escapeHtml(`${order.make} ${order.model}`)}</div><div class="item-meta">${escapeHtml(order.complaint)}</div></div><button class="text-button light open-order-button" type="button" data-order-id="${order.service_order_id}">Open</button></div>`).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); }
|
} catch (error) { showError(error); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,9 +37,31 @@ async function loadCustomers() {
|
|||||||
async function loadOrders() {
|
async function loadOrders() {
|
||||||
try {
|
try {
|
||||||
state.orders = await request('/api/service-records');
|
state.orders = await request('/api/service-records');
|
||||||
$('#orders-table').innerHTML = state.orders.length ? state.orders.map((order) => `<tr><td>${escapeHtml(order.service_date)}</td><td>${escapeHtml(order.vin || '—')}</td><td><strong>${escapeHtml(order.license_plate)}</strong></td><td>${Number(order.mileage).toLocaleString()} km</td><td>${escapeHtml(order.customer_name)}</td><td>${escapeHtml(order.technician)}</td><td>${escapeHtml(order.complaint)}</td><td><select class="status-select" data-order-id="${order.service_order_id}"><option value="${order.status}" selected>${titleCase(order.status)}</option><option value="open">Open</option><option value="in_progress">In progress</option><option value="waiting_for_parts">Waiting for parts</option><option value="ready">Ready</option><option value="closed">Closed</option></select></td><td><button class="edit-button" data-record-id="${order.service_order_id}">Edit</button></td></tr>`).join('') : `<tr><td colspan="9">${emptyMessage('No service records yet.')}</td></tr>`;
|
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) => `<tr><td>${escapeHtml(order.service_date)}</td><td>${escapeHtml(order.vin || '—')}</td><td><strong>${escapeHtml(order.license_plate)}</strong></td><td>${escapeHtml(`${order.make || ''} ${order.model || ''}`.trim())}</td><td>${Number(order.mileage).toLocaleString()} km</td><td>${escapeHtml(order.customer_name)}</td><td>${escapeHtml(order.technician)}</td><td>${escapeHtml(order.complaint)}</td><td><select class="status-select" data-order-id="${order.service_order_id}"><option value="${order.status}" selected>${titleCase(order.status)}</option><option value="open">Open</option><option value="in_progress">In progress</option><option value="waiting_for_parts">Waiting for parts</option><option value="ready">Ready</option><option value="closed">Closed</option></select></td><td><button class="edit-button" data-record-id="${order.service_order_id}">Edit</button></td></tr>`).join('') : `<tr><td colspan="10">${emptyMessage(state.orders.length ? 'No service records match these filters.' : 'No service records yet.')}</td></tr>`;
|
||||||
document.querySelectorAll('.status-select').forEach((select) => select.addEventListener('change', updateOrderStatus));
|
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))));
|
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) => `<tr><td><strong>${escapeHtml(`${vehicle.make || ''} ${vehicle.model || ''}`.trim())}</strong><br />${vehicle.model_year || 'Year not set'}</td><td><strong>${escapeHtml(vehicle.license_plate)}</strong><br />${escapeHtml(vehicle.vin || 'VIN not set')}</td><td>${escapeHtml([vehicle.engine, vehicle.transmission, vehicle.fuel_type, vehicle.color].filter(Boolean).join(' · ') || 'Specifications not set')}</td><td>${escapeHtml(vehicle.customer_name)}</td><td>${Number(vehicle.mileage).toLocaleString()} km</td><td>${vehicle.service_record_count}</td></tr>`).join('') : `<tr><td colspan="6">${emptyMessage('No vehicles registered yet.')}</td></tr>`;
|
||||||
} catch (error) { showError(error); }
|
} catch (error) { showError(error); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,22 +74,21 @@ async function loadUsers() {
|
|||||||
} catch (error) { showError(error); }
|
} 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) {
|
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;
|
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); }
|
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) {
|
function switchView(viewName) {
|
||||||
document.querySelectorAll('.view').forEach((view) => view.classList.toggle('active-view', view.id === `${viewName}-view`));
|
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));
|
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 === 'customers') loadCustomers();
|
||||||
|
if (viewName === 'vehicles') loadVehicles();
|
||||||
if (viewName === 'orders') loadOrders();
|
if (viewName === 'orders') loadOrders();
|
||||||
if (viewName === 'users') loadUsers();
|
if (viewName === 'users') loadUsers();
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelectorAll('[data-view]').forEach((button) => button.addEventListener('click', () => switchView(button.dataset.view)));
|
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()));
|
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(); });
|
$('#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());
|
$('#cancel-user-button').addEventListener('click', () => $('#user-dialog').close());
|
||||||
$('#close-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());
|
$('#cancel-record-button').addEventListener('click', () => $('#record-dialog').close());
|
||||||
$('#close-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) => {
|
$('#customer-form').addEventListener('submit', async (event) => {
|
||||||
event.preventDefault();
|
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); }
|
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) => {
|
$('#user-form').addEventListener('submit', async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
try {
|
try {
|
||||||
|
|||||||
+11
-7
@@ -3,18 +3,19 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Bayline Service Desk</title>
|
<title>eternityproject.fi | Service Desk</title>
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="brand"><span class="brand-mark">B</span><span>Bayline<br /><strong>Service Desk</strong></span></div>
|
<div class="brand"><span class="brand-mark">E</span><span>eternityproject.fi<br /><strong>Service Desk</strong></span></div>
|
||||||
<nav aria-label="Main navigation">
|
<nav aria-label="Main navigation">
|
||||||
<button class="nav-item active" data-view="overview"><span>Overview</span><small>01</small></button>
|
<button class="nav-item active" data-view="overview"><span>Overview</span><small>01</small></button>
|
||||||
<button class="nav-item" data-view="customers"><span>Customers</span><small>02</small></button>
|
<button class="nav-item" data-view="customers"><span>Customers</span><small>02</small></button>
|
||||||
<button class="nav-item" data-view="orders"><span>Service records</span><small>03</small></button>
|
<button class="nav-item" data-view="vehicles"><span>Vehicles</span><small>03</small></button>
|
||||||
<button class="nav-item" data-view="users"><span>Accounts</span><small>04</small></button>
|
<button class="nav-item" data-view="orders"><span>Service records</span><small>04</small></button>
|
||||||
|
<button class="nav-item" data-view="users"><span>Accounts</span><small>05</small></button>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="sidebar-footer"><span class="status-dot"></span><span>Database online</span><small>PostgreSQL 16</small></div>
|
<div class="sidebar-footer"><span class="status-dot"></span><span>Database online</span><small>PostgreSQL 16</small></div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -38,14 +39,17 @@
|
|||||||
|
|
||||||
<section id="customers-view" class="view"><div class="section-heading"><div><p class="eyebrow">Directory</p><h2>Customers</h2><p class="subheading">People and vehicles connected to your workshop.</p></div><button class="primary-button" id="new-customer-button-secondary">+ New customer</button></div><div class="panel table-panel"><div class="table-wrap"><table><thead><tr><th>Customer</th><th>Contact</th><th>Vehicles</th><th>Added</th></tr></thead><tbody id="customers-table"></tbody></table></div></div></section>
|
<section id="customers-view" class="view"><div class="section-heading"><div><p class="eyebrow">Directory</p><h2>Customers</h2><p class="subheading">People and vehicles connected to your workshop.</p></div><button class="primary-button" id="new-customer-button-secondary">+ New customer</button></div><div class="panel table-panel"><div class="table-wrap"><table><thead><tr><th>Customer</th><th>Contact</th><th>Vehicles</th><th>Added</th></tr></thead><tbody id="customers-table"></tbody></table></div></div></section>
|
||||||
|
|
||||||
<section id="orders-view" class="view"><div class="section-heading"><div><p class="eyebrow">Workshop floor</p><h2>Service records</h2><p class="subheading">VIN, plate, mileage, date, customer, and technician in one view.</p></div><button class="primary-button" id="new-record-button">+ Add service record</button></div><div class="panel table-panel"><div class="table-wrap"><table><thead><tr><th>Service date</th><th>VIN</th><th>License plate</th><th>Mileage</th><th>Customer</th><th>Technician</th><th>Complaint</th><th>Status</th></tr></thead><tbody id="orders-table"></tbody></table></div></div></section>
|
<section id="vehicles-view" class="view"><div class="section-heading"><div><p class="eyebrow">Vehicle register</p><h2>Vehicles</h2><p class="subheading">Find a vehicle by plate or VIN and keep its specifications together.</p></div><button class="primary-button" id="new-vehicle-button">+ Add vehicle</button></div><div class="panel table-panel"><div class="table-wrap"><table><thead><tr><th>Vehicle</th><th>Identifiers</th><th>Specifications</th><th>Owner</th><th>Mileage</th><th>Records</th></tr></thead><tbody id="vehicles-table"></tbody></table></div></div></section>
|
||||||
|
|
||||||
|
<section id="orders-view" class="view"><div class="section-heading"><div><p class="eyebrow">Workshop floor</p><h2>Service records</h2><p class="subheading">VIN, plate, vehicle, mileage, date, customer, and technician in one view.</p></div><button class="primary-button" id="new-record-button">+ Add service record</button></div><form class="filter-bar" id="order-filters"><label>Vehicle<input name="vehicle" placeholder="VIN, plate, make, or model" /></label><label>Customer<input name="customer" placeholder="Customer name or email" /></label><label>Technician<input name="technician" placeholder="Technician name" /></label><label>From<input name="dateFrom" type="date" /></label><label>To<input name="dateTo" type="date" /></label><label>Status<select name="status"><option value="">All statuses</option><option value="open">Open</option><option value="in_progress">In progress</option><option value="waiting_for_parts">Waiting for parts</option><option value="ready">Ready</option><option value="closed">Closed</option><option value="cancelled">Cancelled</option></select></label><button class="secondary-button filter-clear" type="reset">Clear filters</button></form><div class="panel table-panel"><div class="table-wrap"><table><thead><tr><th>Service date</th><th>VIN</th><th>License plate</th><th>Vehicle</th><th>Mileage</th><th>Customer</th><th>Technician</th><th>Complaint</th><th>Status</th><th>Actions</th></tr></thead><tbody id="orders-table"></tbody></table></div></div></section>
|
||||||
<section id="users-view" class="view"><div class="section-heading"><div><p class="eyebrow">Access control</p><h2>User accounts</h2><p class="subheading">Manage workshop staff and their database rights.</p></div><button class="primary-button" id="new-user-button">+ Add user</button></div><div class="panel table-panel"><div class="table-wrap"><table><thead><tr><th>User</th><th>Role</th><th>Read</th><th>Add</th><th>Delete</th><th>Actions</th></tr></thead><tbody id="users-table"></tbody></table></div></div></section>
|
<section id="users-view" class="view"><div class="section-heading"><div><p class="eyebrow">Access control</p><h2>User accounts</h2><p class="subheading">Manage workshop staff and their database rights.</p></div><button class="primary-button" id="new-user-button">+ Add user</button></div><div class="panel table-panel"><div class="table-wrap"><table><thead><tr><th>User</th><th>Role</th><th>Read</th><th>Add</th><th>Delete</th><th>Actions</th></tr></thead><tbody id="users-table"></tbody></table></div></div></section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<dialog id="customer-dialog"><form method="dialog" id="customer-form"><div class="dialog-heading"><div><p class="eyebrow">Directory</p><h2>Add customer</h2></div><button class="close-button" value="cancel" aria-label="Close">×</button></div><label>First name<input name="firstName" required /></label><label>Last name<input name="lastName" required /></label><label>Email<input name="email" type="email" /></label><label>Phone<input name="phone" type="tel" /></label><button class="primary-button" value="default">Save customer</button></form></dialog>
|
<dialog id="customer-dialog"><form method="dialog" id="customer-form"><div class="dialog-heading"><div><p class="eyebrow">Directory</p><h2>Add customer</h2></div><button class="close-button" type="button" id="close-customer-button" aria-label="Close">×</button></div><label>First name<input name="firstName" required /></label><label>Last name<input name="lastName" required /></label><label>Email<input name="email" type="email" /></label><label>Phone<input name="phone" type="tel" /></label><div class="dialog-actions"><button class="secondary-button" type="button" id="cancel-customer-button">Cancel</button><button class="primary-button" value="default">Save customer</button></div></form></dialog>
|
||||||
|
<dialog id="vehicle-dialog"><form method="dialog" id="vehicle-form"><div class="dialog-heading"><div><p class="eyebrow">Vehicle register</p><h2>Add vehicle</h2></div><button class="close-button" type="button" id="close-vehicle-button" aria-label="Close">×</button></div><p class="form-hint">Use an existing customer email to link this vehicle to that customer.</p><label>Customer name<input name="customerName" required /></label><label>Customer email<input name="customerEmail" type="email" /></label><label>Customer phone<input name="customerPhone" type="tel" /></label><label>VIN<input name="vin" minlength="17" maxlength="17" required /></label><label>License plate number<input name="licensePlate" required /></label><label>Make<input name="make" required /></label><label>Model<input name="model" required /></label><label>Model year<input name="modelYear" type="number" min="1886" max="2100" /></label><label>Engine<input name="engine" /></label><label>Transmission<input name="transmission" /></label><label>Fuel type<input name="fuelType" /></label><label>Color<input name="color" /></label><label>Car mileage<input name="mileage" type="number" min="0" step="1" value="0" required /></label><div class="dialog-actions"><button class="secondary-button" type="button" id="cancel-vehicle-button">Cancel</button><button class="primary-button" value="default">Create vehicle</button></div></form></dialog>
|
||||||
<dialog id="user-dialog"><form method="dialog" id="user-form"><div class="dialog-heading"><div><p class="eyebrow">Access control</p><h2 id="user-dialog-title">Add user</h2></div><button class="close-button" type="button" id="close-user-button" aria-label="Close">×</button></div><label>Username<input name="username" required /></label><label>Display name<input name="displayName" required /></label><label>Email<input name="email" type="email" /></label><label>Role<select name="role"><option value="viewer">Viewer</option><option value="technician">Technician</option><option value="service_advisor">Service advisor</option><option value="admin">Administrator</option></select></label><fieldset><legend>Rights</legend><label class="check-label"><input name="canRead" type="checkbox" checked /> Read records</label><label class="check-label"><input name="canAdd" type="checkbox" /> Add records</label><label class="check-label"><input name="canDelete" type="checkbox" /> Delete records</label></fieldset><div class="dialog-actions"><button class="secondary-button" type="button" id="cancel-user-button">Cancel</button><button class="primary-button" id="user-submit-button" value="default">Create user</button></div></form></dialog>
|
<dialog id="user-dialog"><form method="dialog" id="user-form"><div class="dialog-heading"><div><p class="eyebrow">Access control</p><h2 id="user-dialog-title">Add user</h2></div><button class="close-button" type="button" id="close-user-button" aria-label="Close">×</button></div><label>Username<input name="username" required /></label><label>Display name<input name="displayName" required /></label><label>Email<input name="email" type="email" /></label><label>Role<select name="role"><option value="viewer">Viewer</option><option value="technician">Technician</option><option value="service_advisor">Service advisor</option><option value="admin">Administrator</option></select></label><fieldset><legend>Rights</legend><label class="check-label"><input name="canRead" type="checkbox" checked /> Read records</label><label class="check-label"><input name="canAdd" type="checkbox" /> Add records</label><label class="check-label"><input name="canDelete" type="checkbox" /> Delete records</label></fieldset><div class="dialog-actions"><button class="secondary-button" type="button" id="cancel-user-button">Cancel</button><button class="primary-button" id="user-submit-button" value="default">Create user</button></div></form></dialog>
|
||||||
<dialog id="record-dialog"><form method="dialog" id="record-form"><div class="dialog-heading"><div><p class="eyebrow">Workshop floor</p><h2 id="record-dialog-title">Add service record</h2></div><button class="close-button" type="button" id="close-record-button" aria-label="Close">×</button></div><label>Service date<input name="serviceDate" type="date" required /></label><label>Customer name<input name="customerName" placeholder="First and last name" required /></label><label>Customer email<input name="customerEmail" type="email" /></label><label>VIN<input name="vin" minlength="17" maxlength="17" required /></label><label>License plate number<input name="licensePlate" required /></label><label>Car mileage<input name="mileage" type="number" min="0" step="1" required /></label><label>Technician<input name="technicianName" required /></label><label>Complaint or service requested<textarea name="complaint" rows="3" required></textarea></label><div class="dialog-actions"><button class="secondary-button" type="button" id="cancel-record-button">Cancel</button><button class="primary-button" id="record-submit-button" value="default">Create service record</button></div></form></dialog>
|
<dialog id="record-dialog"><form method="dialog" id="record-form"><div class="dialog-heading"><div><p class="eyebrow">Workshop floor</p><h2 id="record-dialog-title">Add service record</h2></div><button class="close-button" type="button" id="close-record-button" aria-label="Close">×</button></div><p class="form-hint">Enter a known VIN or license plate to autofill the vehicle and customer. Use customer email when names are shared.</p><label>Service date<input name="serviceDate" type="date" required /></label><label>VIN<input name="vin" minlength="17" maxlength="17" /></label><label>License plate number<input name="licensePlate" /></label><label>Make<input name="make" required /></label><label>Model<input name="model" required /></label><label>Model year<input name="modelYear" type="number" min="1886" max="2100" /></label><label>Engine<input name="engine" placeholder="e.g. 2.0 TDI" /></label><label>Transmission<input name="transmission" placeholder="e.g. Automatic" /></label><label>Fuel type<input name="fuelType" placeholder="e.g. Petrol, diesel, electric" /></label><label>Color<input name="color" /></label><label>Customer name<input name="customerName" placeholder="First and last name" required /></label><label>Customer email<input name="customerEmail" type="email" /></label><label>Customer phone<input name="customerPhone" type="tel" /></label><label>Car mileage<input name="mileage" type="number" min="0" step="1" required /></label><label>Technician<input name="technicianName" required /></label><label>Complaint or service requested<textarea name="complaint" rows="3" required></textarea></label><div class="dialog-actions"><button class="secondary-button" type="button" id="cancel-record-button">Cancel</button><button class="primary-button" id="record-submit-button" value="default">Create service record</button></div></form></dialog>
|
||||||
<script src="/app.js"></script>
|
<script src="/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+7
-2
@@ -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; }
|
.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; }
|
.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; }
|
.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); }
|
.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: 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; } }
|
@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); }
|
.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); }
|
.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); }
|
.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; } }
|
||||||
@@ -34,8 +34,8 @@ app.get('/api/service-records', async (_request, response) => {
|
|||||||
try {
|
try {
|
||||||
const result = await pool.query(`
|
const result = await pool.query(`
|
||||||
SELECT so.service_order_id, so.status, so.complaint, so.opened_at, so.service_date,
|
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,
|
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.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
|
COALESCE(e.first_name || ' ' || e.last_name, 'Unassigned') AS technician
|
||||||
FROM service_orders so
|
FROM service_orders so
|
||||||
JOIN vehicles v ON v.vehicle_id = so.vehicle_id
|
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.' }); }
|
} 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) => {
|
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);
|
const parsedMileage = Number(mileage);
|
||||||
if (!customerName || !vin || !licensePlate || !technicianName || !complaint || !serviceDate || !Number.isInteger(parsedMileage) || parsedMileage < 0) {
|
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.' });
|
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();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
const customer = await client.query(
|
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()]);
|
||||||
`INSERT INTO customers (first_name, last_name, email) VALUES ($1, $2, NULLIF($3, '')) RETURNING customer_id`,
|
let customerId;
|
||||||
[firstName, lastName, customerEmail?.trim() || '']
|
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(
|
const technician = await client.query(
|
||||||
`INSERT INTO employees (first_name, last_name, role) VALUES ($1, $2, 'technician') RETURNING employee_id`,
|
`INSERT INTO employees (first_name, last_name, role) VALUES ($1, $2, 'technician') RETURNING employee_id`,
|
||||||
[technicianFirstName, technicianLastName]
|
[technicianFirstName, technicianLastName]
|
||||||
);
|
);
|
||||||
const vehicle = await client.query(
|
const vehicle = existingVehicle.rowCount
|
||||||
`INSERT INTO vehicles (customer_id, registration_number, make, model, vin, mileage) VALUES ($1, $2, 'Not specified', 'Not specified', $3, $4) RETURNING vehicle_id`,
|
? 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])
|
||||||
[customer.rows[0].customer_id, licensePlate.trim(), vin.trim(), parsedMileage]
|
: 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(
|
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`,
|
`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]
|
[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) {
|
} catch (error) {
|
||||||
await client.query('ROLLBACK');
|
await client.query('ROLLBACK');
|
||||||
const status = error.code === '23505' ? 409 : 500;
|
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(); }
|
} finally { client.release(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
app.patch('/api/service-records/:id', async (request, response) => {
|
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);
|
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.' });
|
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+/);
|
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.' }); }
|
if (!current.rowCount) { await client.query('ROLLBACK'); return response.status(404).json({ error: 'Service record not found.' }); }
|
||||||
const customerFirst = customerParts.shift();
|
const customerFirst = customerParts.shift();
|
||||||
const technicianFirst = technicianParts.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 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('UPDATE service_orders SET complaint = $1, service_date = $2 WHERE service_order_id = $3', [complaint.trim(), serviceDate, request.params.id]);
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
response.json({ service_order_id: request.params.id });
|
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.' }); }
|
} 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) => {
|
app.post('/api/customers', async (request, response) => {
|
||||||
const { firstName, lastName, email, phone } = request.body;
|
const { firstName, lastName, email, phone } = request.body;
|
||||||
if (!firstName || !lastName) return response.status(400).json({ error: 'First name and last name are required.' });
|
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')));
|
app.use((_request, response) => response.sendFile(path.join(__dirname, 'public', 'index.html')));
|
||||||
|
|
||||||
async function start() {
|
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(`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(`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`);
|
await pool.query(`ALTER TABLE service_orders ALTER COLUMN service_date SET DEFAULT CURRENT_DATE`);
|
||||||
|
|||||||
Reference in New Issue
Block a user