diff --git a/README.md b/README.md
index c7fae74..b178dbb 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,8 @@ The dashboard's **Vehicles** view provides a dedicated vehicle register containi
Service records can also include optional used parts and labor lines. Parts support a part number, quantity, and unit price. Labor supports used time in hours and an hourly price. Line totals and the service-record total are calculated automatically and stored with the service order.
+The **Settings** view provides complete JSON data backup and restore. **Download backup** exports all customers, employees, vehicles, appointments, service records, service-order items, invoices, and user accounts. **Choose backup file** imports a backup after confirmation; importing replaces the current database contents in one transaction, so a failed import leaves the existing data unchanged. Keep exported backup files secure because they contain all workshop data.
+
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:
diff --git a/public/app.js b/public/app.js
index 912f9d3..cc39737 100644
--- a/public/app.js
+++ b/public/app.js
@@ -113,7 +113,7 @@ function switchView(viewName) {
document.querySelectorAll('.view').forEach((view) => view.classList.toggle('active-view', view.id === `${viewName}-view`));
document.querySelectorAll('.nav-item').forEach((item) => item.classList.toggle('active', item.dataset.view === viewName));
$('#new-customer-button').hidden = viewName === 'customers' || viewName === 'vehicles' || viewName === 'orders';
- $('#page-title').textContent = viewName === 'overview' ? 'Good morning, workshop.' : viewName === 'customers' ? 'Your customer directory.' : viewName === 'vehicles' ? 'Know every vehicle.' : viewName === 'orders' ? 'Keep the bays moving.' : 'Manage workshop access.';
+ $('#page-title').textContent = viewName === 'overview' ? 'Good morning, workshop.' : viewName === 'customers' ? 'Your customer directory.' : viewName === 'vehicles' ? 'Know every vehicle.' : viewName === 'orders' ? 'Keep the bays moving.' : viewName === 'users' ? 'Manage workshop access.' : 'Keep your workshop data portable.';
if (viewName === 'customers') loadCustomers();
if (viewName === 'vehicles') loadVehicles();
if (viewName === 'orders') loadOrders();
@@ -137,6 +137,21 @@ $('#cancel-record-button').addEventListener('click', () => $('#record-dialog').c
$('#close-record-button').addEventListener('click', () => $('#record-dialog').close());
$('#order-filters').addEventListener('input', renderOrders);
$('#order-filters').addEventListener('change', renderOrders);
+$('#export-data-button').addEventListener('click', async () => {
+ try {
+ const response = await fetch('/api/data/export');
+ if (!response.ok) throw new Error('Unable to export database data.');
+ const blob = await response.blob();
+ const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = `car-service-backup-${new Date().toISOString().slice(0, 10)}.json`; link.click(); URL.revokeObjectURL(link.href);
+ $('#notice').textContent = 'Database backup downloaded.';
+ } catch (error) { showError(error); }
+});
+$('#import-data-input').addEventListener('change', async (event) => {
+ const file = event.target.files[0]; if (!file) return;
+ $('#import-file-name').textContent = file.name;
+ if (!window.confirm('Importing a backup replaces all current database data. Continue?')) { event.target.value = ''; $('#import-file-name').textContent = 'No file selected.'; return; }
+ try { const result = await request('/api/data/import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: await file.text() }); $('#notice').textContent = `Backup imported: ${result.imported} records restored.`; await loadOverview(); await loadCustomers(); await loadVehicles(); await loadOrders(); await loadUsers(); } catch (error) { showError(error); } finally { event.target.value = ''; }
+});
['vin', 'licensePlate'].forEach((name) => $('#record-form').elements[name].addEventListener('blur', async (event) => {
const identifier = event.target.value.trim();
if (!identifier) return;
diff --git a/public/index.html b/public/index.html
index 7d27610..72be385 100644
--- a/public/index.html
+++ b/public/index.html
@@ -16,6 +16,7 @@
+
@@ -43,6 +44,7 @@
Workshop floor
Service records
VIN, plate, vehicle, mileage, date, customer, and technician in one view.
| Service date | VIN | License plate | Vehicle | Mileage | Customer | Technician | Complaint | Total | Status | Actions |
|---|
Access control
User accounts
Manage workshop staff and their database rights.
| User | Role | Read | Add | Delete | Actions |
|---|
+ Workspace
Settings
Manage the workshop database and move a complete backup between environments.
Backup
Export all data
Download customers, vehicles, employees, appointments, service records, parts, invoices, and accounts as one JSON file.
Restore
Import all data
Restore a JSON backup. This replaces the current database contents after the file passes validation.
No file selected.
diff --git a/public/styles.css b/public/styles.css
index 5673c86..a7a4285 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -21,6 +21,8 @@ nav { display: grid; gap: 8px; margin-top: 76px; }
@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); }
+.settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; margin-top: 28px; }.settings-card { min-height: 235px; }.settings-card h3 { font: 600 21px 'Space Grotesk', sans-serif; margin: 0 0 10px; }.settings-card p:not(.eyebrow):not(.form-hint) { color: var(--muted); line-height: 1.6; margin: 0 0 24px; max-width: 48ch; }.file-button { align-items: center; background: var(--ink); color: white; cursor: pointer; display: inline-flex; font-weight: 700; justify-content: center; padding: 12px 17px; }.file-button input { display: none; }
+@media (max-width: 720px) { .settings-grid { grid-template-columns: 1fr; } }
.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; }
.items-section { border-top: 1px solid var(--line); padding-top: 18px; }.items-heading { align-items: flex-start; display: flex; justify-content: space-between; gap: 12px; }.items-heading h3 { font: 600 18px 'Space Grotesk', sans-serif; margin: 0; }.item-add-actions { display: flex; gap: 6px; }.item-add-actions .secondary-button { padding: 8px 10px; }.service-item { background: var(--paper); border: 1px solid var(--line); display: grid; gap: 10px; margin-top: 12px; padding: 14px; }.item-row-heading { display: flex; justify-content: space-between; }.remove-item-button { background: transparent; border: 0; color: #a15b4e; cursor: pointer; font-size: 12px; font-weight: 700; }.item-line-total { color: var(--ink); font-size: 12px; font-weight: 700; }.items-total { display: block; font: 600 18px 'Space Grotesk', sans-serif; margin-top: 16px; text-align: right; }
diff --git a/server.js b/server.js
index 671164e..016df26 100644
--- a/server.js
+++ b/server.js
@@ -26,6 +26,51 @@ async function insertItems(client, serviceOrderId, items) {
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
+const dataTables = [
+ { name: 'customers', columns: ['customer_id', 'first_name', 'last_name', 'email', 'phone', 'created_at'] },
+ { name: 'employees', columns: ['employee_id', 'first_name', 'last_name', 'role', 'email', 'active'] },
+ { name: 'vehicles', columns: ['vehicle_id', 'customer_id', 'registration_number', 'make', 'model', 'model_year', 'engine', 'transmission', 'fuel_type', 'color', 'vin', 'mileage', 'created_at'] },
+ { name: 'appointments', columns: ['appointment_id', 'vehicle_id', 'employee_id', 'scheduled_at', 'reason', 'status', 'created_at'] },
+ { name: 'service_orders', columns: ['service_order_id', 'vehicle_id', 'assigned_employee_id', 'opened_at', 'closed_at', 'complaint', 'service_date', 'diagnosis', 'status'] },
+ { name: 'service_order_items', columns: ['item_id', 'service_order_id', 'item_type', 'description', 'part_number', 'labor_time_minutes', 'quantity', 'unit_price', 'created_at'] },
+ { name: 'invoices', columns: ['invoice_id', 'service_order_id', 'issued_at', 'due_at', 'status', 'tax_rate'] },
+ { name: 'app_users', columns: ['user_id', 'username', 'display_name', 'email', 'role', 'can_read', 'can_add', 'can_delete', 'created_at'] }
+];
+
+app.get('/api/data/export', async (_request, response) => {
+ try {
+ const tables = {};
+ for (const table of dataTables) {
+ const result = await pool.query(`SELECT ${table.columns.join(', ')} FROM ${table.name} ORDER BY 1`);
+ tables[table.name] = result.rows;
+ }
+ response.json({ format: 'car-service-database', version: 1, exportedAt: new Date().toISOString(), tables });
+ } catch (error) { response.status(500).json({ error: 'Unable to export database data.' }); }
+});
+
+app.post('/api/data/import', async (request, response) => {
+ const snapshot = request.body;
+ if (!snapshot || snapshot.format !== 'car-service-database' || snapshot.version !== 1 || !snapshot.tables || typeof snapshot.tables !== 'object') return response.status(400).json({ error: 'Invalid backup file.' });
+ for (const table of dataTables) if (!Array.isArray(snapshot.tables[table.name])) return response.status(400).json({ error: `Backup is missing the ${table.name} table.` });
+ const client = await pool.connect();
+ try {
+ await client.query('BEGIN');
+ await client.query('TRUNCATE TABLE invoices, service_order_items, service_orders, appointments, vehicles, employees, customers, app_users RESTART IDENTITY CASCADE');
+ for (const table of dataTables) {
+ for (const row of snapshot.tables[table.name]) {
+ const values = table.columns.map((column) => row[column] === undefined ? null : row[column]);
+ const placeholders = values.map((_value, index) => `$${index + 1}`).join(', ');
+ await client.query(`INSERT INTO ${table.name} (${table.columns.join(', ')}) VALUES (${placeholders})`, values);
+ }
+ }
+ await client.query('COMMIT');
+ response.json({ imported: dataTables.reduce((total, table) => total + snapshot.tables[table.name].length, 0) });
+ } catch (error) {
+ await client.query('ROLLBACK');
+ response.status(400).json({ error: 'Import failed. No existing data was changed.' });
+ } finally { client.release(); }
+});
+
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`);