Files
canteen-admin-server/static/index.html
T
Leo 9794d8076c Add clear asset GPS endpoint + UI
- DELETE /api/assets/{id}/gps: clears lat/lng to NULL
- GET /api/assets/{id}: asset lookup by ID
- GET /api/assets/search?machine_id=: asset lookup by machine ID
- Export & Admin page: Clear Asset GPS card with lookup + confirmation
- Tests: 204 success + 404 not found
2026-05-22 23:36:40 -04:00

2544 lines
109 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Canteen Admin</title>
<style>
/* ═══════════════════════════════════════════════════════════════════════
DESIGN TOKENS — matching main app dark theme
═══════════════════════════════════════════════════════════════════════ */
:root {
--bg: #0d0e12;
--card: #161820;
--card2: #1c1e28;
--border: #252830;
--border2: #2e3140;
--text: #e4e4e7;
--text2: #8b8fa3;
--text3: #5b5f73;
--accent: #5b6ef7;
--accent2: #7c8cf8;
--accent-bg: #141a3a;
--green: #4ade80;
--green-bg: #1a2e1a;
--red: #f87171;
--red-bg: #2e1a1a;
--amber: #fbbf24;
--amber-bg: #2a2510;
--radius: 14px;
--radius-sm: 10px;
--radius-xs: 6px;
--sidebar-w: 240px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 14px; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
}
/* ═══════════════════════════════════════════════════════════════════════
SIDEBAR
═══════════════════════════════════════════════════════════════════════ */
.sidebar {
width: var(--sidebar-w);
background: var(--card);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
position: fixed;
top: 0; left: 0; bottom: 0;
z-index: 100;
overflow-y: auto;
}
.sidebar-header {
padding: 18px 16px;
border-bottom: 1px solid var(--border);
font-size: 16px;
font-weight: 700;
display: flex;
align-items: center;
gap: 8px;
}
.sidebar-header .sh-icon { font-size: 20px; }
.sidebar-user {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
}
.sidebar-user .su-avatar {
width: 36px; height: 36px; border-radius: 50%;
background: var(--accent-bg); color: var(--accent2);
display: flex; align-items: center; justify-content: center;
font-size: 14px; font-weight: 700; flex-shrink: 0;
}
.sidebar-user .su-info { flex: 1; min-width: 0; }
.sidebar-user .su-name { font-weight: 600; font-size: 13px; }
.sidebar-user .su-role { font-size: 11px; color: var(--text2); text-transform: capitalize; }
.sidebar-nav { flex: 1; padding: 8px 0; }
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 16px;
color: var(--text2);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.1s;
border: none;
background: transparent;
width: 100%;
text-align: left;
}
.nav-item:hover { background: rgba(255,255,255,0.03); color: var(--text); }
.nav-item.active {
background: var(--accent-bg);
color: var(--accent2);
border-right: 2px solid var(--accent);
}
.nav-item .ni-icon { font-size: 16px; width: 22px; text-align: center; flex-shrink: 0; }
.nav-item .ni-badge {
margin-left: auto;
font-size: 10px;
font-weight: 700;
padding: 2px 7px;
border-radius: 10px;
background: var(--accent-bg);
color: var(--accent2);
}
.sidebar-footer {
padding: 12px 16px;
border-top: 1px solid var(--border);
font-size: 11px;
color: var(--text3);
}
.sidebar-footer .logout-btn {
background: none; border: none; color: var(--red);
font-size: 12px; font-weight: 600; cursor: pointer;
padding: 6px 0; display: none;
width: 100%; text-align: left;
}
.sidebar-footer .logout-btn:hover { text-decoration: underline; }
/* ═══════════════════════════════════════════════════════════════════════
MAIN CONTENT
═══════════════════════════════════════════════════════════════════════ */
.main {
margin-left: var(--sidebar-w);
flex: 1;
padding: 24px;
max-width: calc(100vw - var(--sidebar-w));
}
.page-title {
font-size: 22px;
font-weight: 700;
margin-bottom: 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.page-title .pt-actions { display: flex; gap: 8px; align-items: center; }
/* ═══════════════════════════════════════════════════════════════════════
CARDS
═══════════════════════════════════════════════════════════════════════ */
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
margin-bottom: 14px;
}
.card-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text2);
margin-bottom: 12px;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.card-header .card-title { margin-bottom: 0; }
/* ═══════════════════════════════════════════════════════════════════════
BUTTONS
═══════════════════════════════════════════════════════════════════════ */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border: none;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 600;
padding: 9px 16px;
cursor: pointer;
transition: opacity 0.15s, transform 0.1s;
white-space: nowrap;
}
.btn:active { transform: scale(0.97); }
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { background: var(--accent2); }
.btn-sm { padding: 7px 12px; font-size: 12px; }
.btn-xs { padding: 4px 8px; font-size: 11px; }
.btn-outline { background: transparent; border: 1.5px solid var(--border); color: var(--text); }
.btn-danger { background: transparent; border: 1.5px solid var(--red); color: var(--red); }
.btn-green { background: var(--green); color: #000; }
.btn-green-outline { background: transparent; border: 1.5px solid var(--green); color: var(--green); }
.btn-amber { background: var(--amber); color: #000; }
.btn-block { width: 100%; }
/* ═══════════════════════════════════════════════════════════════════════
INPUTS
═══════════════════════════════════════════════════════════════════════ */
.input-field {
width: 100%;
background: #0a0b0f;
border: 1.5px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
padding: 9px 12px;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
margin-bottom: 8px;
}
.input-field:focus { border-color: var(--accent); }
.input-field::placeholder { color: #3a3d4a; }
select.input-field { appearance: none; }
textarea.input-field { resize: vertical; min-height: 60px; font-family: inherit; font-size: 13px; }
.form-row { display: flex; gap: 8px; }
.form-row .input-field { flex: 1; margin-bottom: 0; }
.form-group { margin-bottom: 10px; }
.form-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text2);
margin-bottom: 4px;
display: block;
}
/* ═══════════════════════════════════════════════════════════════════════
TABLES
═══════════════════════════════════════════════════════════════════════ */
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.data-table th {
text-align: left;
padding: 10px 12px;
border-bottom: 2px solid var(--border);
color: var(--text2);
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.03em;
white-space: nowrap;
user-select: none;
}
.data-table td {
padding: 10px 12px;
border-bottom: 1px solid rgba(255,255,255,0.04);
vertical-align: middle;
}
.data-table tr:hover td { background: rgba(255,255,255,0.02); }
.data-table .td-actions { text-align: right; white-space: nowrap; }
.data-table .td-actions button { margin-left: 4px; }
.data-table .rpt-number { text-align: right; font-variant-numeric: tabular-nums; }
.table-wrap { overflow-x: auto; }
/* ═══════════════════════════════════════════════════════════════════════
STATUS & BADGES
═══════════════════════════════════════════════════════════════════════ */
.status-tag {
display: inline-block;
font-size: 10px;
padding: 2px 8px;
border-radius: 4px;
font-weight: 600;
text-transform: uppercase;
}
.status-tag.active { background: var(--green-bg); color: var(--green); }
.status-tag.maintenance { background: var(--amber-bg); color: var(--amber); }
.status-tag.retired { background: var(--red-bg); color: var(--red); }
.role-badge {
display: inline-block;
font-size: 10px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
text-transform: uppercase;
}
.role-badge.admin { background: var(--accent-bg); color: var(--accent2); }
.role-badge.technician { background: var(--green-bg); color: var(--green); }
.role-badge.readonly { background: var(--amber-bg); color: var(--amber); }
/* ═══════════════════════════════════════════════════════════════════════
STATS GRID
═══════════════════════════════════════════════════════════════════════ */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
margin-bottom: 14px;
}
.stat-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
text-align: center;
}
.stat-card .stat-value { font-size: 30px; font-weight: 800; color: var(--accent2); }
.stat-card .stat-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text2);
margin-top: 4px;
}
/* ═══════════════════════════════════════════════════════════════════════
SETTINGS LIST ITEMS
═══════════════════════════════════════════════════════════════════════ */
.settings-list { }
.settings-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 0;
border-bottom: 1px solid rgba(255,255,255,0.04);
font-size: 14px;
}
.settings-item:last-child { border-bottom: none; }
.settings-item .si-icon { font-size: 16px; width: 22px; text-align: center; flex-shrink: 0; }
.settings-item .si-name { flex: 1; }
.settings-item .si-meta { font-size: 11px; color: var(--text2); margin-left: 8px; }
.settings-item .si-actions { display: flex; gap: 4px; flex-shrink: 0; }
.si-btn {
width: 26px; height: 26px; border: none; background: transparent;
color: var(--text2); font-size: 13px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
border-radius: var(--radius-xs);
}
.si-btn:hover { background: rgba(255,255,255,0.05); }
.si-btn.delete:hover { color: var(--red); background: var(--red-bg); }
.settings-inline-form {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
border-bottom: 1px solid var(--accent);
}
.settings-inline-form input { flex: 1; margin-bottom: 0; font-size: 13px; }
.settings-inline-form select { margin-bottom: 0; font-size: 13px; }
/* Make expand */
.settings-item.make-item { cursor: pointer; font-weight: 600; }
.settings-item.make-item .si-chevron {
transition: transform 0.2s; font-size: 11px; color: var(--text2);
}
.settings-item.make-item.expanded .si-chevron { transform: rotate(90deg); }
.models-sublist { display: none; padding-left: 32px; }
.models-sublist.open { display: block; }
.models-sublist .settings-item { font-size: 13px; padding: 7px 0; }
.add-model-row {
display: flex; align-items: center; gap: 8px;
padding: 6px 0 6px 32px; border-bottom: 1px solid rgba(255,255,255,0.04);
}
.add-model-row input { flex: 1; margin-bottom: 0; font-size: 12px; padding: 6px 10px; }
/* Emoji picker */
.emoji-picker {
display: flex; flex-wrap: wrap; gap: 4px; padding: 6px 0;
}
.emoji-picker .emoji-opt {
width: 30px; height: 30px; display: flex; align-items: center; justify-content: center;
font-size: 16px; border-radius: var(--radius-xs); cursor: pointer;
border: 1px solid transparent; background: transparent;
}
.emoji-opt:hover { background: var(--border); }
.emoji-opt.selected { border-color: var(--accent); background: var(--accent-bg); }
/* ═══════════════════════════════════════════════════════════════════════
ACTIVITY ITEMS
═══════════════════════════════════════════════════════════════════════ */
.activity-item {
display: flex;
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid rgba(255,255,255,0.03);
font-size: 13px;
}
.activity-item:last-child { border-bottom: none; }
.activity-item .act-icon {
width: 30px; height: 30px; border-radius: 50%; display: flex;
align-items: center; justify-content: center; font-size: 14px;
flex-shrink: 0; margin-top: 1px;
}
.activity-item .act-icon.created { background: var(--green-bg); }
.activity-item .act-icon.updated { background: var(--accent-bg); }
.activity-item .act-icon.deleted { background: var(--red-bg); }
.activity-item .act-icon.checked { background: var(--amber-bg); }
.activity-item .act-icon.login { background: var(--accent-bg); }
.activity-item .act-icon.user { background: #1e1a2e; }
.activity-item .act-icon.customer { background: #1a2e2a; }
.activity-item .act-icon.asset { background: #2a1a2e; }
.activity-item .act-icon.other { background: var(--border); }
.activity-item .act-info { flex: 1; min-width: 0; }
.activity-item .act-text { line-height: 1.4; }
.activity-item .act-time { font-size: 11px; color: var(--text2); margin-top: 2px; }
.act-filter-row { display: flex; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; }
.act-filter-row select, .act-filter-row input { flex: 1; min-width: 120px; margin-bottom: 0; }
.act-pagination { display: flex; gap: 8px; justify-content: center; padding: 14px 0; }
/* ═══════════════════════════════════════════════════════════════════════
CONTACT SUB-FORM
═══════════════════════════════════════════════════════════════════════ */
.contact-row {
display: flex;
flex-direction: column;
gap: 4px;
background: var(--card2);
border-radius: var(--radius-sm);
padding: 10px;
margin-bottom: 6px;
position: relative;
}
.contact-row .cr-remove {
position: absolute; top: 6px; right: 8px;
background: none; border: none; color: var(--red); font-size: 14px;
cursor: pointer; padding: 2px 6px; border-radius: 4px;
}
.contact-row .cr-remove:hover { background: var(--red-bg); }
/* ═══════════════════════════════════════════════════════════════════════
MODAL
═══════════════════════════════════════════════════════════════════════ */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
z-index: 500; display: flex; align-items: center; justify-content: center;
opacity: 0; pointer-events: none; transition: opacity 0.2s;
padding: 20px;
}
.modal-overlay.open { opacity: 1; pointer-events: auto; }
.modal {
background: var(--card); border: 1px solid var(--border);
border-radius: var(--radius); padding: 24px; width: 100%; max-width: 480px;
}
.modal .modal-title { font-size: 17px; font-weight: 700; margin-bottom: 8px; }
.modal .modal-body { font-size: 14px; color: var(--text2); margin-bottom: 16px; }
.modal .modal-actions { display: flex; gap: 8px; }
.modal .modal-actions .btn { flex: 1; }
/* ═══════════════════════════════════════════════════════════════════════
LOGIN
═══════════════════════════════════════════════════════════════════════ */
.login-overlay {
position: fixed; inset: 0; background: var(--bg);
z-index: 1000; display: flex; align-items: center; justify-content: center;
padding: 20px;
}
.login-overlay.hidden { display: none; }
.login-card {
width: 100%; max-width: 360px; background: var(--card);
border: 1px solid var(--border); border-radius: var(--radius);
padding: 28px 24px;
}
.login-card .login-icon { font-size: 48px; text-align: center; margin-bottom: 8px; }
.login-card h2 { text-align: center; font-size: 20px; margin-bottom: 20px; }
.login-card .login-error {
background: var(--red-bg); color: var(--red); font-size: 13px;
padding: 8px 12px; border-radius: var(--radius-xs); margin-bottom: 10px;
display: none;
}
.login-card .login-error.show { display: block; }
/* ═══════════════════════════════════════════════════════════════════════
SPINNER & TOAST
═══════════════════════════════════════════════════════════════════════ */
.spinner {
width: 18px; height: 18px; border: 2px solid var(--border);
border-top-color: var(--accent); border-radius: 50%;
animation: spin 0.7s linear infinite; display: inline-block;
}
@keyframes spin { to { transform: rotate(360deg); } }
.toast {
position: fixed; bottom: 24px; right: 24px; z-index: 400;
background: var(--green); color: #000;
font-weight: 700; padding: 10px 22px; border-radius: 20px; font-size: 14px;
opacity: 0; transition: opacity 0.3s; pointer-events: none;
white-space: nowrap; max-width: 90vw;
}
.toast.show { opacity: 1; }
.toast.error { background: var(--red); color: #fff; }
/* ═══════════════════════════════════════════════════════════════════════
MISC
═══════════════════════════════════════════════════════════════════════ */
.empty-state { text-align: center; padding: 28px 16px; color: var(--text2); font-size: 13px; }
.empty-state .es-icon { font-size: 36px; margin-bottom: 8px; opacity: 0.5; }
.loading-row { text-align: center; padding: 20px; color: var(--text2); }
.flex-row { display: flex; gap: 8px; align-items: center; }
.gap-sm { gap: 6px; }
.mt-8 { margin-top: 8px; }
.mb-8 { margin-bottom: 8px; }
.text-sm { font-size: 12px; }
.text-muted { color: var(--text2); }
.text-danger { color: var(--red); }
.w-full { width: 100%; }
.hidden { display: none !important; }
.mono { font-family: 'Courier New', monospace; font-size: 12px; }
/* ═══════════════════════════════════════════════════════════════════════
HAMBURGER BUTTON & SIDEBAR OVERLAY (mobile)
═══════════════════════════════════════════════════════════════════════ */
.hamburger {
display: none;
background: none; border: none; color: var(--text); font-size: 24px;
cursor: pointer; padding: 6px; margin-right: 10px;
line-height: 1; flex-shrink: 0;
}
.hamburger:hover { color: var(--accent2); }
.sidebar-overlay {
display: none;
position: fixed; inset: 0; background: rgba(0,0,0,0.5);
z-index: 99; opacity: 0; transition: opacity 0.25s;
pointer-events: none;
}
.sidebar-overlay.open { opacity: 1; pointer-events: auto; }
/* Responsive: off-canvas sidebar on narrow screens */
@media (max-width: 768px) {
.hamburger { display: inline-flex; align-items: center; }
.sidebar-overlay { display: block; }
.sidebar {
transform: translateX(-100%);
transition: transform 0.25s ease;
z-index: 100;
}
.sidebar.open { transform: translateX(0); }
.main {
margin-left: 0;
padding: 16px;
max-width: 100vw;
}
.page-title .pt-actions .btn .btn-label { display: none; }
.page-title .pt-actions .btn { min-width: 36px; justify-content: center; }
/* Stats grid: tighter cards */
.stats-grid { grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 6px; }
.stat-card { padding: 10px; }
.stat-card .stat-value { font-size: 22px; }
/* Import batch rows stack */
.cs-batch-row .csb-status { width: 60px; }
.cs-batch-row .csb-time { width: auto; font-size: 11px; }
.cs-batch-row .csb-rows { width: auto; text-align: left; margin-left: auto; }
/* Changes table horizontal scroll */
.cs-changes-table-wrapper { overflow-x: auto; }
.cs-changes-table { min-width: 480px; }
/* Page actions */
.page-title { flex-wrap: wrap; gap: 6px; }
.page-title h2 { font-size: 16px; }
/* Form rows stack */
.form-row { flex-direction: column; }
.form-row .input-field { flex: none; width: 100%; }
}
@media (max-width: 480px) {
.main { padding: 10px; }
.stats-grid { grid-template-columns: repeat(2, 1fr); gap: 4px; }
.stat-card { padding: 8px; }
.stat-card .stat-value { font-size: 18px; }
.stat-card .stat-label { font-size: 9px; }
/* Dashboard page title */
.page-title { flex-direction: column; align-items: flex-start; }
.page-title .pt-actions { width: 100%; }
.page-title .pt-actions button { flex: 1; }
/* Import diff badges stack 2x2 */
.cs-diff-summary { grid-template-columns: repeat(2, 1fr); }
.cs-diff-badge { padding: 8px; }
.cs-diff-badge .csd-val { font-size: 18px; }
/* Batch rows compact */
.cs-batch-row { flex-wrap: wrap; gap: 4px; padding: 8px; }
.cs-batch-row .csb-time { font-size: 10px; width: 100%; order: 3; }
/* Card padding */
.card { padding: 10px; }
.card-title { font-size: 13px; }
/* Tables scroll */
.cs-actions { flex-direction: column; }
.cs-actions button { width: 100%; }
}
/* ═══════════════════════════════════════════════════════════════════════
EXCEL IMPORT
═══════════════════════════════════════════════════════════════════════ */
.cs-diff-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 8px;
margin-bottom: 14px;
}
.cs-diff-badge {
background: var(--card2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 12px;
text-align: center;
}
.cs-diff-badge .csd-val { font-size: 24px; font-weight: 800; }
.cs-diff-badge .csd-label { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text2); margin-top: 2px; }
.cs-diff-badge.add .csd-val { color: var(--green); }
.cs-diff-badge.chg .csd-val { color: var(--amber); }
.cs-diff-badge.rem .csd-val { color: var(--red); }
.cs-diff-badge.unc .csd-val { color: var(--text2); }
.cs-batch-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-bottom: 1px solid rgba(255,255,255,0.04);
cursor: pointer;
transition: background 0.1s;
font-size: 13px;
}
.cs-batch-row:hover { background: rgba(255,255,255,0.03); }
.cs-batch-row .csb-status { width: 80px; flex-shrink: 0; }
.cs-batch-row .csb-time { width: 160px; flex-shrink: 0; color: var(--text2); }
.cs-batch-row .csb-rows { width: 80px; text-align: right; flex-shrink: 0; font-variant-numeric: tabular-nums; }
.cs-batch-row .csb-chevron { font-size: 11px; color: var(--text2); transition: transform 0.2s; flex-shrink: 0; }
.cs-batch-row.expanded .csb-chevron { transform: rotate(90deg); }
.cs-batch-detail {
display: none;
padding: 0 12px 14px 36px;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.cs-batch-detail.open { display: block; }
.cs-changes-table { width: 100%; border-collapse: collapse; font-size: 12px; margin-top: 8px; }
.cs-changes-table th { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); color: var(--text2); font-size: 10px; text-transform: uppercase; letter-spacing: 0.03em; }
.cs-changes-table td { padding: 6px 8px; border-bottom: 1px solid rgba(255,255,255,0.03); vertical-align: middle; }
.cs-changes-table .cs-old { color: var(--red); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cs-changes-table .cs-new { color: var(--green); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cs-changes-table .cs-arrow { color: var(--text2); padding: 0 4px; }
.cs-actions { display: flex; gap: 8px; margin-top: 12px; }
</style>
</head>
<body>
<!-- ═══ LOGIN OVERLAY ═══ -->
<div id="loginOverlay" class="login-overlay">
<div class="login-card">
<div class="login-icon">🗝️</div>
<h2>Canteen Admin</h2>
<div id="loginError" class="login-error"></div>
<input id="loginUser" class="input-field" type="text" placeholder="Username" autocomplete="username">
<input id="loginPass" class="input-field" type="password" placeholder="Password" autocomplete="current-password">
<button id="loginBtn" class="btn btn-primary btn-block" onclick="doLogin()">Sign In</button>
<div style="text-align:center;font-size:12px;color:var(--text3);margin-top:10px;">Default: admin / changeme</div>
</div>
</div>
<!-- ═══ SIDEBAR OVERLAY (mobile) ═══ -->
<div id="sidebarOverlay" class="sidebar-overlay" onclick="toggleSidebar()"></div>
<!-- ═══ SIDEBAR ═══ -->
<div class="sidebar" id="sidebar"> <!-- add hamburger to title bar-->
<div class="sidebar-header">
<span class="sh-icon">📊</span>
<span>Canteen Admin</span>
</div>
<div class="sidebar-user">
<div class="su-avatar" id="sideAvatar">?</div>
<div class="su-info">
<div class="su-name" id="sideName">Not logged in</div>
<div class="su-role" id="sideRole">guest</div>
</div>
</div>
<div class="sidebar-nav">
<button class="nav-item" data-page="dashboard" onclick="switchPage('dashboard')">
<span class="ni-icon">📈</span><span>Dashboard</span>
</button>
<button class="nav-item" data-page="settings" onclick="switchPage('settings')">
<span class="ni-icon">⚙️</span><span>Settings</span>
</button>
<button class="nav-item" data-page="users" onclick="switchPage('users')">
<span class="ni-icon">👥</span><span>Users</span><span class="ni-badge" id="userBadgeNav"></span>
</button>
<button class="nav-item" data-page="customers" onclick="switchPage('customers')">
<span class="ni-icon">🏢</span><span>Customers</span><span class="ni-badge" id="custBadgeNav"></span>
</button>
<button class="nav-item" data-page="activity" onclick="switchPage('activity')">
<span class="ni-icon">📋</span><span>Activity Log</span>
</button>
<button class="nav-item" data-page="export" onclick="switchPage('export')">
<span class="ni-icon">📤</span><span>Export</span>
</button>
<button class="nav-item" data-page="cantaloupe" onclick="switchPage('cantaloupe')">
<span class="ni-icon">📥</span><span>Import</span>
</button>
</div>
<div class="sidebar-footer">
<button id="logoutBtn" class="logout-btn" onclick="doLogout()">🚪 Sign out</button>
<span>v3.0 admin</span>
</div>
</div>
<!-- ═══ MAIN CONTENT ═══ -->
<div class="main">
<!-- Hamburger button (mobile) -->
<div style="display:flex;align-items:center;margin-bottom:12px;" class="mobile-title-row">
<button class="hamburger" onclick="toggleSidebar()" aria-label="Toggle menu"></button>
</div>
<!-- ── DASHBOARD ── -->
<div id="pageDashboard" class="page">
<div class="page-title">
<span>Dashboard</span>
<div class="pt-actions">
<button class="btn btn-sm btn-outline" onclick="loadDashboard()">🔄 Refresh</button>
</div>
</div>
<div id="dashStats" class="stats-grid"></div>
<div class="card">
<div class="card-title">By Category</div>
<div id="dashCategoryBars"></div>
</div>
<div class="card">
<div class="card-title">By Make</div>
<div id="dashMakeBars"></div>
</div>
<div class="card">
<div class="card-title">By Status</div>
<div id="dashStatusBars"></div>
</div>
<div class="card">
<div class="card-title">Recent Activity</div>
<div id="dashActivity"></div>
<div style="text-align:center;margin-top:10px;">
<button class="btn btn-sm btn-outline" onclick="switchPage('activity')">View Full Log →</button>
</div>
</div>
</div>
<!-- ── SETTINGS ── -->
<div id="pageSettings" class="page hidden">
<div class="page-title">
<span>Settings</span>
<div class="pt-actions">
<button class="btn btn-sm btn-outline" onclick="renderAllSettings()">🔄 Refresh</button>
</div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">Categories</span>
<button class="btn btn-sm btn-outline" onclick="showSettingsAddForm('categories')">+ Add</button>
</div>
<div id="settingsCategories" class="settings-list"></div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">Makes & Models</span>
<button class="btn btn-sm btn-outline" onclick="showSettingsAddForm('makes')">+ Add Make</button>
</div>
<div id="settingsMakes" class="settings-list"></div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">Key Names</span>
<button class="btn btn-sm btn-outline" onclick="showSettingsAddForm('key_names')">+ Add</button>
</div>
<div id="settingsKeyNames" class="settings-list"></div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">Key Types</span>
<button class="btn btn-sm btn-outline" onclick="showSettingsAddForm('key_types')">+ Add</button>
</div>
<div id="settingsKeyTypes" class="settings-list"></div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">Badge Types</span>
<button class="btn btn-sm btn-outline" onclick="showSettingsAddForm('badge_types')">+ Add</button>
</div>
<div id="settingsBadgeTypes" class="settings-list"></div>
</div>
</div>
<!-- ── USERS ── -->
<div id="pageUsers" class="page hidden">
<div class="page-title">
<span>User Accounts</span>
<div class="pt-actions">
<button class="btn btn-sm btn-outline" onclick="showAddUserForm()">+ Add User</button>
<button class="btn btn-sm btn-outline" onclick="loadUsers()">🔄 Refresh</button>
</div>
</div>
<div id="usersContent"></div>
</div>
<!-- ── CUSTOMERS & LOCATIONS ── -->
<div id="pageCustomers" class="page hidden">
<div class="page-title">
<span>Customers & Locations</span>
<div class="pt-actions">
<button class="btn btn-sm btn-outline" onclick="showAddCustomerForm()">+ Add Customer</button>
<button class="btn btn-sm btn-outline" onclick="loadCustomers()">🔄 Refresh</button>
</div>
</div>
<input id="custSearch" type="text" class="input-field" placeholder="Search customers..." oninput="loadCustomers()">
<div id="customersContent"></div>
<div id="customerDetail" class="hidden">
<button class="btn btn-sm btn-outline mb-8" onclick="closeCustomerDetail()">← Back</button>
<div id="customerDetailContent"></div>
</div>
</div>
<!-- ── ACTIVITY LOG ── -->
<div id="pageActivity" class="page hidden">
<div class="page-title">
<span>Activity Log</span>
<div class="pt-actions">
<button class="btn btn-sm btn-outline" onclick="loadActivity()">🔄 Refresh</button>
</div>
</div>
<div class="card">
<div class="act-filter-row">
<select id="actUserFilter" class="input-field" onchange="loadActivity()">
<option value="">All users</option>
</select>
<select id="actTypeFilter" class="input-field" onchange="loadActivity()">
<option value="">All types</option>
<option value="asset">Assets</option>
<option value="checkin">Check-ins</option>
<option value="user">Users</option>
<option value="customer">Customers</option>
<option value="location">Locations</option>
<option value="settings">Settings</option>
<option value="geofence">Geofences</option>
</select>
<input id="actDateFrom" class="input-field" type="date" onchange="loadActivity()">
<input id="actDateTo" class="input-field" type="date" onchange="loadActivity()">
</div>
<div id="activityList"></div>
<div id="activityPagination" class="act-pagination"></div>
</div>
</div>
<!-- ── EXPORT ── -->
<div id="pageExport" class="page hidden">
<div class="page-title">
<span>Export & Admin</span>
</div>
<div class="card">
<div class="card-title">CSV Exports</div>
<div style="display:flex;flex-wrap:wrap;gap:8px;">
<a href="/api/export/assets" class="btn btn-outline" download>📄 Export Assets CSV</a>
<a href="/api/export/checkins" class="btn btn-outline" download>📄 Export Check-Ins CSV</a>
</div>
</div>
<div class="card">
<div class="card-title">Clear Asset GPS</div>
<p style="font-size:13px;color:var(--text2);margin-bottom:10px;">
Clear GPS coordinates from an asset. Enter the asset ID or machine ID.
</p>
<div style="display:flex;gap:8px;margin-bottom:10px;">
<input id="clearGpsAssetId" type="text" class="input-field" placeholder="Asset ID or Machine ID" style="flex:1;">
<button class="btn btn-outline btn-sm" onclick="clearAssetGps()">📍 Clear GPS</button>
</div>
<div id="clearGpsResult" style="display:none;"></div>
</div>
<div class="card">
<div class="card-title">Database</div>
<div style="display:flex;flex-wrap:wrap;gap:8px;">
<button class="btn btn-danger btn-sm" onclick="confirmResetDb()">⚠️ Reset Database</button>
</div>
<div id="resetResult" class="text-sm text-muted mt-8"></div>
</div>
</div>
<!-- ── EXCEL IMPORT ── -->
<div id="pageCantaloupe" class="page hidden">
<div class="page-title">
<span>Excel Import</span>
<div class="pt-actions">
<input type="file" id="csFileInput" accept=".xlsx,.xls" style="display:none" onchange="uploadSyncFile(event)">
<button class="btn btn-sm btn-primary" onclick="document.getElementById('csFileInput').click()">📤 Upload Excel</button>
<button class="btn btn-sm btn-outline" onclick="loadCantaloupeSync()">↻ Refresh</button>
</div>
</div>
<!-- Section 1: Sync Overview -->
<div class="card" id="csOverview">
<div class="card-title">Latest Sync Status</div>
<div id="csOverviewContent"><div class="loading-row"><div class="spinner"></div> Loading...</div></div>
</div>
<!-- Section 2: Pending Batch Review -->
<div class="card hidden" id="csPendingReview">
<div class="card-title">Pending Batch — Review Changes</div>
<div id="csPendingContent"></div>
</div>
<!-- Section 3: Import History -->
<div class="card">
<div class="card-title">Import History</div>
<div id="csHistoryContent"><div class="loading-row"><div class="spinner"></div> Loading...</div></div>
</div>
</div>
<!-- ── DETAIL PAGES ── -->
<div id="pageDetail" class="page hidden">
<div class="page-title">
<span id="detailTitle">Details</span>
<div class="pt-actions">
<button class="btn btn-sm btn-outline" onclick="closeDetail()">← Back</button>
</div>
</div>
<div id="detailContent"></div>
</div>
</div>
<!-- ═══ MODAL ═══ -->
<div id="modalOverlay" class="modal-overlay" onclick="closeModal(false)">
<div class="modal" onclick="event.stopPropagation()">
<div id="modalTitle" class="modal-title"></div>
<div id="modalBody" class="modal-body"></div>
<div id="modalActions" class="modal-actions"></div>
</div>
</div>
<!-- ═══ TOAST ═══ -->
<div id="toast" class="toast"></div>
<script>
// ═════════════════════════════════════════════════════════════════════════════
// API WRAPPER
// ═════════════════════════════════════════════════════════════════════════════
async function api(url, opts = {}) {
if (AppState.authToken) {
opts.headers = opts.headers || {};
if (!opts.headers['Authorization']) {
opts.headers['Authorization'] = 'Bearer ' + AppState.authToken;
}
}
try {
const res = await fetch(url, opts);
if (res.status === 204) return null;
const text = await res.text();
let data = null;
try { data = JSON.parse(text); } catch (e) { /* not JSON */ }
if (!res.ok) {
const detail = (data && data.detail) || text || `HTTP ${res.status}`;
throw new Error(detail);
}
return data;
} catch (e) {
if (e.name === 'TypeError' && e.message === 'Failed to fetch') {
throw new Error('Network error — check your connection');
}
throw e;
}
}
// ═════════════════════════════════════════════════════════════════════════════
// APP STATE
// ═════════════════════════════════════════════════════════════════════════════
const AppState = {
currentUser: null,
authToken: null,
currentPage: 'dashboard',
settingsCache: {},
};
function isLoggedIn() { return !!AppState.authToken; }
function isAdmin() { return AppState.currentUser && AppState.currentUser.role === 'admin'; }
function updateUserUI() {
const u = AppState.currentUser;
const initial = u ? u.username.charAt(0).toUpperCase() : '?';
const name = u ? u.username : 'Not logged in';
const role = u ? u.role : 'guest';
document.getElementById('sideAvatar').textContent = initial;
document.getElementById('sideName').textContent = name;
document.getElementById('sideRole').textContent = role;
const logoutBtn = document.getElementById('logoutBtn');
if (logoutBtn) logoutBtn.style.display = isLoggedIn() ? 'block' : 'none';
}
// ── Sidebar toggle (mobile) ──
function toggleSidebar() {
document.getElementById('sidebar').classList.toggle('open');
document.getElementById('sidebarOverlay').classList.toggle('open');
}
// ═════════════════════════════════════════════════════════════════════════════
// TOAST & MODAL
// ═════════════════════════════════════════════════════════════════════════════
function showToast(msg, isError = false) {
const t = document.getElementById('toast');
t.textContent = msg;
t.className = 'toast' + (isError ? ' error' : '');
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 3000);
}
let modalResolve = null;
function showModal(title, body, confirmText = 'Confirm', confirmClass = 'btn-danger') {
return new Promise((resolve) => {
modalResolve = resolve;
document.getElementById('modalTitle').textContent = title;
document.getElementById('modalBody').innerHTML = body;
document.getElementById('modalActions').innerHTML = `
<button class="btn btn-outline" onclick="closeModal(false)">Cancel</button>
<button class="btn ${confirmClass}" onclick="closeModal(true)">${confirmText}</button>
`;
document.getElementById('modalOverlay').classList.add('open');
});
}
function closeModal(confirmed = false) {
document.getElementById('modalOverlay').classList.remove('open');
if (modalResolve) { modalResolve(confirmed); modalResolve = null; }
}
// ═════════════════════════════════════════════════════════════════════════════
// PAGE ROUTING
// ═════════════════════════════════════════════════════════════════════════════
function switchPage(page) {
AppState.currentPage = page;
document.querySelectorAll('.page').forEach(p => p.classList.add('hidden'));
const target = document.getElementById('page' + page.charAt(0).toUpperCase() + page.slice(1));
if (target) target.classList.remove('hidden');
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
const navItem = document.querySelector(`.nav-item[data-page="${page}"]`);
if (navItem) navItem.classList.add('active');
// Close sidebar on mobile after nav
document.getElementById('sidebar')?.classList.remove('open');
document.getElementById('sidebarOverlay')?.classList.remove('open');
// Lazy load
if (page === 'dashboard') loadDashboard();
else if (page === 'settings') renderAllSettings();
else if (page === 'users') loadUsers();
else if (page === 'customers') loadCustomers();
else if (page === 'activity') loadActivity();
else if (page === 'cantaloupe') loadCantaloupeSync();
}
function closeDetail() {
if (AppState._prevPage) switchPage(AppState._prevPage);
else switchPage('dashboard');
}
// ═════════════════════════════════════════════════════════════════════════════
// AUTH
// ═════════════════════════════════════════════════════════════════════════════
async function doLogin() {
const username = document.getElementById('loginUser').value.trim();
const password = document.getElementById('loginPass').value;
if (!username || !password) {
showLoginError('Please enter username and password');
return;
}
try {
const data = await api('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
AppState.currentUser = { id: data.id, username: data.username, role: data.role };
AppState.authToken = data.token;
localStorage.setItem('adminToken', data.token);
sessionStorage.setItem('adminUser', JSON.stringify(AppState.currentUser));
document.getElementById('loginOverlay').classList.add('hidden');
updateUserUI();
switchPage('dashboard');
} catch (e) {
showLoginError(e.message);
}
}
function showLoginError(msg) {
const el = document.getElementById('loginError');
el.textContent = msg;
el.classList.add('show');
}
function doLogout() {
AppState.currentUser = null;
AppState.authToken = null;
localStorage.removeItem('adminToken');
document.getElementById('loginOverlay').classList.remove('hidden');
updateUserUI();
// Close sidebar on mobile
document.getElementById('sidebar')?.classList.remove('open');
document.getElementById('sidebarOverlay')?.classList.remove('open');
}
async function initAuth() {
// Try to restore session from stored token — admin server may not have /auth/me,
// so we just try a lightweight API call to verify the token is still valid.
const savedToken = localStorage.getItem('adminToken');
if (savedToken) {
AppState.authToken = savedToken;
try {
// Verify token by fetching a protected endpoint that returns user info
const users = await api('/api/users?limit=1');
// If we got here, token is valid — but we need user info.
// The login response stored user data, try sessionStorage fallback.
const savedUser = sessionStorage.getItem('adminUser');
if (savedUser) {
AppState.currentUser = JSON.parse(savedUser);
document.getElementById('loginOverlay').classList.add('hidden');
updateUserUI();
switchPage('dashboard');
return;
}
} catch (e) { /* token expired or admin server without /auth/me */ }
}
document.getElementById('loginOverlay').classList.remove('hidden');
document.getElementById('loginUser').focus();
}
// ═════════════════════════════════════════════════════════════════════════════
// DASHBOARD
// ═════════════════════════════════════════════════════════════════════════════
async function loadDashboard() {
try {
const [stats, activity] = await Promise.all([
api('/api/stats'),
api('/api/activity?limit=10'),
]);
renderDashStats(stats || {});
renderDashCategoryBars(stats || {});
renderDashMakeBars(stats || {});
renderDashStatusBars(stats || {});
renderDashActivity(activity || []);
} catch (e) {
document.getElementById('dashStats').innerHTML =
`<div class="card" style="grid-column:1/-1;text-align:center;color:var(--red);">Failed to load: ${esc(e.message)}</div>`;
}
}
function renderDashStats(stats) {
const total = stats.total_assets || 0;
const active = stats.by_status?.active || 0;
const maint = stats.by_status?.maintenance || 0;
const retired = stats.by_status?.retired || 0;
const checkins = stats.total_checkins || 0;
const customers = stats.total_customers || 0;
const locations = stats.total_locations || 0;
const users = stats.total_users || 0;
document.getElementById('dashStats').innerHTML = `
<div class="stat-card"><div class="stat-value">${total}</div><div class="stat-label">Total Assets</div></div>
<div class="stat-card"><div class="stat-value">${active}</div><div class="stat-label">Active</div></div>
<div class="stat-card"><div class="stat-value" style="color:var(--amber)">${maint}</div><div class="stat-label">Maintenance</div></div>
<div class="stat-card"><div class="stat-value" style="color:var(--red)">${retired}</div><div class="stat-label">Retired</div></div>
<div class="stat-card"><div class="stat-value">${checkins}</div><div class="stat-label">Check-Ins</div></div>
<div class="stat-card"><div class="stat-value">${customers}</div><div class="stat-label">Customers</div></div>
<div class="stat-card"><div class="stat-value">${locations}</div><div class="stat-label">Locations</div></div>
<div class="stat-card"><div class="stat-value">${users}</div><div class="stat-label">Users</div></div>
`;
}
function renderBar(elId, items, labelKey, valueKey, max) {
const el = document.getElementById(elId);
if (!items || items.length === 0) {
el.innerHTML = '<div class="empty-state"><div class="es-icon">📭</div>No data</div>';
return;
}
const mx = max || Math.max(...items.map(i => i[valueKey]));
el.innerHTML = items.map(i => {
const pct = mx > 0 ? (i[valueKey] / mx * 100) : 0;
return `<div class="stat-bar">
<span class="sb-label">${esc(i[labelKey] || '')}</span>
<span class="sb-track"><span class="sb-fill" style="width:${pct}%"></span></span>
<span class="sb-count">${i[valueKey]}</span>
</div>`;
}).join('');
}
function renderDashCategoryBars(stats) {
const items = stats.by_category ? Object.entries(stats.by_category).map(([k, v]) => ({ name: k, count: v })) : [];
renderBar('dashCategoryBars', items, 'name', 'count');
}
function renderDashMakeBars(stats) {
const items = stats.by_make ? Object.entries(stats.by_make).map(([k, v]) => ({ name: k, count: v })) : [];
renderBar('dashMakeBars', items, 'name', 'count');
}
function renderDashStatusBars(stats) {
const items = stats.by_status ? Object.entries(stats.by_status).map(([k, v]) => ({ name: k, count: v })) : [];
renderBar('dashStatusBars', items, 'name', 'count');
}
function renderDashActivity(items) {
const el = document.getElementById('dashActivity');
if (!items || items.length === 0) {
el.innerHTML = '<div class="empty-state"><div class="es-icon">📭</div>No recent activity</div>';
return;
}
el.innerHTML = items.map(a => {
const icon = getActIcon(a.action);
return `<div class="activity-item">
<div class="act-icon ${icon}">${getActEmoji(a.action)}</div>
<div class="act-info">
<div class="act-text">${esc(a.details || a.action || '')} <span class="act-user">${esc(a.user_name || '')}</span></div>
<div class="act-time">${timeAgo(a.created_at)}</div>
</div>
</div>`;
}).join('');
}
// ═════════════════════════════════════════════════════════════════════════════
// SETTINGS — All Entities CRUD
// ═════════════════════════════════════════════════════════════════════════════
async function loadSettingsCache() {
try {
const [cat, makes, models, kn, kt, bt] = await Promise.all([
api('/api/settings/categories'),
api('/api/settings/makes'),
api('/api/settings/models'),
api('/api/settings/key_names'),
api('/api/settings/key_types'),
api('/api/settings/badge_types'),
]);
AppState.settingsCache = {
categories: cat || [],
makes: makes || [],
models: models || [],
key_names: kn || [],
key_types: kt || [],
badge_types: bt || [],
_loaded: true,
};
} catch (e) {
showToast('Failed to load settings: ' + e.message, true);
}
}
async function renderAllSettings() {
await loadSettingsCache();
renderSettingsList('settingsCategories', 'categories', cat => cat.icon ? cat.icon + ' ' : '📁 ');
renderMakesModels();
renderSettingsList('settingsKeyNames', 'key_names');
renderSettingsList('settingsKeyTypes', 'key_types');
renderSettingsList('settingsBadgeTypes', 'badge_types');
}
function renderSettingsList(elId, entity, iconFn) {
const el = document.getElementById(elId);
const items = AppState.settingsCache[entity] || [];
if (items.length === 0) {
el.innerHTML = '<div class="text-muted text-sm" style="padding:8px 0;">No items</div>';
return;
}
el.innerHTML = items.map(item => {
const icon = iconFn ? iconFn(item) : '•';
return `<div class="settings-item">
<span class="si-icon">${icon}</span>
<span class="si-name">${esc(item.name || '')}</span>
<span class="si-actions">
<button class="si-btn" onclick="showSettingsEditForm('${entity}', ${item.id})">✏️</button>
<button class="si-btn delete" onclick="deleteSettingsItem('${entity}', ${item.id})">🗑</button>
</span>
</div>`;
}).join('');
}
function renderMakesModels() {
const el = document.getElementById('settingsMakes');
const makes = AppState.settingsCache.makes || [];
const models = AppState.settingsCache.models || [];
if (makes.length === 0) {
el.innerHTML = '<div class="text-muted text-sm" style="padding:8px 0;">No makes</div>';
return;
}
el.innerHTML = makes.map(make => {
const mkModels = models.filter(m => m.make_id === make.id);
return `<div class="settings-item make-item" onclick="toggleMakeModels(this)">
<span class="si-chevron">▶</span>
<span class="si-name">${esc(make.name)}</span>
<span class="si-meta">${mkModels.length} models</span>
<span class="si-actions">
<button class="si-btn" onclick="event.stopPropagation();showSettingsEditForm('makes', ${make.id})">✏️</button>
<button class="si-btn delete" onclick="event.stopPropagation();deleteSettingsItem('makes', ${make.id})">🗑</button>
</span>
</div>
<div class="models-sublist">${mkModels.map(m => `
<div class="settings-item">
<span class="si-icon">📦</span>
<span class="si-name">${esc(m.name)}</span>
<span class="si-actions">
<button class="si-btn" onclick="showSettingsEditForm('models', ${m.id})">✏️</button>
<button class="si-btn delete" onclick="deleteSettingsItem('models', ${m.id})">🗑</button>
</span>
</div>`
).join('')}
<div class="add-model-row">
<input id="newModel_${make.id}" class="input-field" placeholder="New model name..." style="margin-bottom:0;font-size:12px;padding:6px 10px;">
<button class="btn btn-sm btn-primary" onclick="addModel(${make.id})">Add</button>
</div>
</div>`;
}).join('');
}
function toggleMakeModels(el) {
el.classList.toggle('expanded');
const sub = el.nextElementSibling;
if (sub && sub.classList.contains('models-sublist')) {
sub.classList.toggle('open');
}
}
async function addModel(makeId) {
const input = document.getElementById('newModel_' + makeId);
const name = input.value.trim();
if (!name) return;
try {
await api('/api/settings/models', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, make_id: makeId }),
});
input.value = '';
showToast('Model added');
renderAllSettings();
} catch (e) { showToast(e.message, true); }
}
function showSettingsAddForm(entity) {
const labels = { categories: 'Category', makes: 'Make', key_names: 'Key Name', key_types: 'Key Type', badge_types: 'Badge Type' };
const label = labels[entity] || entity;
const isCat = entity === 'categories';
const emojis = ['🪑','🔌','🍽️','⚙️','📦','☕','🥤','🍪','🧊','🛒','🖥️','🔧','📋','🏗️','🧰','🛠️','📎','🗂️','📁','🏪'];
document.getElementById('modalTitle').textContent = `Add ${label}`;
document.getElementById('modalBody').innerHTML = `
<div class="form-group">
<label class="form-label">Name</label>
<input id="modalFormName" class="input-field" placeholder="${label} name">
</div>
${isCat ? `
<div class="form-group">
<label class="form-label">Icon</label>
<div class="emoji-picker" id="emojiPicker">
${emojis.map(e => `<button class="emoji-opt" onclick="pickEmoji(this,'${e}')">${e}</button>`).join('')}
</div>
<input id="modalFormIcon" class="input-field" placeholder="Or type an emoji" value="📁">
</div>` : ''}
`;
document.getElementById('modalActions').innerHTML = `
<button class="btn btn-outline" onclick="closeModal(false)">Cancel</button>
<button class="btn btn-primary" onclick="saveSettingsAdd('${entity}')">Save</button>
`;
document.getElementById('modalOverlay').classList.add('open');
}
function pickEmoji(el, emoji) {
document.querySelectorAll('.emoji-opt').forEach(e => e.classList.remove('selected'));
el.classList.add('selected');
document.getElementById('modalFormIcon').value = emoji;
}
async function saveSettingsAdd(entity) {
const name = document.getElementById('modalFormName').value.trim();
if (!name) { showToast('Name is required', true); return; }
const body = { name };
if (entity === 'categories') {
body.icon = document.getElementById('modalFormIcon').value.trim() || '📁';
}
try {
await api(`/api/settings/${entity}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
closeModal(false);
showToast('Created');
await renderAllSettings();
} catch (e) { showToast(e.message, true); }
}
function showSettingsEditForm(entity, id) {
const items = AppState.settingsCache[entity] || [];
const item = items.find(i => i.id === id);
if (!item) return;
const labels = { categories: 'Category', makes: 'Make', models: 'Model', key_names: 'Key Name', key_types: 'Key Type', badge_types: 'Badge Type' };
const label = labels[entity] || entity;
const isCat = entity === 'categories';
const emojis = ['🪑','🔌','🍽️','⚙️','📦','☕','🥤','🍪','🧊','🛒','🖥️','🔧','📋','🏗️','🧰','🛠️','📎','🗂️','📁','🏪'];
document.getElementById('modalTitle').textContent = `Edit ${label}`;
document.getElementById('modalBody').innerHTML = `
<div class="form-group">
<label class="form-label">Name</label>
<input id="modalFormName" class="input-field" value="${esc(item.name || '')}">
</div>
${isCat ? `
<div class="form-group">
<label class="form-label">Icon</label>
<div class="emoji-picker" id="emojiPicker">
${emojis.map(e => `<button class="emoji-opt ${e === item.icon ? 'selected' : ''}" onclick="pickEmoji(this,'${e}')">${e}</button>`).join('')}
</div>
<input id="modalFormIcon" class="input-field" value="${esc(item.icon || '📁')}">
</div>` : ''}
${entity === 'models' ? `
<div class="form-group">
<label class="form-label">Make</label>
<select id="modalFormMakeId" class="input-field">
${(AppState.settingsCache.makes || []).map(m =>
`<option value="${m.id}" ${m.id === item.make_id ? 'selected' : ''}>${esc(m.name)}</option>`
).join('')}
</select>
</div>` : ''}
`;
document.getElementById('modalActions').innerHTML = `
<button class="btn btn-outline" onclick="closeModal(false)">Cancel</button>
<button class="btn btn-primary" onclick="saveSettingsEdit('${entity}', ${id})">Save</button>
`;
document.getElementById('modalOverlay').classList.add('open');
}
async function saveSettingsEdit(entity, id) {
const name = document.getElementById('modalFormName').value.trim();
if (!name) { showToast('Name is required', true); return; }
const body = { name };
if (entity === 'categories') {
body.icon = document.getElementById('modalFormIcon').value.trim() || '📁';
}
if (entity === 'models') {
body.make_id = parseInt(document.getElementById('modalFormMakeId').value);
}
try {
await api(`/api/settings/${entity}/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
closeModal(false);
showToast('Updated');
await renderAllSettings();
} catch (e) { showToast(e.message, true); }
}
async function deleteSettingsItem(entity, id) {
const confirmed = await showModal('Delete', 'Are you sure you want to delete this item?', 'Delete');
if (!confirmed) return;
try {
await api(`/api/settings/${entity}/${id}`, { method: 'DELETE' });
showToast('Deleted');
await renderAllSettings();
} catch (e) { showToast(e.message, true); }
}
// ═════════════════════════════════════════════════════════════════════════════
// USERS
// ═════════════════════════════════════════════════════════════════════════════
let cachedUsers = [];
async function loadUsers() {
try {
cachedUsers = await api('/api/users') || [];
renderUsers();
} catch (e) {
document.getElementById('usersContent').innerHTML =
`<div class="card" style="text-align:center;color:var(--red);">Failed to load users: ${esc(e.message)}</div>`;
}
}
function renderUsers() {
const el = document.getElementById('usersContent');
if (cachedUsers.length === 0) {
el.innerHTML = '<div class="card"><div class="empty-state"><div class="es-icon">👥</div>No users found</div></div>';
return;
}
el.innerHTML = `<div class="table-wrap"><table class="data-table">
<thead><tr>
<th>ID</th>
<th>Username</th>
<th>Role</th>
<th>Created</th>
<th class="td-actions">Actions</th>
</tr></thead>
<tbody>${cachedUsers.map(u => `<tr>
<td>${u.id}</td>
<td><strong>${esc(u.username)}</strong></td>
<td><span class="role-badge ${u.role || 'technician'}">${u.role || 'technician'}</span></td>
<td class="text-muted">${formatDate(u.created_at)}</td>
<td class="td-actions">
<button class="btn btn-xs btn-outline" onclick="showEditUserForm(${u.id})">✏️</button>
<button class="btn btn-xs btn-danger" onclick="deleteUser(${u.id})">🗑</button>
</td>
</tr>`).join('')}</tbody>
</table></div>`;
document.getElementById('userBadgeNav').textContent = cachedUsers.length;
}
function showAddUserForm() {
document.getElementById('modalTitle').textContent = 'Add User';
document.getElementById('modalBody').innerHTML = `
<div class="form-group">
<label class="form-label">Username</label>
<input id="modalFormUser" class="input-field" placeholder="Username">
</div>
<div class="form-group">
<label class="form-label">Password</label>
<input id="modalFormPass" class="input-field" type="password" placeholder="Password">
</div>
<div class="form-group">
<label class="form-label">Role</label>
<select id="modalFormRole" class="input-field">
<option value="technician">Technician</option>
<option value="admin">Admin</option>
<option value="readonly">Read Only</option>
</select>
</div>
`;
document.getElementById('modalActions').innerHTML = `
<button class="btn btn-outline" onclick="closeModal(false)">Cancel</button>
<button class="btn btn-primary" onclick="saveAddUser()">Save</button>
`;
document.getElementById('modalOverlay').classList.add('open');
}
async function saveAddUser() {
const username = document.getElementById('modalFormUser').value.trim();
const password = document.getElementById('modalFormPass').value;
const role = document.getElementById('modalFormRole').value;
if (!username || !password) { showToast('Username and password required', true); return; }
try {
await api('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, role }),
});
closeModal(false);
showToast('User created');
loadUsers();
} catch (e) { showToast(e.message, true); }
}
function showEditUserForm(userId) {
const user = cachedUsers.find(u => u.id === userId);
if (!user) return;
document.getElementById('modalTitle').textContent = `Edit User: ${esc(user.username)}`;
document.getElementById('modalBody').innerHTML = `
<div class="form-group">
<label class="form-label">Role</label>
<select id="modalFormRole" class="input-field">
<option value="technician" ${user.role === 'technician' ? 'selected' : ''}>Technician</option>
<option value="admin" ${user.role === 'admin' ? 'selected' : ''}>Admin</option>
<option value="readonly" ${user.role === 'readonly' ? 'selected' : ''}>Read Only</option>
</select>
</div>
<div class="form-group">
<label class="form-label">New Password <span class="text-muted">(leave blank to keep)</span></label>
<input id="modalFormPass" class="input-field" type="password" placeholder="New password">
</div>
`;
document.getElementById('modalActions').innerHTML = `
<button class="btn btn-outline" onclick="closeModal(false)">Cancel</button>
<button class="btn btn-primary" onclick="saveEditUser(${userId})">Save</button>
`;
document.getElementById('modalOverlay').classList.add('open');
}
async function saveEditUser(userId) {
const role = document.getElementById('modalFormRole').value;
const password = document.getElementById('modalFormPass').value;
const body = { role };
if (password) body.password = password;
try {
await api(`/api/users/${userId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
closeModal(false);
showToast('User updated');
loadUsers();
} catch (e) { showToast(e.message, true); }
}
async function deleteUser(userId) {
const user = cachedUsers.find(u => u.id === userId);
const confirmed = await showModal('Delete User',
`Are you sure you want to delete <strong>${esc(user ? user.username : 'this user')}</strong>?`,
'Delete');
if (!confirmed) return;
try {
await api(`/api/users/${userId}`, { method: 'DELETE' });
showToast('User deleted');
loadUsers();
} catch (e) { showToast(e.message, true); }
}
// ═════════════════════════════════════════════════════════════════════════════
// CUSTOMERS & LOCATIONS
// ═════════════════════════════════════════════════════════════════════════════
let cachedCustomers = [];
let cachedLocations = [];
let selectedCustomerId = null;
async function loadCustomers() {
try {
const q = document.getElementById('custSearch').value.trim();
const [customers, locations] = await Promise.all([
api('/api/customers'),
api('/api/locations'),
]);
cachedCustomers = customers || [];
cachedLocations = locations || [];
renderCustomers();
} catch (e) {
document.getElementById('customersContent').innerHTML =
`<div class="card" style="text-align:center;color:var(--red);">Failed to load: ${esc(e.message)}</div>`;
}
}
function renderCustomers() {
const el = document.getElementById('customersContent');
const q = document.getElementById('custSearch').value.trim().toLowerCase();
let filtered = cachedCustomers;
if (q) filtered = filtered.filter(c => c.name.toLowerCase().includes(q));
document.getElementById('custBadgeNav').textContent = cachedCustomers.length;
if (filtered.length === 0) {
el.innerHTML = '<div class="card"><div class="empty-state"><div class="es-icon">🏢</div>No customers found</div></div>';
return;
}
el.innerHTML = filtered.map(c => {
const locs = cachedLocations.filter(l => l.customer_id === c.id);
return `<div class="card" style="cursor:pointer;" onclick="showCustomerDetail(${c.id})">
<div style="display:flex;align-items:center;justify-content:space-between;">
<div>
<strong>${esc(c.name)}</strong>
<span class="text-muted text-sm"> — ${locs.length} location${locs.length !== 1 ? 's' : ''}</span>
</div>
<div class="flex-row gap-sm">
<button class="btn btn-xs btn-outline" onclick="event.stopPropagation();showEditCustomerForm(${c.id})">✏️</button>
<button class="btn btn-xs btn-danger" onclick="event.stopPropagation();deleteCustomer(${c.id})">🗑</button>
</div>
</div>
</div>`;
}).join('');
}
function showAddCustomerForm() {
document.getElementById('modalTitle').textContent = 'Add Customer';
document.getElementById('modalBody').innerHTML = `
<div class="form-group">
<label class="form-label">Customer Name</label>
<input id="modalCustName" class="input-field" placeholder="Customer name">
</div>
<div class="card-title" style="margin-top:12px;">Contact Info</div>
<div id="contactsContainer"></div>
<button class="btn btn-sm btn-outline btn-block" onclick="addContactRow()">+ Add Contact</button>
`;
document.getElementById('modalActions').innerHTML = `
<button class="btn btn-outline" onclick="closeModal(false)">Cancel</button>
<button class="btn btn-primary" onclick="saveAddCustomer()">Save</button>
`;
document.getElementById('modalOverlay').classList.add('open');
}
let contactCount = 0;
function addContactRow(name, phone, email) {
contactCount++;
const el = document.getElementById('contactsContainer');
el.insertAdjacentHTML('beforeend', `
<div class="contact-row" id="cr_${contactCount}">
<button class="cr-remove" onclick="document.getElementById('cr_${contactCount}').remove()">✕</button>
<input class="input-field contact-name" placeholder="Contact name" value="${esc(name || '')}" style="margin-bottom:4px;">
<div class="form-row">
<input class="input-field contact-phone" placeholder="Phone" value="${esc(phone || '')}">
<input class="input-field contact-email" placeholder="Email" value="${esc(email || '')}">
</div>
</div>
`);
}
async function saveAddCustomer() {
const name = document.getElementById('modalCustName').value.trim();
if (!name) { showToast('Customer name is required', true); return; }
const contacts = [];
document.querySelectorAll('.contact-row').forEach(row => {
const cname = row.querySelector('.contact-name').value.trim();
const phone = row.querySelector('.contact-phone').value.trim();
const email = row.querySelector('.contact-email').value.trim();
if (cname || phone || email) contacts.push({ name: cname, phone, email });
});
try {
await api('/api/customers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, contacts }),
});
closeModal(false);
showToast('Customer created');
loadCustomers();
} catch (e) { showToast(e.message, true); }
}
function showEditCustomerForm(custId) {
const cust = cachedCustomers.find(c => c.id === custId);
if (!cust) return;
document.getElementById('modalTitle').textContent = `Edit Customer: ${esc(cust.name)}`;
document.getElementById('modalBody').innerHTML = `
<div class="form-group">
<label class="form-label">Customer Name</label>
<input id="modalCustName" class="input-field" value="${esc(cust.name || '')}">
</div>
<div class="card-title" style="margin-top:12px;">Contacts</div>
<div id="contactsContainer"></div>
<button class="btn btn-sm btn-outline btn-block" onclick="addContactRow()">+ Add Contact</button>
`;
// Add existing contacts
contactCount = 0;
if (cust.contacts) cust.contacts.forEach(c => addContactRow(c.name, c.phone, c.email));
document.getElementById('modalActions').innerHTML = `
<button class="btn btn-outline" onclick="closeModal(false)">Cancel</button>
<button class="btn btn-primary" onclick="saveEditCustomer(${custId})">Save</button>
`;
document.getElementById('modalOverlay').classList.add('open');
}
async function saveEditCustomer(custId) {
const name = document.getElementById('modalCustName').value.trim();
if (!name) { showToast('Customer name is required', true); return; }
const contacts = [];
document.querySelectorAll('.contact-row').forEach(row => {
const cname = row.querySelector('.contact-name').value.trim();
const phone = row.querySelector('.contact-phone').value.trim();
const email = row.querySelector('.contact-email').value.trim();
if (cname || phone || email) contacts.push({ name: cname, phone, email });
});
try {
await api(`/api/customers/${custId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, contacts }),
});
closeModal(false);
showToast('Customer updated');
loadCustomers();
} catch (e) { showToast(e.message, true); }
}
async function deleteCustomer(custId) {
const cust = cachedCustomers.find(c => c.id === custId);
const confirmed = await showModal('Delete Customer',
`Are you sure? This will also remove all locations under <strong>${esc(cust ? cust.name : 'this customer')}</strong>.`,
'Delete');
if (!confirmed) return;
try {
await api(`/api/customers/${custId}`, { method: 'DELETE' });
showToast('Customer deleted');
if (selectedCustomerId === custId) closeCustomerDetail();
loadCustomers();
} catch (e) { showToast(e.message, true); }
}
function showCustomerDetail(custId) {
selectedCustomerId = custId;
const cust = cachedCustomers.find(c => c.id === custId);
if (!cust) return;
document.getElementById('customersContent').classList.add('hidden');
document.getElementById('customerDetail').classList.remove('hidden');
const locs = cachedLocations.filter(l => l.customer_id === custId);
document.getElementById('customerDetailContent').innerHTML = `
<div class="card">
<div class="card-header">
<span class="card-title">${esc(cust.name)}</span>
<div class="flex-row gap-sm">
<button class="btn btn-sm btn-outline" onclick="showEditCustomerForm(${custId})">✏️ Edit</button>
<button class="btn btn-sm btn-outline" onclick="showAddLocationForm(${custId})">+ Add Location</button>
</div>
</div>
${cust.contacts && cust.contacts.length > 0 ? `
<div style="margin-bottom:10px;">
<div class="card-title" style="margin-bottom:6px;">Contacts</div>
${cust.contacts.map(c => `
<div style="display:flex;gap:8px;padding:4px 0;font-size:13px;">
<span>${esc(c.name || '')}</span>
${c.phone ? `<span class="text-muted">📞 ${esc(c.phone)}</span>` : ''}
${c.email ? `<span class="text-muted">✉️ ${esc(c.email)}</span>` : ''}
</div>
`).join('')}
</div>` : ''}
<div class="card-title" style="margin-bottom:6px;">
Locations (${locs.length})
</div>
${locs.length === 0 ? '<div class="text-muted text-sm">No locations</div>' :
`<div class="table-wrap"><table class="data-table" style="font-size:12px;">
<thead><tr>
<th>Name</th>
<th>Address</th>
<th>Building</th>
<th>Floor</th>
<th class="td-actions">Actions</th>
</tr></thead>
<tbody>${locs.map(l => `<tr>
<td><strong>${esc(l.name || '')}</strong></td>
<td class="text-muted">${esc(l.address || '')}</td>
<td class="text-muted">${esc(l.building_name || '')}</td>
<td class="text-muted">${esc(l.floor || '')}</td>
<td class="td-actions">
<button class="btn btn-xs btn-outline" onclick="showEditLocationForm(${l.id})">✏️</button>
<button class="btn btn-xs btn-danger" onclick="deleteLocation(${l.id})">🗑</button>
</td>
</tr>`).join('')}
</tbody></table></div>`
}
</div>
`;
}
function closeCustomerDetail() {
selectedCustomerId = null;
document.getElementById('customersContent').classList.remove('hidden');
document.getElementById('customerDetail').classList.add('hidden');
}
function showAddLocationForm(custId) {
document.getElementById('modalTitle').textContent = 'Add Location';
document.getElementById('modalBody').innerHTML = `
<div class="form-group">
<label class="form-label">Location Name</label>
<input id="modalLocName" class="input-field" placeholder="Location name">
</div>
<div class="form-group">
<label class="form-label">Address</label>
<input id="modalLocAddr" class="input-field" placeholder="Address">
</div>
<div class="form-row">
<div class="form-group" style="flex:1;">
<label class="form-label">Building Name</label>
<input id="modalLocBuilding" class="input-field" placeholder="Building">
</div>
<div class="form-group" style="flex:1;">
<label class="form-label">Floor</label>
<input id="modalLocFloor" class="input-field" placeholder="Floor">
</div>
</div>
<div class="form-row">
<div class="form-group" style="flex:1;">
<label class="form-label">Site Hours</label>
<input id="modalLocHours" class="input-field" placeholder="e.g. 8am-5pm">
</div>
<div class="form-group" style="flex:1;">
<label class="form-label">Trailer #</label>
<input id="modalLocTrailer" class="input-field" placeholder="Trailer number">
</div>
</div>
<div class="form-group">
<label class="form-label">Access Notes</label>
<textarea id="modalLocAccess" class="input-field" placeholder="Access notes"></textarea>
</div>
<div class="form-group">
<label class="form-label">Walking Directions</label>
<textarea id="modalLocDirections" class="input-field" placeholder="Walking directions"></textarea>
</div>
<div class="form-row">
<div class="form-group" style="flex:1;">
<label class="form-label">Latitude</label>
<input id="modalLocLat" class="input-field" type="number" step="any" placeholder="Lat">
</div>
<div class="form-group" style="flex:1;">
<label class="form-label">Longitude</label>
<input id="modalLocLng" class="input-field" type="number" step="any" placeholder="Lng">
</div>
</div>
`;
document.getElementById('modalActions').innerHTML = `
<button class="btn btn-outline" onclick="closeModal(false)">Cancel</button>
<button class="btn btn-primary" onclick="saveAddLocation(${custId})">Save</button>
`;
document.getElementById('modalOverlay').classList.add('open');
}
async function saveAddLocation(custId) {
const name = document.getElementById('modalLocName').value.trim();
if (!name) { showToast('Location name is required', true); return; }
const body = {
customer_id: custId,
name,
address: document.getElementById('modalLocAddr').value.trim(),
building_name: document.getElementById('modalLocBuilding').value.trim(),
floor: document.getElementById('modalLocFloor').value.trim(),
site_hours: document.getElementById('modalLocHours').value.trim(),
trailer_number: document.getElementById('modalLocTrailer').value.trim(),
access_notes: document.getElementById('modalLocAccess').value.trim(),
walking_directions: document.getElementById('modalLocDirections').value.trim(),
latitude: parseFloat(document.getElementById('modalLocLat').value) || null,
longitude: parseFloat(document.getElementById('modalLocLng').value) || null,
};
try {
await api('/api/locations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
closeModal(false);
showToast('Location created');
loadCustomers();
if (selectedCustomerId) showCustomerDetail(selectedCustomerId);
} catch (e) { showToast(e.message, true); }
}
function showEditLocationForm(locId) {
const loc = cachedLocations.find(l => l.id === locId);
if (!loc) return;
document.getElementById('modalTitle').textContent = 'Edit Location';
document.getElementById('modalBody').innerHTML = `
<div class="form-group">
<label class="form-label">Location Name</label>
<input id="modalLocName" class="input-field" value="${esc(loc.name || '')}">
</div>
<div class="form-group">
<label class="form-label">Address</label>
<input id="modalLocAddr" class="input-field" value="${esc(loc.address || '')}">
</div>
<div class="form-row">
<div class="form-group" style="flex:1;">
<label class="form-label">Building Name</label>
<input id="modalLocBuilding" class="input-field" value="${esc(loc.building_name || '')}">
</div>
<div class="form-group" style="flex:1;">
<label class="form-label">Floor</label>
<input id="modalLocFloor" class="input-field" value="${esc(loc.floor || '')}">
</div>
</div>
<div class="form-row">
<div class="form-group" style="flex:1;">
<label class="form-label">Site Hours</label>
<input id="modalLocHours" class="input-field" value="${esc(loc.site_hours || '')}">
</div>
<div class="form-group" style="flex:1;">
<label class="form-label">Trailer #</label>
<input id="modalLocTrailer" class="input-field" value="${esc(loc.trailer_number || '')}">
</div>
</div>
<div class="form-group">
<label class="form-label">Access Notes</label>
<textarea id="modalLocAccess" class="input-field">${esc(loc.access_notes || '')}</textarea>
</div>
<div class="form-group">
<label class="form-label">Walking Directions</label>
<textarea id="modalLocDirections" class="input-field">${esc(loc.walking_directions || '')}</textarea>
</div>
<div class="form-row">
<div class="form-group" style="flex:1;">
<label class="form-label">Latitude</label>
<input id="modalLocLat" class="input-field" type="number" step="any" value="${loc.latitude || ''}">
</div>
<div class="form-group" style="flex:1;">
<label class="form-label">Longitude</label>
<input id="modalLocLng" class="input-field" type="number" step="any" value="${loc.longitude || ''}">
</div>
</div>
`;
document.getElementById('modalActions').innerHTML = `
<button class="btn btn-outline" onclick="closeModal(false)">Cancel</button>
<button class="btn btn-primary" onclick="saveEditLocation(${locId})">Save</button>
`;
document.getElementById('modalOverlay').classList.add('open');
}
async function saveEditLocation(locId) {
const name = document.getElementById('modalLocName').value.trim();
if (!name) { showToast('Location name is required', true); return; }
const body = {
name,
address: document.getElementById('modalLocAddr').value.trim(),
building_name: document.getElementById('modalLocBuilding').value.trim(),
floor: document.getElementById('modalLocFloor').value.trim(),
site_hours: document.getElementById('modalLocHours').value.trim(),
trailer_number: document.getElementById('modalLocTrailer').value.trim(),
access_notes: document.getElementById('modalLocAccess').value.trim(),
walking_directions: document.getElementById('modalLocDirections').value.trim(),
latitude: parseFloat(document.getElementById('modalLocLat').value) || null,
longitude: parseFloat(document.getElementById('modalLocLng').value) || null,
};
try {
await api(`/api/locations/${locId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
closeModal(false);
showToast('Location updated');
loadCustomers();
if (selectedCustomerId) showCustomerDetail(selectedCustomerId);
} catch (e) { showToast(e.message, true); }
}
async function deleteLocation(locId) {
const confirmed = await showModal('Delete Location', 'Are you sure you want to delete this location?', 'Delete');
if (!confirmed) return;
try {
await api(`/api/locations/${locId}`, { method: 'DELETE' });
showToast('Location deleted');
loadCustomers();
if (selectedCustomerId) showCustomerDetail(selectedCustomerId);
} catch (e) { showToast(e.message, true); }
}
// ═════════════════════════════════════════════════════════════════════════════
// ACTIVITY LOG
// ═════════════════════════════════════════════════════════════════════════════
let activityOffset = 0;
const ACTIVITY_LIMIT = 50;
async function loadActivity() {
activityOffset = 0;
await _loadActivity();
}
async function loadActivityPage(dir) {
activityOffset = Math.max(0, activityOffset + dir * ACTIVITY_LIMIT);
await _loadActivity();
}
async function _loadActivity() {
const userId = document.getElementById('actUserFilter').value;
const typeFilter = document.getElementById('actTypeFilter').value;
const dateFrom = document.getElementById('actDateFrom').value;
const dateTo = document.getElementById('actDateTo').value;
try {
let url = `/api/activity?limit=${ACTIVITY_LIMIT}&offset=${activityOffset}`;
if (userId) url += `&user_id=${userId}`;
if (typeFilter) url += `&entity_type=${typeFilter}`;
if (dateFrom) url += `&date_from=${dateFrom}`;
if (dateTo) url += `&date_to=${dateTo}`;
const items = await api(url) || [];
renderActivity(items);
// Also load user filter options
await loadActivityUserFilter();
} catch (e) {
document.getElementById('activityList').innerHTML =
`<div style="color:var(--red);padding:12px;text-align:center;">Failed: ${esc(e.message)}</div>`;
}
}
function renderActivity(items) {
const el = document.getElementById('activityList');
if (items.length === 0) {
el.innerHTML = '<div class="empty-state"><div class="es-icon">📋</div>No activity found</div>';
document.getElementById('activityPagination').innerHTML = '';
return;
}
el.innerHTML = items.map(a => {
const icon = getActIcon(a.action);
return `<div class="activity-item">
<div class="act-icon ${icon}">${getActEmoji(a.action)}</div>
<div class="act-info">
<div class="act-text">${esc(a.details || a.action || '')}</div>
<div class="act-time">${esc(a.user_name || '')} · ${timeAgo(a.created_at)}</div>
</div>
</div>`;
}).join('');
renderActivityPagination(items.length);
}
function renderActivityPagination(count) {
const el = document.getElementById('activityPagination');
el.innerHTML = `
<button class="btn btn-sm btn-outline" onclick="loadActivityPage(-1)" ${activityOffset <= 0 ? 'disabled style="opacity:0.4"' : ''}>← Previous</button>
<span class="text-sm text-muted" style="align-self:center;">Offset ${activityOffset}</span>
<button class="btn btn-sm btn-outline" onclick="loadActivityPage(1)" ${count < ACTIVITY_LIMIT ? 'disabled style="opacity:0.4"' : ''}>Next →</button>
`;
}
async function loadActivityUserFilter() {
const sel = document.getElementById('actUserFilter');
const currentVal = sel.value;
try {
const users = await api('/api/users') || [];
sel.innerHTML = '<option value="">All users</option>' +
users.map(u => `<option value="${u.id}" ${u.id == currentVal ? 'selected' : ''}>${esc(u.username)}</option>`).join('');
} catch (e) { /* silent */ }
}
// ═════════════════════════════════════════════════════════════════════════════
// EXPORT & ADMIN
// ═════════════════════════════════════════════════════════════════════════════
async function confirmResetDb() {
const confirmed = await showModal('⚠️ Reset Database',
'This will delete ALL data (assets, check-ins, customers, locations, users, activity log) and reinitialize with defaults.<br><br><strong style="color:var(--red)">This cannot be undone.</strong>',
'Reset Everything', 'btn-danger');
if (!confirmed) return;
try {
const result = await api('/api/reset-database', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Confirm-Reset': 'yes' },
});
showToast(result.message || 'Database reset');
document.getElementById('resetResult').textContent = 'Reset at ' + new Date().toLocaleString();
} catch (e) {
document.getElementById('resetResult').innerHTML =
`<span class="text-danger">Reset failed: ${esc(e.message)}</span>`;
}
}
// ── Clear Asset GPS ──────────────────────────────────────────────────────
async function clearAssetGps() {
const input = document.getElementById('clearGpsAssetId').value.trim();
if (!input) {
showToast('Enter an asset ID or machine ID', true);
return;
}
const resultEl = document.getElementById('clearGpsResult');
resultEl.style.display = 'none';
try {
// Try numeric asset ID first, otherwise search by machine_id
let asset;
const idNum = parseInt(input);
if (!isNaN(idNum) && idNum > 0) {
try {
asset = await api('/api/assets/' + idNum);
} catch (e) {
// Fall through to machine_id search
}
}
if (!asset) {
asset = await api('/api/assets/search?machine_id=' + encodeURIComponent(input));
}
if (!asset || !asset.id) {
resultEl.style.display = '';
resultEl.innerHTML = '<span style="color:var(--red);">Asset not found</span>';
return;
}
const confirmed = await showModal(
'Clear GPS Data',
`<strong>${esc(asset.name)}</strong> (#${asset.id})<br>
Machine ID: ${esc(asset.machine_id)}<br>
Current: lat=${asset.latitude || 'none'}, lng=${asset.longitude || 'none'}<br><br>
Remove GPS coordinates from this asset?`,
'Clear GPS', 'btn-danger'
);
if (!confirmed) return;
await api('/api/assets/' + asset.id + '/gps', { method: 'DELETE' });
resultEl.style.display = '';
resultEl.innerHTML = '<span style="color:var(--green);">✅ GPS data cleared for ' + esc(asset.name) + ' (#' + asset.id + ')</span>';
document.getElementById('clearGpsAssetId').value = '';
showToast('GPS data cleared');
} catch (e) {
resultEl.style.display = '';
resultEl.innerHTML = '<span style="color:var(--red);">' + esc(e.message) + '</span>';
}
}
// ═════════════════════════════════════════════════════════════════════════════
// CANTALOUPE SYNC
// ═════════════════════════════════════════════════════════════════════════════
let csBatches = [];
let csLatestBatch = null;
async function loadCantaloupeSync() {
try {
const [batches] = await Promise.all([
api('/api/admin/cantaloupe/batches?limit=20'),
]);
csBatches = batches || [];
// Find the latest batch
csLatestBatch = csBatches.length > 0 ? csBatches[0] : null;
renderCsOverview();
renderCsPendingReview();
renderCsHistory();
} catch (e) {
document.getElementById('csOverviewContent').innerHTML =
`<div style="color:var(--red);text-align:center;padding:12px;">Failed to load: ${esc(e.message)}</div>`;
document.getElementById('csHistoryContent').innerHTML = '';
}
}
// ── Section 1: Overview ──
function renderCsOverview() {
const el = document.getElementById('csOverviewContent');
if (!csLatestBatch) {
el.innerHTML = '<div class="empty-state"><div class="es-icon">📥</div>No imports yet. Upload an Excel file to import asset data.</div>';
return;
}
const b = csLatestBatch;
const ds = b.diff_summary || {};
const statusTag = csStatusBadge(b.status);
const lastSync = formatDate(b.created_at);
const rowCount = b.row_count || 0;
const dsInfo = (ds.new_assets || ds.changed_assets || ds.removed_assets)
? `<div class="cs-diff-summary">
${ds.new_assets && ds.new_assets.length > 0 ? `<div class="cs-diff-badge add"><div class="csd-val">${ds.new_assets.length}</div><div class="csd-label">New</div></div>` : ''}
${ds.changed_assets && ds.changed_assets.length > 0 ? `<div class="cs-diff-badge chg"><div class="csd-val">${ds.changed_assets.length}</div><div class="csd-label">Changed</div></div>` : ''}
${ds.removed_assets && ds.removed_assets.length > 0 ? `<div class="cs-diff-badge rem"><div class="csd-val">${ds.removed_assets.length}</div><div class="csd-label">Removed</div></div>` : ''}
${ds.unchanged_assets && ds.unchanged_assets.length > 0 ? `<div class="cs-diff-badge unc"><div class="csd-val">${ds.unchanged_assets.length}</div><div class="csd-label">Unchanged</div></div>` : ''}
</div>` : '';
el.innerHTML = `
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
<span style="font-size:14px;font-weight:600;">Batch #${b.id}</span>
${statusTag}
<span class="text-muted">${lastSync}</span>
<span class="text-muted">${rowCount} rows</span>
${b.error_message ? `<span class="text-danger text-sm">⚠ ${esc(b.error_message)}</span>` : ''}
</div>
${dsInfo}
`;
}
function csStatusBadge(status) {
const map = {
pending: '<span class="status-tag" style="background:var(--amber-bg);color:var(--amber);">PENDING</span>',
approved: '<span class="status-tag active">APPROVED</span>',
rejected: '<span class="status-tag" style="background:var(--red-bg);color:var(--red);">REJECTED</span>',
error: '<span class="status-tag" style="background:var(--red-bg);color:var(--red);">ERROR</span>',
};
return map[status] || `<span class="status-tag">${esc(status)}</span>`;
}
// ── Section 2: Pending Review ──
function renderCsPendingReview() {
const card = document.getElementById('csPendingReview');
const el = document.getElementById('csPendingContent');
// Find a pending batch
const pending = csBatches.find(b => b.status === 'pending');
if (!pending) {
card.classList.add('hidden');
return;
}
card.classList.remove('hidden');
const ds = pending.diff_summary || {};
const newCount = (ds.new_assets || []).length;
const chgCount = (ds.changed_assets || []).length;
const remCount = (ds.removed_assets || []).length;
el.innerHTML = `
<div style="margin-bottom:10px;">
<strong>Batch #${pending.id}</strong>
<span class="text-muted"> · ${formatDate(pending.created_at)} · ${pending.row_count} rows</span>
</div>
<div class="cs-diff-summary">
<div class="cs-diff-badge add"><div class="csd-val">${newCount}</div><div class="csd-label">New</div></div>
<div class="cs-diff-badge chg"><div class="csd-val">${chgCount}</div><div class="csd-label">Changed</div></div>
<div class="cs-diff-badge rem"><div class="csd-val">${remCount}</div><div class="csd-label">Removed</div></div>
</div>
<div id="csPendingDetail"></div>
<div class="cs-actions">
<button class="btn btn-green" onclick="approveBatch(${pending.id})">✓ Approve</button>
<button class="btn btn-danger" onclick="rejectBatch(${pending.id})">✕ Reject</button>
</div>
`;
// Load full detail for the pending batch
loadPendingBatchDetail(pending.id);
}
async function loadPendingBatchDetail(batchId) {
const el = document.getElementById('csPendingDetail');
try {
const batch = await api(`/api/admin/cantaloupe/batches/${batchId}`);
renderBatchDiffDetail(el, batch);
} catch (e) {
el.innerHTML = `<div class="text-danger text-sm">Failed to load detail: ${esc(e.message)}</div>`;
}
}
function renderBatchDiffDetail(el, batch) {
const dr = batch.diff_rows || {};
const newItems = dr.new || [];
const removedItems = dr.removed || [];
const changedItems = dr.changed || [];
let html = '';
// New assets table
if (newItems.length > 0) {
html += `<div class="card-title" style="margin-top:8px;color:var(--green);">+ New Assets (${newItems.length})</div>`;
html += `<table class="cs-changes-table"><thead><tr><th>Machine ID</th><th>Name</th><th>Customer</th><th>Location</th></tr></thead><tbody>`;
html += newItems.map(r => `<tr>
<td><code class="mono">${esc(r.machine_id)}</code></td>
<td>${esc(r.name || '—')}</td>
<td>${esc(r.customer || '—')}</td>
<td>${esc(r.location || '—')}</td>
</tr>`).join('');
html += `</tbody></table>`;
}
// Changed assets table
if (changedItems.length > 0) {
html += `<div class="card-title" style="margin-top:8px;color:var(--amber);">~ Changed Assets (${changedItems.length})</div>`;
html += `<table class="cs-changes-table"><thead><tr><th>Machine ID</th><th>Name</th><th>Field</th><th>Old Value</th><th></th><th>New Value</th></tr></thead><tbody>`;
html += changedItems.map(r => {
const changes = r.changes || [];
const firstRow = `<td rowspan="${changes.length || 1}"><code class="mono">${esc(r.machine_id)}</code></td><td rowspan="${changes.length || 1}">${esc(r.name || '—')}</td>`;
if (changes.length === 0) {
return `<tr>${firstRow}<td colspan="4" class="text-muted">No field-level changes</td></tr>`;
}
return changes.map((ch, i) => {
const row = i === 0 ? firstRow : '';
return `<tr>${row}
<td style="font-weight:600;">${esc(ch.field)}</td>
<td class="cs-old">${esc(String(ch.old_value ?? '—'))}</td>
<td class="cs-arrow">→</td>
<td class="cs-new">${esc(String(ch.new_value ?? '—'))}</td>
</tr>`;
}).join('');
}).join('');
html += `</tbody></table>`;
}
// Removed assets table
if (removedItems.length > 0) {
html += `<div class="card-title" style="margin-top:8px;color:var(--red);"> Removed Assets (${removedItems.length})</div>`;
html += `<table class="cs-changes-table"><thead><tr><th>Machine ID</th><th>Name</th></tr></thead><tbody>`;
html += removedItems.map(r => `<tr>
<td><code class="mono">${esc(r.machine_id)}</code></td>
<td>${esc(r.name || '—')}</td>
</tr>`).join('');
html += `</tbody></table>`;
}
if (newItems.length === 0 && changedItems.length === 0 && removedItems.length === 0) {
html = '<div class="text-muted text-sm">No changes detected.</div>';
}
el.innerHTML = html;
}
// ── Section 3: Import History ──
function renderCsHistory() {
const el = document.getElementById('csHistoryContent');
if (csBatches.length === 0) {
el.innerHTML = '<div class="empty-state"><div class="es-icon">📭</div>No import history</div>';
return;
}
el.innerHTML = csBatches.map(b => {
const ds = b.diff_summary || {};
const newCount = (ds.new_assets || []).length;
const chgCount = (ds.changed_assets || []).length;
const remCount = (ds.removed_assets || []).length;
const summaryParts = [];
if (newCount) summaryParts.push(`+${newCount}`);
if (chgCount) summaryParts.push(`~${chgCount}`);
if (remCount) summaryParts.push(`${remCount}`);
return `
<div class="cs-batch-row" onclick="toggleBatchHistory(${b.id})" id="csBatchRow_${b.id}">
<span class="csb-status">${csStatusBadge(b.status)}</span>
<span class="csb-time">${formatDate(b.created_at)}</span>
<span class="csb-rows">${b.row_count} rows</span>
<span class="text-muted text-sm" style="flex:1;">${summaryParts.join(' ')}</span>
<span class="csb-chevron">▶</span>
</div>
<div class="cs-batch-detail" id="csBatchDetail_${b.id}">
<div class="loading-row"><div class="spinner"></div> Loading...</div>
</div>`;
}).join('');
}
async function toggleBatchHistory(batchId) {
const row = document.getElementById('csBatchRow_' + batchId);
const detail = document.getElementById('csBatchDetail_' + batchId);
if (!row || !detail) return;
const isExpanded = row.classList.contains('expanded');
if (isExpanded) {
row.classList.remove('expanded');
detail.classList.remove('open');
return;
}
row.classList.add('expanded');
detail.classList.add('open');
// Fetch detail if not already loaded
if (detail.dataset.loaded !== '1') {
try {
const batch = await api(`/api/admin/cantaloupe/batches/${batchId}`);
// Render into a temporary container, then swap
const tmp = document.createElement('div');
renderBatchDiffDetail(tmp, batch);
detail.innerHTML = tmp.innerHTML;
detail.dataset.loaded = '1';
} catch (e) {
detail.innerHTML = `<div class="text-danger text-sm">Failed to load: ${esc(e.message)}</div>`;
}
}
}
// ── Upload Excel File ──
let _csUploading = false;
async function uploadSyncFile(event) {
const file = event.target.files?.[0];
if (!file) return;
if (_csUploading) return;
_csUploading = true;
const fd = new FormData();
fd.append('file', file);
try {
const result = await api('/api/admin/cantaloupe/sync', { method: 'POST', body: fd });
showToast('Upload processed — batch #' + result.id);
await loadCantaloupeSync();
} catch (e) {
showToast('Upload failed: ' + e.message, true);
} finally {
_csUploading = false;
event.target.value = ''; // reset so same file can be re-picked
}
}
// ── Approve / Reject ──
async function approveBatch(batchId) {
const confirmed = await showModal('Approve Batch',
`Apply all ${csLatestBatch && csLatestBatch.id === batchId ? 'pending ' : ''}changes from batch #${batchId} to live data?`,
'Approve', 'btn-green');
if (!confirmed) return;
try {
const result = await api(`/api/admin/cantaloupe/batches/${batchId}/approve`, { method: 'POST' });
showToast('Batch approved — ' + (result.applied_stats ? JSON.stringify(result.applied_stats) : 'done'));
await loadCantaloupeSync();
} catch (e) {
showToast('Approve failed: ' + e.message, true);
}
}
async function rejectBatch(batchId) {
const confirmed = await showModal('Reject Batch',
`Discard batch #${batchId}? This cannot be undone.`,
'Reject', 'btn-danger');
if (!confirmed) return;
try {
await api(`/api/admin/cantaloupe/batches/${batchId}/reject`, { method: 'POST' });
showToast('Batch rejected');
await loadCantaloupeSync();
} catch (e) {
showToast('Reject failed: ' + e.message, true);
}
}
// ═════════════════════════════════════════════════════════════════════════════
// HELPERS
// ═════════════════════════════════════════════════════════════════════════════
function esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function formatDate(iso) {
if (!iso) return '—';
// Handle space-separated datetime (SQLite format): "2026-05-21 23:16:21"
let normalized = iso;
if (!iso.includes('T') && iso.includes(' ')) {
normalized = iso.replace(' ', 'T');
} else if (!iso.includes('T')) {
normalized = iso + 'T00:00:00';
}
const d = new Date(normalized);
if (isNaN(d.getTime())) return iso;
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
}
function timeAgo(iso) {
if (!iso) return '';
const d = new Date(iso + (iso.includes('T') ? '' : 'Z'));
if (isNaN(d.getTime())) return iso;
const now = new Date();
const sec = Math.floor((now - d) / 1000);
if (sec < 60) return 'just now';
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
const day = Math.floor(hr / 24);
if (day < 7) return `${day}d ago`;
return formatDate(iso);
}
function getActIcon(action) {
if (!action) return 'other';
const a = action.toLowerCase();
if (a.includes('create') || a.includes('add')) return 'created';
if (a.includes('update') || a.includes('edit')) return 'updated';
if (a.includes('delete') || a.includes('remove')) return 'deleted';
if (a.includes('check') || a.includes('login')) return 'checked';
if (a.includes('user')) return 'user';
if (a.includes('customer')) return 'customer';
if (a.includes('asset')) return 'asset';
return 'other';
}
function getActEmoji(action) {
if (!action) return '📌';
const a = action.toLowerCase();
if (a.includes('create') || a.includes('add')) return '';
if (a.includes('update') || a.includes('edit')) return '✏️';
if (a.includes('delete') || a.includes('remove')) return '🗑';
if (a.includes('check')) return '✅';
if (a.includes('login')) return '🔑';
if (a.includes('user')) return '👤';
if (a.includes('customer')) return '🏢';
if (a.includes('asset')) return '📦';
if (a.includes('geofence')) return '📍';
if (a.includes('visit')) return '🕐';
return '📌';
}
// ═════════════════════════════════════════════════════════════════════════════
// INIT
// ═════════════════════════════════════════════════════════════════════════════
document.addEventListener('DOMContentLoaded', () => {
initAuth();
// Enter key on login
document.getElementById('loginPass').addEventListener('keydown', e => {
if (e.key === 'Enter') doLogin();
});
document.getElementById('loginUser').addEventListener('keydown', e => {
if (e.key === 'Enter') document.getElementById('loginPass').focus();
});
});
</script>
</body>
</html>