Add user accounting

This commit is contained in:
2026-08-27 00:10:53 +03:00
parent ce70b7322f
commit b3ea30e807
6 changed files with 105 additions and 16 deletions
+24 -6
View File
@@ -1,4 +1,4 @@
const state = { orders: [], users: [], vehicles: [], recordItems: [] };
const state = { orders: [], users: [], vehicles: [], recordItems: [], user: null };
const $ = (selector) => document.querySelector(selector);
async function request(url, options) {
@@ -13,6 +13,10 @@ function formatTime(value) { return new Date(value).toLocaleTimeString([], { hou
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[character])); }
function canModify() { return ['admin', 'technician', 'service_advisor'].includes(state.user?.role) || state.user?.can_add === true; }
function canDelete() { return state.user?.role === 'admin'; }
async function loadAuth() { try { state.user = (await request('/api/auth/me')).user; updateAuthUi(); } catch (error) { showError(error); } }
function updateAuthUi() { const signedIn = Boolean(state.user); $('#current-user').textContent = signedIn ? `${state.user.display_name} · ${state.user.role.replaceAll('_', ' ')}` : 'Read-only mode'; $('#login-button').hidden = signedIn; $('#logout-button').hidden = !signedIn; document.querySelectorAll('#new-customer-button, #new-customer-button-secondary, #new-vehicle-button, #new-record-button').forEach((button) => { button.hidden = !canModify(); }); $('#new-user-button').hidden = !canDelete(); if (state.users.length) renderUsers(); renderOrders(); }
async function loadOverview() {
try {
@@ -53,7 +57,7 @@ function renderOrders() {
const serviceDate = String(order.service_date || '').slice(0, 10);
return (!vehicleFilter || vehicleText.includes(vehicleFilter)) && (!customerFilter || customerText.includes(customerFilter)) && (!technicianFilter || technicianText.includes(technicianFilter)) && (!filters.dateFrom || serviceDate >= filters.dateFrom) && (!filters.dateTo || serviceDate <= filters.dateTo) && (!filters.status || order.status === filters.status);
});
$('#orders-table').innerHTML = visibleOrders.length ? visibleOrders.map((order) => `<tr><td>${escapeHtml(order.service_date)}</td><td>${escapeHtml(order.vin || '—')}</td><td><strong>${escapeHtml(order.license_plate)}</strong></td><td>${escapeHtml(`${order.make || ''} ${order.model || ''}`.trim())}</td><td>${Number(order.mileage).toLocaleString()} km</td><td>${escapeHtml(order.customer_name)}</td><td>${escapeHtml(order.technician)}</td><td>${escapeHtml(order.complaint)}</td><td>€${Number(order.total || 0).toFixed(2)}</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="11">${emptyMessage(state.orders.length ? 'No service records match these filters.' : 'No service records yet.')}</td></tr>`;
$('#orders-table').innerHTML = visibleOrders.length ? visibleOrders.map((order) => `<tr><td>${escapeHtml(order.service_date)}</td><td>${escapeHtml(order.vin || '—')}</td><td><strong>${escapeHtml(order.license_plate)}</strong></td><td>${escapeHtml(`${order.make || ''} ${order.model || ''}`.trim())}</td><td>${Number(order.mileage).toLocaleString()} km</td><td>${escapeHtml(order.customer_name)}</td><td>${escapeHtml(order.technician)}</td><td>${escapeHtml(order.complaint)}</td><td>€${Number(order.total || 0).toFixed(2)}</td><td><select class="status-select" data-order-id="${order.service_order_id}" ${canModify() ? '' : 'disabled'}><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>${canModify() ? `<button class="edit-button" data-record-id="${order.service_order_id}">Edit</button>` : '<span class="read-only-label">Read only</span>'}</td></tr>`).join('') : `<tr><td colspan="11">${emptyMessage(state.orders.length ? 'No service records match these filters.' : 'No service records yet.')}</td></tr>`;
document.querySelectorAll('.status-select').forEach((select) => select.addEventListener('change', updateOrderStatus));
document.querySelectorAll('.edit-button[data-record-id]').forEach((button) => button.addEventListener('click', () => editRecord(state.orders.find((order) => order.service_order_id === button.dataset.recordId))));
}
@@ -68,12 +72,16 @@ async function loadVehicles() {
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))));
renderUsers();
} catch (error) { showError(error); }
}
function renderUsers() {
$('#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>${canDelete() ? `<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>` : '<span class="read-only-label">Admin only</span>'}</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))));
}
function editUser(user) {
const form = $('#user-form'); form.dataset.editId = user.user_id;
form.elements.username.value = user.username; form.elements.username.disabled = true;
@@ -122,6 +130,10 @@ function switchView(viewName) {
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()));
$('#login-button').addEventListener('click', () => $('#login-dialog').showModal());
$('#cancel-login-button').addEventListener('click', () => $('#login-dialog').close());
$('#close-login-button').addEventListener('click', () => $('#login-dialog').close());
$('#logout-button').addEventListener('click', async () => { await request('/api/auth/logout', { method: 'POST' }); state.user = null; updateAuthUi(); });
$('#cancel-customer-button').addEventListener('click', () => $('#customer-dialog').close());
$('#close-customer-button').addEventListener('click', () => $('#customer-dialog').close());
$('#new-user-button').addEventListener('click', () => { const form = $('#user-form'); delete form.dataset.editId; form.reset(); form.elements.username.disabled = false; $('#user-dialog-title').textContent = 'Add user'; $('#user-submit-button').textContent = 'Create user'; $('#user-dialog').showModal(); });
@@ -193,11 +205,16 @@ $('#user-form').addEventListener('submit', async (event) => {
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') }) });
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'), password: form.get('password'), 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); }
});
$('#login-form').addEventListener('submit', async (event) => {
event.preventDefault();
try { state.user = (await request('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(Object.fromEntries(new FormData(event.target))) })).user; event.target.reset(); $('#login-dialog').close(); updateAuthUi(); } catch (error) { showError(error); }
});
$('#record-form').addEventListener('submit', async (event) => {
event.preventDefault();
try {
@@ -209,4 +226,5 @@ $('#record-form').addEventListener('submit', async (event) => {
} catch (error) { showError(error); }
});
loadAuth();
loadOverview();
+3 -2
View File
@@ -22,7 +22,7 @@
</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>
<header class="topbar"><div><p class="eyebrow">Wednesday, August 26, 2026</p><h1 id="page-title">Good morning, workshop.</h1></div><div class="topbar-actions"><span id="current-user" class="account-state">Read-only mode</span><button class="secondary-button" id="login-button">Sign in</button><button class="secondary-button" id="logout-button" hidden>Sign out</button><button class="primary-button" id="new-customer-button">+ New customer</button></div></header>
<div id="notice" class="notice" role="status"></div>
<section id="overview-view" class="view active-view">
@@ -50,7 +50,8 @@
<dialog id="customer-dialog"><form method="dialog" id="customer-form"><div class="dialog-heading"><div><p class="eyebrow">Directory</p><h2>Add customer</h2></div><button class="close-button" type="button" id="close-customer-button" aria-label="Close">×</button></div><label>First name<input name="firstName" required /></label><label>Last name<input name="lastName" required /></label><label>Email<input name="email" type="email" /></label><label>Phone<input name="phone" type="tel" /></label><div class="dialog-actions"><button class="secondary-button" type="button" id="cancel-customer-button">Cancel</button><button class="primary-button" value="default">Save customer</button></div></form></dialog>
<dialog id="vehicle-dialog"><form method="dialog" id="vehicle-form"><div class="dialog-heading"><div><p class="eyebrow">Vehicle register</p><h2>Add vehicle</h2></div><button class="close-button" type="button" id="close-vehicle-button" aria-label="Close">×</button></div><p class="form-hint">Use an existing customer email to link this vehicle to that customer.</p><label>Customer name<input name="customerName" required /></label><label>Customer email<input name="customerEmail" type="email" /></label><label>Customer phone<input name="customerPhone" type="tel" /></label><label>VIN<input name="vin" minlength="17" maxlength="17" required /></label><label>License plate number<input name="licensePlate" required /></label><label>Make<input name="make" required /></label><label>Model<input name="model" required /></label><label>Model year<input name="modelYear" type="number" min="1886" max="2100" /></label><label>Engine<input name="engine" /></label><label>Transmission<input name="transmission" /></label><label>Fuel type<input name="fuelType" /></label><label>Color<input name="color" /></label><label>Car mileage<input name="mileage" type="number" min="0" step="1" value="0" required /></label><div class="dialog-actions"><button class="secondary-button" type="button" id="cancel-vehicle-button">Cancel</button><button class="primary-button" value="default">Create vehicle</button></div></form></dialog>
<dialog id="user-dialog"><form method="dialog" id="user-form"><div class="dialog-heading"><div><p class="eyebrow">Access control</p><h2 id="user-dialog-title">Add user</h2></div><button class="close-button" type="button" id="close-user-button" aria-label="Close">×</button></div><label>Username<input name="username" required /></label><label>Display name<input name="displayName" required /></label><label>Email<input name="email" type="email" /></label><label>Role<select name="role"><option value="viewer">Viewer</option><option value="technician">Technician</option><option value="service_advisor">Service advisor</option><option value="admin">Administrator</option></select></label><fieldset><legend>Rights</legend><label class="check-label"><input name="canRead" type="checkbox" checked /> Read records</label><label class="check-label"><input name="canAdd" type="checkbox" /> Add records</label><label class="check-label"><input name="canDelete" type="checkbox" /> Delete records</label></fieldset><div class="dialog-actions"><button class="secondary-button" type="button" id="cancel-user-button">Cancel</button><button class="primary-button" id="user-submit-button" value="default">Create user</button></div></form></dialog>
<dialog id="user-dialog"><form method="dialog" id="user-form"><div class="dialog-heading"><div><p class="eyebrow">Access control</p><h2 id="user-dialog-title">Add user</h2></div><button class="close-button" type="button" id="close-user-button" aria-label="Close">×</button></div><label>Username<input name="username" required /></label><label>Display name<input name="displayName" required /></label><label>Email<input name="email" type="email" /></label><label>Password<input name="password" type="password" minlength="8" /><small class="form-hint">Required for new users. Leave blank when editing to keep the current password.</small></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="login-dialog"><form method="dialog" id="login-form"><div class="dialog-heading"><div><p class="eyebrow">Workshop access</p><h2>Sign in</h2></div><button class="close-button" type="button" id="close-login-button" aria-label="Close">×</button></div><p class="form-hint">Without an account, the dashboard remains read-only.</p><label>Username<input name="username" autocomplete="username" required /></label><label>Password<input name="password" type="password" autocomplete="current-password" required /></label><div class="dialog-actions"><button class="secondary-button" type="button" id="cancel-login-button">Cancel</button><button class="primary-button" value="default">Sign in</button></div></form></dialog>
<dialog id="record-dialog"><form method="dialog" id="record-form"><div class="dialog-heading"><div><p class="eyebrow">Workshop floor</p><h2 id="record-dialog-title">Add service record</h2></div><button class="close-button" type="button" id="close-record-button" aria-label="Close">×</button></div><p class="form-hint">Enter a known VIN or license plate to autofill the vehicle and customer. Use customer email when names are shared.</p><label>Service date<input name="serviceDate" type="date" required /></label><label>VIN<input name="vin" minlength="17" maxlength="17" /></label><label>License plate number<input name="licensePlate" /></label><label>Make<input name="make" required /></label><label>Model<input name="model" required /></label><label>Model year<input name="modelYear" type="number" min="1886" max="2100" /></label><label>Engine<input name="engine" placeholder="e.g. 2.0 TDI" /></label><label>Transmission<input name="transmission" placeholder="e.g. Automatic" /></label><label>Fuel type<input name="fuelType" placeholder="e.g. Petrol, diesel, electric" /></label><label>Color<input name="color" /></label><label>Customer name<input name="customerName" placeholder="First and last name" required /></label><label>Customer email<input name="customerEmail" type="email" /></label><label>Customer phone<input name="customerPhone" type="tel" /></label><label>Car mileage<input name="mileage" type="number" min="0" step="1" required /></label><label>Technician<input name="technicianName" required /></label><label>Complaint or service requested<textarea name="complaint" rows="3" required></textarea></label><section class="items-section"><div class="items-heading"><div><p class="eyebrow">Optional billing</p><h3>Used parts and labor</h3></div><div class="item-add-actions"><button class="secondary-button" type="button" id="add-part-button">+ Part</button><button class="secondary-button" type="button" id="add-labor-button">+ Labor</button></div></div><div id="service-items"></div><strong class="items-total">Total: <span id="service-items-total">€0.00</span></strong></section><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>
+1
View File
@@ -22,6 +22,7 @@ nav { display: grid; gap: 8px; margin-top: 76px; }
.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; }
.topbar-actions { align-items: center; display: flex; flex-wrap: wrap; gap: 10px; justify-content: flex-end; }.account-state { color: var(--muted); font-size: 12px; font-weight: 700; text-transform: capitalize; }.read-only-label { color: var(--muted); font-size: 12px; font-weight: 700; }
@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; }