9794d8076c
- 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
2544 lines
109 KiB
HTML
2544 lines
109 KiB
HTML
<!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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
}
|
||
|
||
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>
|