first commit
This commit is contained in:
+12
@@ -0,0 +1,12 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
COPY server.js ./
|
||||
COPY public ./public
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,54 @@
|
||||
# Car Service Database
|
||||
|
||||
PostgreSQL database and web dashboard for a car service workshop, managed with Docker Compose.
|
||||
|
||||
## Start
|
||||
|
||||
```powershell
|
||||
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 **Accounts** view supports reading, adding, editing, and deleting users with `read`, `add`, and `delete` rights. A default `admin` account is created automatically, and the last administrator cannot be deleted. These permissions are currently stored as staff access profiles; login/session enforcement can be added when authentication is required.
|
||||
|
||||
Check that the database is ready:
|
||||
|
||||
```powershell
|
||||
docker compose ps
|
||||
docker compose exec car-service-db pg_isready -U car_service_app -d car_service
|
||||
```
|
||||
|
||||
Connect with `psql` inside the container:
|
||||
|
||||
```powershell
|
||||
docker compose exec car-service-db psql -U car_service_app -d car_service
|
||||
```
|
||||
|
||||
The default connection values are:
|
||||
|
||||
- Host: `localhost`
|
||||
- Port: `5432`
|
||||
- Database: `car_service`
|
||||
- User: `car_service_app`
|
||||
- Password: `change-me-in-production`
|
||||
|
||||
To use different values, create a `.env` file next to `docker-compose.yml`:
|
||||
|
||||
```dotenv
|
||||
POSTGRES_DB=car_service
|
||||
POSTGRES_USER=car_service_app
|
||||
POSTGRES_PASSWORD=use-a-strong-password
|
||||
POSTGRES_PORT=5432
|
||||
```
|
||||
|
||||
The schema is loaded from `init.sql` the first time the named volume is created. To recreate the database from scratch, including all data:
|
||||
|
||||
```powershell
|
||||
docker compose down -v
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Do not use the default password in production. Restrict the published port or place the database on a private Docker network when an application is added.
|
||||
@@ -0,0 +1,35 @@
|
||||
services:
|
||||
car-service-db:
|
||||
image: postgres:16-alpine
|
||||
container_name: car-service-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-car_service}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-car_service_app}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me-in-production}
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
volumes:
|
||||
- car_service_data:/var/lib/postgresql/data
|
||||
- ./init.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
car-service-web:
|
||||
build: .
|
||||
container_name: car-service-web
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PORT: 3000
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-car_service_app}:${POSTGRES_PASSWORD:-change-me-in-production}@car-service-db:5432/${POSTGRES_DB:-car_service}
|
||||
ports:
|
||||
- "${WEB_PORT:-3000}:3000"
|
||||
depends_on:
|
||||
car-service-db:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
car_service_data:
|
||||
@@ -0,0 +1,102 @@
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS customers (
|
||||
customer_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT NOT NULL,
|
||||
email TEXT UNIQUE,
|
||||
phone TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vehicles (
|
||||
vehicle_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
customer_id UUID NOT NULL REFERENCES customers(customer_id) ON DELETE CASCADE,
|
||||
registration_number TEXT NOT NULL UNIQUE,
|
||||
make TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
model_year INTEGER CHECK (model_year BETWEEN 1886 AND EXTRACT(YEAR FROM CURRENT_DATE)::INTEGER + 1),
|
||||
vin CHAR(17) UNIQUE,
|
||||
mileage INTEGER NOT NULL DEFAULT 0 CHECK (mileage >= 0),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS employees (
|
||||
employee_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('technician', 'service_advisor', 'manager')),
|
||||
email TEXT UNIQUE,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS appointments (
|
||||
appointment_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
vehicle_id UUID NOT NULL REFERENCES vehicles(vehicle_id) ON DELETE CASCADE,
|
||||
employee_id UUID REFERENCES employees(employee_id) ON DELETE SET NULL,
|
||||
scheduled_at TIMESTAMPTZ NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'scheduled'
|
||||
CHECK (status IN ('scheduled', 'confirmed', 'completed', 'cancelled', 'no_show')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS service_orders (
|
||||
service_order_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
vehicle_id UUID NOT NULL REFERENCES vehicles(vehicle_id) ON DELETE RESTRICT,
|
||||
assigned_employee_id UUID REFERENCES employees(employee_id) ON DELETE SET NULL,
|
||||
opened_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
closed_at TIMESTAMPTZ,
|
||||
complaint TEXT NOT NULL,
|
||||
service_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||
diagnosis TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'open'
|
||||
CHECK (status IN ('open', 'in_progress', 'waiting_for_parts', 'ready', 'closed', 'cancelled')),
|
||||
CHECK (closed_at IS NULL OR closed_at >= opened_at)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS service_order_items (
|
||||
item_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
service_order_id UUID NOT NULL REFERENCES service_orders(service_order_id) ON DELETE CASCADE,
|
||||
item_type TEXT NOT NULL CHECK (item_type IN ('labor', 'part')),
|
||||
description TEXT NOT NULL,
|
||||
quantity NUMERIC(10, 2) NOT NULL DEFAULT 1 CHECK (quantity > 0),
|
||||
unit_price NUMERIC(12, 2) NOT NULL CHECK (unit_price >= 0),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS invoices (
|
||||
invoice_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
service_order_id UUID NOT NULL UNIQUE REFERENCES service_orders(service_order_id) ON DELETE RESTRICT,
|
||||
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
due_at DATE,
|
||||
status TEXT NOT NULL DEFAULT 'unpaid'
|
||||
CHECK (status IN ('draft', 'unpaid', 'paid', 'void')),
|
||||
tax_rate NUMERIC(5, 2) NOT NULL DEFAULT 0 CHECK (tax_rate >= 0 AND tax_rate <= 100)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_users (
|
||||
user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
email TEXT UNIQUE,
|
||||
role TEXT NOT NULL DEFAULT 'viewer'
|
||||
CHECK (role IN ('admin', 'service_advisor', 'technician', 'viewer')),
|
||||
can_read BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
can_add BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
can_delete BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_vehicles_customer_id ON vehicles(customer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_appointments_scheduled_at ON appointments(scheduled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_service_orders_vehicle_id ON service_orders(vehicle_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_service_orders_status ON service_orders(status);
|
||||
|
||||
INSERT INTO customers (first_name, last_name, email, phone)
|
||||
VALUES ('Alex', 'Morgan', 'alex.morgan@example.com', '+1-555-0100')
|
||||
ON CONFLICT (email) DO NOTHING;
|
||||
|
||||
INSERT INTO app_users (username, display_name, role, can_read, can_add, can_delete)
|
||||
VALUES ('admin', 'Workshop administrator', 'admin', TRUE, TRUE, TRUE)
|
||||
ON CONFLICT (username) DO NOTHING;
|
||||
Generated
+1032
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "car-service-web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Web dashboard for the car service database",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"pg": "^8.16.3"
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
const state = { orders: [], users: [] };
|
||||
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 `<div class="empty-state">${message}</div>`; }
|
||||
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) => `<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.');
|
||||
} catch (error) { showError(error); }
|
||||
}
|
||||
|
||||
async function loadCustomers() {
|
||||
try {
|
||||
const customers = await request('/api/customers');
|
||||
$('#customers-table').innerHTML = customers.length ? customers.map((customer) => `<tr><td><strong>${customer.first_name} ${customer.last_name}</strong></td><td>${customer.email || '—'}<br />${customer.phone || ''}</td><td>${customer.vehicle_count}</td><td>${new Date(customer.created_at || Date.now()).toLocaleDateString()}</td></tr>`).join('') : `<tr><td colspan="4">${emptyMessage('No customers yet.')}</td></tr>`;
|
||||
} catch (error) { showError(error); }
|
||||
}
|
||||
|
||||
async function loadOrders() {
|
||||
try {
|
||||
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>`;
|
||||
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))));
|
||||
} catch (error) { showError(error); }
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
state.users = await request('/api/users');
|
||||
$('#users-table').innerHTML = state.users.map((user) => `<tr><td><strong>${escapeHtml(user.display_name)}</strong>${escapeHtml(user.username)}<br />${escapeHtml(user.email || '')}</td><td>${escapeHtml(titleCase(user.role))}</td><td><span class="permission ${user.can_read ? 'allowed' : ''}">${user.can_read ? 'Yes' : 'No'}</span></td><td><span class="permission ${user.can_add ? 'allowed' : ''}">${user.can_add ? 'Yes' : 'No'}</span></td><td><span class="permission ${user.can_delete ? 'allowed' : ''}">${user.can_delete ? 'Yes' : 'No'}</span></td><td><button class="edit-button" data-user-id="${user.user_id}">Edit</button> <button class="delete-button" data-user-id="${user.user_id}" data-user-name="${escapeHtml(user.display_name)}">Delete</button></td></tr>`).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); }
|
||||
}
|
||||
|
||||
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); }
|
||||
}
|
||||
|
||||
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));
|
||||
$('#page-title').textContent = viewName === 'overview' ? 'Good morning, workshop.' : viewName === 'customers' ? 'Your customer directory.' : viewName === 'orders' ? 'Keep the bays moving.' : 'Manage workshop access.';
|
||||
if (viewName === 'customers') loadCustomers();
|
||||
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()));
|
||||
$('#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());
|
||||
$('#cancel-record-button').addEventListener('click', () => $('#record-dialog').close());
|
||||
$('#close-record-button').addEventListener('click', () => $('#record-dialog').close());
|
||||
$('#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); }
|
||||
});
|
||||
|
||||
$('#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));
|
||||
const editId = event.target.dataset.editId;
|
||||
await request(editId ? `/api/service-records/${editId}` : '/api/service-records', { method: editId ? 'PATCH' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form) });
|
||||
event.target.reset(); delete event.target.dataset.editId; $('#record-dialog-title').textContent = 'Add service record'; $('#record-submit-button').textContent = 'Create service record'; $('#record-dialog').close(); await loadOrders(); await loadOverview();
|
||||
} catch (error) { showError(error); }
|
||||
});
|
||||
|
||||
loadOverview();
|
||||
@@ -0,0 +1,51 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Bayline Service Desk</title>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand"><span class="brand-mark">B</span><span>Bayline<br /><strong>Service Desk</strong></span></div>
|
||||
<nav aria-label="Main navigation">
|
||||
<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="orders"><span>Service records</span><small>03</small></button>
|
||||
<button class="nav-item" data-view="users"><span>Accounts</span><small>04</small></button>
|
||||
</nav>
|
||||
<div class="sidebar-footer"><span class="status-dot"></span><span>Database online</span><small>PostgreSQL 16</small></div>
|
||||
</aside>
|
||||
|
||||
<main class="main-content">
|
||||
<header class="topbar"><div><p class="eyebrow">Wednesday, August 26, 2026</p><h1 id="page-title">Good morning, workshop.</h1></div><button class="primary-button" id="new-customer-button">+ New customer</button></header>
|
||||
<div id="notice" class="notice" role="status"></div>
|
||||
|
||||
<section id="overview-view" class="view active-view">
|
||||
<div class="metric-grid">
|
||||
<article class="metric-card"><span class="metric-label">Today’s appointments</span><strong id="today-appointments">—</strong><span class="metric-note">Scheduled for today</span></article>
|
||||
<article class="metric-card accent"><span class="metric-label">Active service orders</span><strong id="active-orders">—</strong><span class="metric-note">Work currently in motion</span></article>
|
||||
<article class="metric-card"><span class="metric-label">Customers</span><strong id="customer-count">—</strong><span class="metric-note">In the workshop database</span></article>
|
||||
<article class="metric-card"><span class="metric-label">Vehicles</span><strong id="vehicle-count">—</strong><span class="metric-note">Registered vehicles</span></article>
|
||||
</div>
|
||||
<div class="content-grid">
|
||||
<section class="panel"><div class="panel-heading"><div><p class="eyebrow">Today</p><h2>Appointments</h2></div><button class="text-button" data-view="customers">View customers →</button></div><div id="appointments-list" class="list-container"><div class="empty-state">Loading appointments...</div></div></section>
|
||||
<section class="panel dark-panel"><div class="panel-heading"><div><p class="eyebrow">Workshop floor</p><h2>Active work</h2></div><button class="text-button light" data-view="orders">All orders →</button></div><div id="orders-list" class="list-container"><div class="empty-state">Loading service orders...</div></div></section>
|
||||
</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="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>
|
||||
</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="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>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&display=swap');
|
||||
|
||||
:root { --ink: #18201f; --muted: #72807c; --line: #dfe7e2; --paper: #f5f7f4; --white: #fff; --mint: #b9e9cf; --mint-deep: #75c99e; --orange: #f2a65a; --navy: #1b3131; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--paper); color: var(--ink); font: 14px 'DM Sans', sans-serif; }
|
||||
button, input { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
.app-shell { display: grid; grid-template-columns: 238px 1fr; min-height: 100vh; }
|
||||
.sidebar { background: var(--navy); color: #e5f1eb; display: flex; flex-direction: column; padding: 30px 20px 22px; }
|
||||
.brand { display: flex; align-items: center; gap: 11px; font-size: 12px; line-height: 1.25; letter-spacing: .04em; color: #b9cfc5; }
|
||||
.brand strong { color: white; font: 600 17px 'Space Grotesk', sans-serif; letter-spacing: -.03em; }
|
||||
.brand-mark { display: grid; place-items: center; width: 36px; height: 36px; background: var(--mint); color: var(--navy); font: 700 20px 'Space Grotesk', sans-serif; }
|
||||
nav { display: grid; gap: 8px; margin-top: 76px; }
|
||||
.nav-item { border: 0; border-left: 2px solid transparent; background: transparent; color: #a9c0b6; display: flex; justify-content: space-between; padding: 12px 12px; text-align: left; }
|
||||
.nav-item small { color: #6d8c7e; }.nav-item.active, .nav-item:hover { background: #294542; border-left-color: var(--mint); color: white; }
|
||||
.sidebar-footer { margin-top: auto; border-top: 1px solid #34504a; padding: 18px 4px 0; color: #a9c0b6; font-size: 12px; display: grid; grid-template-columns: 10px 1fr; gap: 4px 7px; }.sidebar-footer small { grid-column: 2; color: #6d8c7e; }.status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--mint-deep); margin-top: 4px; }
|
||||
.main-content { max-width: 1450px; width: 100%; padding: 48px 5.5%; margin: auto; }.topbar, .section-heading, .panel-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; }.eyebrow { color: #84918d; font-size: 11px; font-weight: 700; letter-spacing: .12em; margin: 0 0 7px; text-transform: uppercase; }.topbar h1, h2 { font: 600 32px 'Space Grotesk', sans-serif; letter-spacing: -.055em; margin: 0; }.section-heading h2, .panel-heading h2 { font-size: 23px; }.subheading { color: var(--muted); margin: 8px 0 0; }
|
||||
.primary-button { background: var(--ink); border: 0; color: white; padding: 12px 17px; font-weight: 700; }.primary-button:hover { background: #30423f; }.text-button { background: transparent; border: 0; color: #71847b; padding: 5px 0; }.text-button.light { color: #bad9cc; }.notice { min-height: 20px; color: #b15b27; padding-top: 20px; }.notice:empty { padding-top: 0; }
|
||||
.metric-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin: 42px 0 38px; }.metric-card { background: var(--white); border: 1px solid var(--line); min-height: 155px; padding: 20px; display: flex; flex-direction: column; }.metric-card.accent { background: var(--mint); border-color: var(--mint); }.metric-label { color: #6c7b75; font-size: 12px; font-weight: 700; }.metric-card strong { font: 600 44px 'Space Grotesk', sans-serif; letter-spacing: -.08em; margin: 18px 0 10px; }.metric-note { color: #85918d; font-size: 12px; }.content-grid { display: grid; grid-template-columns: 1.05fr .95fr; gap: 16px; }.panel { background: var(--white); border: 1px solid var(--line); padding: 25px; }.dark-panel { background: var(--navy); border-color: var(--navy); color: white; }.panel-heading { align-items: flex-start; }.list-container { margin-top: 22px; }.list-row { border-top: 1px solid var(--line); display: grid; grid-template-columns: 74px 1fr auto; gap: 14px; padding: 15px 0; align-items: center; }.dark-panel .list-row { border-color: #34504a; }.time { color: #71847b; font-weight: 700; font-size: 12px; }.dark-panel .time { color: var(--mint); }.item-title { font-weight: 700; }.item-meta { color: var(--muted); font-size: 12px; margin-top: 4px; }.dark-panel .item-meta { color: #9bb3aa; }.status { display: inline-block; background: #edf2ee; color: #60706a; font-size: 11px; font-weight: 700; padding: 5px 8px; text-transform: capitalize; white-space: nowrap; }.status.ready, .status.confirmed { background: #dff5e8; color: #27784a; }.status.in_progress { background: #fff0dc; color: #a36218; }.empty-state { color: var(--muted); padding: 30px 0; text-align: center; }.view { display: none; }.active-view { display: block; }.section-heading { align-items: flex-start; margin: 25px 0 32px; }.table-panel { padding: 0; }.table-wrap { overflow-x: auto; }table { border-collapse: collapse; width: 100%; }th, td { border-bottom: 1px solid var(--line); padding: 17px 20px; text-align: left; white-space: nowrap; }th { color: #8a9691; font-size: 11px; letter-spacing: .1em; text-transform: uppercase; }td { color: #465550; }td strong { color: var(--ink); display: block; }.dialog-heading { display: flex; justify-content: space-between; margin-bottom: 25px; }.close-button { background: transparent; border: 0; font-size: 28px; line-height: 1; color: var(--muted); }.dialog-heading h2 { font-size: 24px; }dialog { border: 0; padding: 0; width: min(420px, calc(100% - 32px)); box-shadow: 0 18px 60px #13272533; }dialog::backdrop { background: #122b2a99; }.dialog-heading, dialog form { padding: 26px; }dialog form { display: grid; gap: 15px; }dialog form .dialog-heading { margin: -26px -26px 0; }label { color: #64726d; font-size: 12px; font-weight: 700; display: grid; gap: 7px; }input { border: 1px solid var(--line); padding: 11px; color: var(--ink); outline-color: var(--mint-deep); }
|
||||
@media (max-width: 900px) { .app-shell { grid-template-columns: 1fr; }.sidebar { padding: 18px; }.sidebar nav { display: flex; margin-top: 24px; overflow-x: auto; }.nav-item { flex: 1; min-width: 145px; }.sidebar-footer { display: none; }.main-content { padding: 30px 20px; }.metric-grid { grid-template-columns: repeat(2, 1fr); margin-top: 30px; }.content-grid { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 520px) { .topbar { align-items: flex-start; flex-direction: column; }.topbar h1 { font-size: 27px; }.metric-grid { grid-template-columns: 1fr 1fr; gap: 8px; }.metric-card { min-height: 135px; padding: 15px; }.metric-card strong { font-size: 34px; }.panel { padding: 18px; }.list-row { grid-template-columns: 58px 1fr; }.list-row .status { grid-column: 2; justify-self: start; }.section-heading { flex-direction: column; } }
|
||||
.permission { color: #a15b4e; font-size: 12px; font-weight: 700; }.permission.allowed { color: #27784a; }.delete-button { background: transparent; border: 1px solid #e5c9c4; color: #a15b4e; padding: 7px 10px; }.delete-button:hover { background: #fff1ee; }select { border: 1px solid var(--line); padding: 10px; background: white; color: var(--ink); }fieldset { border: 1px solid var(--line); display: grid; gap: 10px; padding: 12px; }legend { color: var(--muted); font-size: 11px; font-weight: 700; text-transform: uppercase; }.check-label { display: flex; align-items: center; gap: 8px; }.check-label input { accent-color: var(--navy); }
|
||||
.dialog-actions { display: flex; gap: 10px; justify-content: flex-end; }.secondary-button { background: transparent; border: 1px solid var(--line); color: var(--ink); padding: 12px 17px; font-weight: 700; }.secondary-button:hover { background: var(--paper); }
|
||||
.edit-button { background: transparent; border: 1px solid var(--line); color: var(--ink); padding: 7px 10px; }.edit-button:hover { background: var(--paper); }
|
||||
@@ -0,0 +1,196 @@
|
||||
const express = require('express');
|
||||
const path = require('node:path');
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const app = express();
|
||||
const port = Number(process.env.PORT || 3000);
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
app.get('/api/summary', async (_request, response) => {
|
||||
try {
|
||||
const result = await pool.query(`SELECT (SELECT COUNT(*)::int FROM customers) AS customers, (SELECT COUNT(*)::int FROM vehicles) AS vehicles, (SELECT COUNT(*)::int FROM service_orders WHERE status NOT IN ('closed', 'cancelled')) AS active_orders, (SELECT COUNT(*)::int FROM appointments WHERE scheduled_at >= CURRENT_DATE AND scheduled_at < CURRENT_DATE + INTERVAL '1 day' AND status NOT IN ('cancelled', 'no_show')) AS today_appointments`);
|
||||
response.json(result.rows[0]);
|
||||
} catch (error) { response.status(500).json({ error: 'Unable to load dashboard summary.' }); }
|
||||
});
|
||||
|
||||
app.get('/api/appointments', async (_request, response) => {
|
||||
try {
|
||||
const result = await pool.query(`SELECT a.appointment_id, a.scheduled_at, a.reason, a.status, c.first_name || ' ' || c.last_name AS customer_name, v.registration_number, v.make, v.model FROM appointments a JOIN vehicles v ON v.vehicle_id = a.vehicle_id JOIN customers c ON c.customer_id = v.customer_id WHERE a.scheduled_at >= CURRENT_DATE AND a.status NOT IN ('cancelled', 'no_show') ORDER BY a.scheduled_at LIMIT 12`);
|
||||
response.json(result.rows);
|
||||
} catch (error) { response.status(500).json({ error: 'Unable to load appointments.' }); }
|
||||
});
|
||||
|
||||
app.get('/api/orders', async (_request, response) => {
|
||||
try {
|
||||
const result = await pool.query(`SELECT so.service_order_id, so.status, so.complaint, so.opened_at, c.first_name || ' ' || c.last_name AS customer_name, v.registration_number, v.make, v.model, COALESCE(e.first_name || ' ' || e.last_name, 'Unassigned') AS technician FROM service_orders so JOIN vehicles v ON v.vehicle_id = so.vehicle_id JOIN customers c ON c.customer_id = v.customer_id LEFT JOIN employees e ON e.employee_id = so.assigned_employee_id WHERE so.status NOT IN ('closed', 'cancelled') ORDER BY so.opened_at DESC LIMIT 12`);
|
||||
response.json(result.rows);
|
||||
} catch (error) { response.status(500).json({ error: 'Unable to load service orders.' }); }
|
||||
});
|
||||
|
||||
app.get('/api/service-records', async (_request, response) => {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT so.service_order_id, so.status, so.complaint, so.opened_at, so.service_date,
|
||||
btrim(v.vin) AS vin, v.registration_number AS license_plate, v.mileage,
|
||||
c.first_name || ' ' || c.last_name AS customer_name, c.email AS customer_email,
|
||||
COALESCE(e.first_name || ' ' || e.last_name, 'Unassigned') AS technician
|
||||
FROM service_orders so
|
||||
JOIN vehicles v ON v.vehicle_id = so.vehicle_id
|
||||
JOIN customers c ON c.customer_id = v.customer_id
|
||||
LEFT JOIN employees e ON e.employee_id = so.assigned_employee_id
|
||||
ORDER BY so.opened_at DESC
|
||||
LIMIT 100
|
||||
`);
|
||||
response.json(result.rows);
|
||||
} catch (error) { response.status(500).json({ error: 'Unable to load service records.' }); }
|
||||
});
|
||||
|
||||
app.post('/api/service-records', async (request, response) => {
|
||||
const { customerName, customerEmail, vin, licensePlate, mileage, serviceDate, technicianName, complaint } = request.body;
|
||||
const parsedMileage = Number(mileage);
|
||||
if (!customerName || !vin || !licensePlate || !technicianName || !complaint || !serviceDate || !Number.isInteger(parsedMileage) || parsedMileage < 0) {
|
||||
return response.status(400).json({ error: 'Customer, VIN, license plate, non-negative mileage, service date, technician, and complaint are required.' });
|
||||
}
|
||||
|
||||
const customerParts = customerName.trim().split(/\s+/);
|
||||
const firstName = customerParts.shift();
|
||||
const lastName = customerParts.join(' ') || firstName;
|
||||
const technicianParts = technicianName.trim().split(/\s+/);
|
||||
const technicianFirstName = technicianParts.shift();
|
||||
const technicianLastName = technicianParts.join(' ') || technicianFirstName;
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const customer = await client.query(
|
||||
`INSERT INTO customers (first_name, last_name, email) VALUES ($1, $2, NULLIF($3, '')) RETURNING customer_id`,
|
||||
[firstName, lastName, customerEmail?.trim() || '']
|
||||
);
|
||||
const technician = await client.query(
|
||||
`INSERT INTO employees (first_name, last_name, role) VALUES ($1, $2, 'technician') RETURNING employee_id`,
|
||||
[technicianFirstName, technicianLastName]
|
||||
);
|
||||
const vehicle = await client.query(
|
||||
`INSERT INTO vehicles (customer_id, registration_number, make, model, vin, mileage) VALUES ($1, $2, 'Not specified', 'Not specified', $3, $4) RETURNING vehicle_id`,
|
||||
[customer.rows[0].customer_id, licensePlate.trim(), vin.trim(), parsedMileage]
|
||||
);
|
||||
const record = await client.query(
|
||||
`INSERT INTO service_orders (vehicle_id, assigned_employee_id, complaint, service_date) VALUES ($1, $2, $3, $4) RETURNING service_order_id`,
|
||||
[vehicle.rows[0].vehicle_id, technician.rows[0].employee_id, complaint.trim(), serviceDate]
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
response.status(201).json({ service_order_id: record.rows[0].service_order_id });
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
const status = error.code === '23505' ? 409 : 500;
|
||||
response.status(status).json({ error: status === 409 ? 'That VIN or license plate is already registered.' : 'Unable to create service record.' });
|
||||
} finally { client.release(); }
|
||||
});
|
||||
|
||||
app.patch('/api/service-records/:id', async (request, response) => {
|
||||
const { customerName, customerEmail, vin, licensePlate, mileage, serviceDate, technicianName, complaint } = request.body;
|
||||
const parsedMileage = Number(mileage);
|
||||
if (!customerName || !vin || !licensePlate || !technicianName || !complaint || !serviceDate || !Number.isInteger(parsedMileage) || parsedMileage < 0) return response.status(400).json({ error: 'Customer, VIN, license plate, non-negative mileage, service date, technician, and complaint are required.' });
|
||||
const customerParts = customerName.trim().split(/\s+/);
|
||||
const technicianParts = technicianName.trim().split(/\s+/);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const current = await client.query(`SELECT c.customer_id, v.vehicle_id, so.assigned_employee_id FROM service_orders so JOIN vehicles v ON v.vehicle_id = so.vehicle_id JOIN customers c ON c.customer_id = v.customer_id WHERE so.service_order_id = $1`, [request.params.id]);
|
||||
if (!current.rowCount) { await client.query('ROLLBACK'); return response.status(404).json({ error: 'Service record not found.' }); }
|
||||
const customerFirst = customerParts.shift();
|
||||
const technicianFirst = technicianParts.shift();
|
||||
await client.query('UPDATE customers SET first_name = $1, last_name = $2, email = NULLIF($3, \'\') WHERE customer_id = $4', [customerFirst, customerParts.join(' ') || customerFirst, customerEmail?.trim() || '', current.rows[0].customer_id]);
|
||||
await client.query('UPDATE 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 service_orders SET complaint = $1, service_date = $2 WHERE service_order_id = $3', [complaint.trim(), serviceDate, request.params.id]);
|
||||
await client.query('COMMIT');
|
||||
response.json({ service_order_id: request.params.id });
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
response.status(error.code === '23505' ? 409 : 500).json({ error: error.code === '23505' ? 'That VIN or license plate is already registered.' : 'Unable to update service record.' });
|
||||
} finally { client.release(); }
|
||||
});
|
||||
|
||||
app.get('/api/customers', async (_request, response) => {
|
||||
try {
|
||||
const result = await pool.query(`SELECT c.customer_id, c.first_name, c.last_name, c.email, c.phone, c.created_at, COUNT(v.vehicle_id)::int AS vehicle_count FROM customers c LEFT JOIN vehicles v ON v.customer_id = c.customer_id GROUP BY c.customer_id ORDER BY c.created_at DESC LIMIT 50`);
|
||||
response.json(result.rows);
|
||||
} catch (error) { response.status(500).json({ error: 'Unable to load customers.' }); }
|
||||
});
|
||||
|
||||
app.post('/api/customers', async (request, response) => {
|
||||
const { firstName, lastName, email, phone } = request.body;
|
||||
if (!firstName || !lastName) return response.status(400).json({ error: 'First name and last name are required.' });
|
||||
try {
|
||||
const result = await pool.query(`INSERT INTO customers (first_name, last_name, email, phone) VALUES ($1, $2, NULLIF($3, ''), NULLIF($4, '')) RETURNING customer_id, first_name, last_name, email, phone`, [firstName.trim(), lastName.trim(), email?.trim() || '', phone?.trim() || '']);
|
||||
response.status(201).json(result.rows[0]);
|
||||
} catch (error) { response.status(error.code === '23505' ? 409 : 500).json({ error: error.code === '23505' ? 'That email is already registered.' : 'Unable to create customer.' }); }
|
||||
});
|
||||
|
||||
app.patch('/api/orders/:id/status', async (request, response) => {
|
||||
const allowedStatuses = ['open', 'in_progress', 'waiting_for_parts', 'ready', 'closed', 'cancelled'];
|
||||
if (!allowedStatuses.includes(request.body.status)) return response.status(400).json({ error: 'Invalid service order status.' });
|
||||
try {
|
||||
const result = await pool.query(`UPDATE service_orders SET status = $1, closed_at = CASE WHEN $1 = 'closed' THEN NOW() ELSE NULL END WHERE service_order_id = $2 RETURNING service_order_id, status`, [request.body.status, request.params.id]);
|
||||
if (!result.rowCount) return response.status(404).json({ error: 'Service order not found.' });
|
||||
response.json(result.rows[0]);
|
||||
} catch (error) { response.status(500).json({ error: 'Unable to update service order.' }); }
|
||||
});
|
||||
|
||||
app.get('/api/users', async (_request, response) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT user_id, username, display_name, email, role, can_read, can_add, can_delete, created_at FROM app_users ORDER BY created_at DESC');
|
||||
response.json(result.rows);
|
||||
} catch (error) { response.status(500).json({ error: 'Unable to load user accounts.' }); }
|
||||
});
|
||||
|
||||
app.post('/api/users', async (request, response) => {
|
||||
const { username, displayName, email, role, canRead, canAdd, canDelete } = request.body;
|
||||
const allowedRoles = ['admin', 'service_advisor', 'technician', 'viewer'];
|
||||
if (!username || !displayName || !allowedRoles.includes(role)) return response.status(400).json({ error: 'Username, display name, and a valid role are required.' });
|
||||
try {
|
||||
const result = await pool.query(`INSERT INTO app_users (username, display_name, email, role, can_read, can_add, can_delete) VALUES ($1, $2, NULLIF($3, ''), $4, $5, $6, $7) RETURNING user_id, username, display_name, email, role, can_read, can_add, can_delete, created_at`, [username.trim(), displayName.trim(), email?.trim() || '', role, canRead !== false, canAdd === true, canDelete === true]);
|
||||
response.status(201).json(result.rows[0]);
|
||||
} catch (error) { response.status(error.code === '23505' ? 409 : 500).json({ error: error.code === '23505' ? 'That username or email is already in use.' : 'Unable to create user account.' }); }
|
||||
});
|
||||
|
||||
app.patch('/api/users/:id', async (request, response) => {
|
||||
const { displayName, email, role, canRead, canAdd, canDelete } = request.body;
|
||||
const allowedRoles = ['admin', 'service_advisor', 'technician', 'viewer'];
|
||||
if (!displayName || !allowedRoles.includes(role)) return response.status(400).json({ error: 'Display name and a valid role are required.' });
|
||||
try {
|
||||
const result = await pool.query(`UPDATE app_users SET display_name = $1, email = NULLIF($2, ''), role = $3, can_read = $4, can_add = $5, can_delete = $6 WHERE user_id = $7 RETURNING user_id`, [displayName.trim(), email?.trim() || '', role, canRead === true, canAdd === true, canDelete === true, request.params.id]);
|
||||
if (!result.rowCount) return response.status(404).json({ error: 'User account not found.' });
|
||||
response.json(result.rows[0]);
|
||||
} catch (error) { response.status(error.code === '23505' ? 409 : 500).json({ error: error.code === '23505' ? 'That email is already in use.' : 'Unable to update user account.' }); }
|
||||
});
|
||||
|
||||
app.delete('/api/users/:id', async (request, response) => {
|
||||
try {
|
||||
const user = await pool.query('SELECT role FROM app_users WHERE user_id = $1', [request.params.id]);
|
||||
if (!user.rowCount) return response.status(404).json({ error: 'User account not found.' });
|
||||
if (user.rows[0].role === 'admin') {
|
||||
const admins = await pool.query("SELECT COUNT(*)::int AS count FROM app_users WHERE role = 'admin'");
|
||||
if (admins.rows[0].count <= 1) return response.status(409).json({ error: 'The last administrator cannot be deleted.' });
|
||||
}
|
||||
await pool.query('DELETE FROM app_users WHERE user_id = $1', [request.params.id]);
|
||||
response.status(204).end();
|
||||
} catch (error) { response.status(500).json({ error: 'Unable to delete user account.' }); }
|
||||
});
|
||||
|
||||
app.use((_request, response) => response.sendFile(path.join(__dirname, 'public', 'index.html')));
|
||||
|
||||
async function start() {
|
||||
await pool.query(`ALTER TABLE service_orders ADD COLUMN IF NOT EXISTS service_date DATE`);
|
||||
await pool.query(`UPDATE service_orders SET service_date = opened_at::date WHERE service_date IS NULL`);
|
||||
await pool.query(`ALTER TABLE service_orders ALTER COLUMN service_date SET DEFAULT CURRENT_DATE`);
|
||||
await pool.query(`ALTER TABLE service_orders ALTER COLUMN service_date SET NOT NULL`);
|
||||
await pool.query(`CREATE TABLE IF NOT EXISTS app_users (user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), username TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, email TEXT UNIQUE, role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('admin', 'service_advisor', 'technician', 'viewer')), can_read BOOLEAN NOT NULL DEFAULT TRUE, can_add BOOLEAN NOT NULL DEFAULT FALSE, can_delete BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`);
|
||||
await pool.query("INSERT INTO app_users (username, display_name, role, can_read, can_add, can_delete) VALUES ('admin', 'Workshop administrator', 'admin', TRUE, TRUE, TRUE) ON CONFLICT (username) DO NOTHING");
|
||||
app.listen(port, () => console.log(`Car service web app listening on port ${port}`));
|
||||
}
|
||||
|
||||
start().catch((error) => { console.error('Unable to initialize user accounts.', error); process.exit(1); });
|
||||
Reference in New Issue
Block a user