From ce70b7322f531ab60a746f46cf7187f3ab643aa8 Mon Sep 17 00:00:00 2001 From: Tero Huttunen Date: Wed, 26 Aug 2026 23:52:34 +0300 Subject: [PATCH] Add data import export functions --- README.md | 2 ++ public/app.js | 17 ++++++++++++++++- public/index.html | 2 ++ public/styles.css | 2 ++ server.js | 45 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 67 insertions(+), 1 deletion(-) 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 dateVINLicense plateVehicleMileageCustomerTechnicianComplaintTotalStatusActions

Access control

User accounts

Manage workshop staff and their database rights.

UserRoleReadAddDeleteActions
+

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`);