Add data import export functions
This commit is contained in:
@@ -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:
|
||||
|
||||
+16
-1
@@ -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;
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<button class="nav-item" data-view="vehicles"><span>Vehicles</span><small>03</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>
|
||||
<button class="nav-item" data-view="settings"><span>Settings</span><small>06</small></button>
|
||||
</nav>
|
||||
<div class="sidebar-footer"><span class="status-dot"></span><span>Database online</span><small>PostgreSQL 16</small></div>
|
||||
</aside>
|
||||
@@ -43,6 +44,7 @@
|
||||
|
||||
<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>Total</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="settings-view" class="view"><div class="section-heading"><div><p class="eyebrow">Workspace</p><h2>Settings</h2><p class="subheading">Manage the workshop database and move a complete backup between environments.</p></div></div><div class="settings-grid"><section class="panel settings-card"><p class="eyebrow">Backup</p><h3>Export all data</h3><p>Download customers, vehicles, employees, appointments, service records, parts, invoices, and accounts as one JSON file.</p><button class="primary-button" id="export-data-button">Download backup</button></section><section class="panel settings-card"><p class="eyebrow">Restore</p><h3>Import all data</h3><p>Restore a JSON backup. This replaces the current database contents after the file passes validation.</p><label class="file-button">Choose backup file<input id="import-data-input" type="file" accept="application/json,.json" /></label><p id="import-file-name" class="form-hint">No file selected.</p></section></div></section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user