Files
canteen-admin-server/static/index.html
T

2035 lines
88 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; }
/* Responsive: collapse sidebar on narrow screens */
@media (max-width: 768px) {
.sidebar {
width: 56px;
overflow: hidden;
}
.sidebar .sidebar-header span,
.sidebar .sidebar-user,
.sidebar .nav-item span,
.sidebar .nav-item .ni-badge,
.sidebar .sidebar-footer span,
.sidebar .sidebar-footer .logout-btn { display: none; }
.sidebar .nav-item { justify-content: center; padding: 11px 0; }
.sidebar .nav-item .ni-icon { font-size: 20px; width: auto; }
.sidebar .sidebar-header { justify-content: center; }
.sidebar .sidebar-header .sh-icon { font-size: 22px; }
.main { margin-left: 56px; padding: 16px; max-width: calc(100vw - 56px); }
}
</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 / Brett85!@</div>
</div>
</div>
<!-- ═══ SIDEBAR ═══ -->
<div class="sidebar">
<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>
</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">
<!-- ── 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>
<a href="/api/export/service-summary" class="btn btn-outline" download>📄 Service Summary CSV</a>
</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>
<button class="btn btn-outline btn-sm" onclick="showRunSqlModal()">💾 Run SQL</button>
</div>
<div id="resetResult" class="text-sm text-muted mt-8"></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';
}
// ═════════════════════════════════════════════════════════════════════════════
// 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');
// 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();
}
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);
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();
}
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/admin/reset', { method: 'POST' });
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 endpoint not available: ${esc(e.message)}. Add a POST /api/admin/reset route to the server.</span>`;
}
}
function showRunSqlModal() {
document.getElementById('modalTitle').textContent = 'Run SQL Query';
document.getElementById('modalBody').innerHTML = `
<div class="form-group">
<label class="form-label">SQL Statement</label>
<textarea id="modalSql" class="input-field" rows="4" placeholder="SELECT * FROM assets LIMIT 10" style="font-family:monospace;font-size:12px;"></textarea>
</div>
<div id="sqlResult" class="text-sm text-muted"></div>
`;
document.getElementById('modalActions').innerHTML = `
<button class="btn btn-outline" onclick="closeModal(false)">Cancel</button>
<button class="btn btn-primary" onclick="runSql()">Run</button>
`;
document.getElementById('modalOverlay').classList.add('open');
}
async function runSql() {
const sql = document.getElementById('modalSql').value.trim();
if (!sql) return;
try {
const result = await api('/api/admin/query', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sql }),
});
document.getElementById('sqlResult').innerHTML = `<pre style="background:#0a0b0f;padding:10px;border-radius:6px;font-size:11px;max-height:300px;overflow:auto;">${esc(JSON.stringify(result, null, 2))}</pre>`;
} catch (e) {
document.getElementById('sqlResult').innerHTML = `<span class="text-danger">${esc(e.message)}</span>`;
}
}
// ═════════════════════════════════════════════════════════════════════════════
// 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 '—';
const d = new Date(iso + (iso.includes('T') ? '' : 'T00:00:00'));
if (isNaN(d.getTime())) return iso;
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
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>