Files
canteen-asset-tracker/static/index.html
T

5938 lines
275 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Canteen Asset Tracker</title>
<script src="https://cdn.jsdelivr.net/npm/@zxing/library@0.20.0/umd/index.min.js"></script>
<!-- Leaflet Map -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.js"></script>
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
<style>
/* ═══════════════════════════════════════════════════════════════════════
DESIGN TOKENS
═══════════════════════════════════════════════════════════════════════ */
: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;
--tab-height: 64px;
--header-height: 52px;
--drawer-width: 280px;
}
/* ═══════════════════════════════════════════════════════════════════════
RESET & BASE
═══════════════════════════════════════════════════════════════════════ */
* { box-sizing: border-box; margin: 0; padding: 0; }
html { overflow-x: hidden; -webkit-text-size-adjust: 100%; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
max-width: 480px;
margin: 0 auto;
min-height: 100dvh;
overflow-x: hidden;
padding-top: calc(var(--header-height) + 8px);
padding-bottom: calc(var(--tab-height) + 20px);
}
/* ═══════════════════════════════════════════════════════════════════════
HEADER
═══════════════════════════════════════════════════════════════════════ */
.header {
position: fixed; top: 0; left: 50%; transform: translateX(-50%);
width: 100%; max-width: 480px; height: var(--header-height);
display: flex; align-items: center; gap: 10px;
padding: 0 12px;
background: var(--card);
border-bottom: 1px solid var(--border);
z-index: 200;
}
.header .hamburger {
width: 36px; height: 36px; border: none; background: transparent;
color: var(--text); font-size: 22px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
border-radius: var(--radius-sm);
-webkit-tap-highlight-color: transparent;
flex-shrink: 0;
}
.header .hamburger:active { background: var(--border); }
.header h1 {
font-size: 16px; font-weight: 700; flex: 1;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.header .header-badges {
display: flex; align-items: center; gap: 6px; flex-shrink: 0;
}
.gps-badge {
display: inline-flex; align-items: center; gap: 3px;
font-size: 10px; font-weight: 600; padding: 4px 8px; border-radius: 20px;
white-space: nowrap;
}
.gps-badge.ok { background: var(--green-bg); color: var(--green); }
.gps-badge.waiting{ background: var(--amber-bg); color: var(--amber); }
.gps-badge.err { background: var(--red-bg); color: var(--red); }
.user-badge {
width: 30px; height: 30px; border-radius: 50%;
background: var(--accent-bg); color: var(--accent2);
display: flex; align-items: center; justify-content: center;
font-size: 13px; font-weight: 700; cursor: pointer;
border: 1.5px solid var(--border);
}
/* ═══════════════════════════════════════════════════════════════════════
DRAWER OVERLAY & PANEL
═══════════════════════════════════════════════════════════════════════ */
.drawer-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.55);
z-index: 2000; opacity: 0; pointer-events: none;
transition: opacity 0.25s;
}
.drawer-overlay.open { opacity: 1; pointer-events: auto; }
.drawer {
position: fixed; top: 0; left: 0; bottom: 0;
width: var(--drawer-width); max-width: 85vw;
background: var(--card); z-index: 2001;
transform: translateX(-100%);
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
display: flex; flex-direction: column;
border-right: 1px solid var(--border);
box-shadow: 4px 0 20px rgba(0,0,0,0.4);
}
.drawer.open { transform: translateX(0); }
.drawer-header {
display: flex; align-items: center; gap: 10px;
padding: 14px 16px; border-bottom: 1px solid var(--border);
min-height: var(--header-height);
}
.drawer-header .dh-title {
font-size: 17px; font-weight: 700; flex: 1;
}
.drawer-header .close-drawer {
width: 32px; height: 32px; border: none; background: transparent;
color: var(--text2); font-size: 20px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
border-radius: var(--radius-xs);
}
.drawer-header .close-drawer:active { background: var(--border); }
.drawer-user {
display: flex; align-items: center; gap: 12px;
padding: 14px 16px; border-bottom: 1px solid var(--border);
}
.drawer-user .du-avatar {
width: 40px; height: 40px; border-radius: 50%;
background: var(--accent-bg); color: var(--accent2);
display: flex; align-items: center; justify-content: center;
font-size: 18px; font-weight: 700; flex-shrink: 0;
}
.drawer-user .du-info { flex: 1; min-width: 0; }
.drawer-user .du-name { font-weight: 600; font-size: 14px; }
.drawer-user .du-role {
font-size: 11px; color: var(--text2); text-transform: capitalize;
}
.drawer-nav { flex: 1; overflow-y: auto; padding: 8px 0; }
.drawer-nav .dn-item {
display: flex; align-items: center; gap: 12px;
padding: 13px 16px; color: var(--text); font-size: 15px;
cursor: pointer; transition: background 0.1s;
border: none; background: transparent; width: 100%;
text-align: left; font-weight: 500;
-webkit-tap-highlight-color: transparent;
}
.drawer-nav .dn-item:active, .drawer-nav .dn-item.active {
background: rgba(255,255,255,0.04);
}
.drawer-nav .dn-item .dn-icon { font-size: 20px; width: 28px; text-align: center; flex-shrink: 0; }
.drawer-nav .dn-item .dn-badge {
margin-left: auto; font-size: 10px; font-weight: 700;
padding: 2px 8px; border-radius: 10px;
background: var(--accent-bg); color: var(--accent2);
}
.drawer-footer {
padding: 12px 16px; border-top: 1px solid var(--border);
font-size: 12px; color: var(--text3);
}
/* ═══════════════════════════════════════════════════════════════════════
TAB PANELS
═══════════════════════════════════════════════════════════════════════ */
.tab-panel { display: none; padding: 0 12px; }
.tab-panel.active { display: block; }
/* ═══════════════════════════════════════════════════════════════════════
BOTTOM TABS
═══════════════════════════════════════════════════════════════════════ */
.tabs {
position: fixed; bottom: 0; left: 50%; transform: translateX(-50%);
width: 100%; max-width: 480px;
display: flex; background: var(--card); border-top: 1px solid var(--border);
z-index: 100;
}
.tab-btn {
flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 2px; padding: 8px 2px 6px; border: none; background: transparent;
color: var(--text2); font-size: 10px; font-weight: 600; cursor: pointer;
transition: color 0.15s; -webkit-tap-highlight-color: transparent;
min-width: 0;
}
.tab-btn.active { color: var(--accent2); }
.tab-btn .tab-icon { font-size: 22px; }
/* ═══════════════════════════════════════════════════════════════════════
CARDS
═══════════════════════════════════════════════════════════════════════ */
.card {
background: var(--card); border: 1px solid var(--border);
border-radius: var(--radius); padding: 16px; margin-bottom: 10px;
}
.card-title {
font-size: 11px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.05em; color: var(--text2); margin-bottom: 10px;
}
/* ═══════════════════════════════════════════════════════════════════════
BUTTONS
═══════════════════════════════════════════════════════════════════════ */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
border: none; border-radius: var(--radius-sm); font-size: 14px; font-weight: 600;
padding: 11px 18px; cursor: pointer; transition: opacity 0.15s, transform 0.1s;
}
.btn:active { transform: scale(0.97); }
.btn-primary { background: var(--accent); color: #fff; width: 100%; }
.btn-primary:hover { background: var(--accent2); }
.btn-sm { padding: 8px 14px; font-size: 13px; width: auto; }
.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; }
/* ═══════════════════════════════════════════════════════════════════════
INPUTS
═══════════════════════════════════════════════════════════════════════ */
.input-field {
width: 100%; background: #0a0b0f; border: 1.5px solid var(--border);
border-radius: var(--radius-sm); color: var(--text); padding: 11px 14px;
font-size: 16px; outline: none; transition: border-color 0.2s; margin-bottom: 8px;
}
.input-field:focus { border-color: var(--accent); }
.input-field::placeholder { color: #3a3d4a; }
textarea.input-field { resize: vertical; min-height: 60px; font-family: inherit; font-size: 14px; }
select.input-field { appearance: none; }
.form-row { display: flex; gap: 8px; }
.form-row .input-field { flex: 1; }
/* checkbox */
.checkbox-row {
display: flex; align-items: center; gap: 8px;
padding: 8px 0; font-size: 14px; cursor: pointer;
}
.checkbox-row input[type=checkbox] {
width: 18px; height: 18px; accent-color: var(--accent); cursor: pointer;
}
/* ═══════════════════════════════════════════════════════════════════════
STATUS BAR
═══════════════════════════════════════════════════════════════════════ */
.status-bar {
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
background: #1a1b26; border-radius: var(--radius-sm); font-size: 12px;
color: var(--text2); margin-bottom: 8px; min-height: 36px;
}
.status-bar.success { background: var(--green-bg); color: var(--green); }
.status-bar.error { background: var(--red-bg); color: var(--red); }
.status-bar.working { color: var(--accent2); }
/* ═══════════════════════════════════════════════════════════════════════
TOAST
═══════════════════════════════════════════════════════════════════════ */
.toast {
position: fixed; bottom: calc(var(--tab-height) + 12px); left: 50%;
transform: translateX(-50%); background: var(--green); color: #000;
font-weight: 700; padding: 10px 22px; border-radius: 20px; font-size: 14px;
z-index: 400; opacity: 0; transition: opacity 0.3s; pointer-events: none;
white-space: nowrap; max-width: 90vw; overflow: hidden; text-overflow: ellipsis;
}
.toast.show { opacity: 1; }
.toast.error { background: var(--red); color: #fff; }
/* ═══════════════════════════════════════════════════════════════════════
SPINNER
═══════════════════════════════════════════════════════════════════════ */
.spinner {
width: 20px; height: 20px; 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); } }
/* ═══════════════════════════════════════════════════════════════════════
EMPTY STATE
═══════════════════════════════════════════════════════════════════════ */
.empty-state { text-align: center; padding: 32px 16px; color: var(--text2); font-size: 13px; }
.empty-state .es-icon { font-size: 40px; margin-bottom: 8px; opacity: 0.5; }
/* ═══════════════════════════════════════════════════════════════════════
MODAL / DIALOG
═══════════════════════════════════════════════════════════════════════ */
.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: 20px; width: 100%; max-width: 400px;
text-align: center;
}
.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 OVERLAY (Phase M)
═══════════════════════════════════════════════════════════════════════ */
.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; }
.login-card .login-footer { text-align: center; font-size: 12px; color: var(--text3); margin-top: 12px; }
/* ═══════════════════════════════════════════════════════════════════════
ROLE BADGE (Phase M)
═══════════════════════════════════════════════════════════════════════ */
.role-badge {
font-size: 10px; font-weight: 700; padding: 3px 8px; border-radius: 10px;
text-transform: uppercase; letter-spacing: 0.03em; margin-right: 4px;
}
.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(--border); color: var(--text2); }
.role-badge.guest { background: var(--border); color: var(--text3); }
/* ═══════════════════════════════════════════════════════════════════════
ACTIVITY FEED (Phase M)
═══════════════════════════════════════════════════════════════════════ */
.activity-item {
display: flex; gap: 12px; padding: 12px 0;
border-bottom: 1px solid rgba(255,255,255,0.04);
}
.activity-item .act-icon {
width: 36px; height: 36px; border-radius: 50%; display: flex;
align-items: center; justify-content: center; font-size: 16px; flex-shrink: 0;
}
.activity-item .act-icon.created { background: #1a2e2a; }
.activity-item .act-icon.updated { background: #1a2a3e; }
.activity-item .act-icon.deleted { background: #2e1a1a; }
.activity-item .act-icon.checkin { background: #2a2510; }
.activity-item .act-icon.geofence { background: #2a1a2e; }
.activity-item .act-icon.customer { background: #1a2e2a; }
.activity-item .act-icon.user { background: #1e1a2e; }
.activity-item .act-info { flex: 1; min-width: 0; }
.activity-item .act-text { font-size: 14px; line-height: 1.3; }
.activity-item .act-time { font-size: 12px; color: var(--text2); margin-top: 2px; }
.activity-item .act-user { font-size: 11px; color: var(--accent2); font-weight: 600; }
.act-filter-row { display: flex; gap: 8px; margin-bottom: 8px; }
.act-filter-row select { flex: 1; margin-bottom: 0; font-size: 13px; }
.act-pagination { display: flex; gap: 8px; justify-content: center; padding: 14px 0; }
.act-pagination button { min-width: 80px; }
/* ═══════════════════════════════════════════════════════════════════════
ASSET LIST ITEMS (shared)
═══════════════════════════════════════════════════════════════════════ */
.asset-item {
display: flex; align-items: center; gap: 12px; padding: 12px;
border-bottom: 1px solid rgba(255,255,255,0.04); cursor: pointer;
transition: background 0.15s;
}
.asset-item:hover, .asset-item:active { background: rgba(255,255,255,0.03); }
.asset-item .ai-icon {
width: 42px; height: 42px; border-radius: var(--radius-sm); display: flex;
align-items: center; justify-content: center; font-size: 20px; flex-shrink: 0;
}
.asset-item .ai-icon.cat-Furniture { background: #1a2e2a; }
.asset-item .ai-icon.cat-Appliances { background: #1a2a3e; }
.asset-item .ai-icon.cat-Utensils---Serveware { background: #2a2510; }
.asset-item .ai-icon.cat-Equipment { background: #2a1a2e; }
.asset-item .ai-icon.cat-Other { background: #1a1b26; }
.asset-item .ai-info { flex: 1; min-width: 0; }
.asset-item .ai-name { font-weight: 600; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.asset-item .ai-meta { font-size: 12px; color: var(--text2); margin-top: 2px; }
.asset-item .ai-arrow { color: var(--text2); font-size: 18px; }
/* status tag */
.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); }
/* ═══════════════════════════════════════════════════════════════════════
SEARCH BAR
═══════════════════════════════════════════════════════════════════════ */
.search-bar { position: relative; margin-bottom: 8px; }
.search-bar input { padding-right: 36px; margin-bottom: 0; }
.search-bar .clear-btn {
position: absolute; right: 10px; top: 50%; transform: translateY(-50%);
background: none; border: none; color: var(--text2); font-size: 18px;
cursor: pointer; display: none; padding: 4px;
}
/* ═══════════════════════════════════════════════════════════════════════
FILTER PILLS
═══════════════════════════════════════════════════════════════════════ */
.filter-pills { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
.pill {
font-size: 12px; padding: 5px 12px; border-radius: 20px; border: 1px solid var(--border);
background: transparent; color: var(--text2); cursor: pointer; font-weight: 500;
transition: all 0.15s;
}
.pill.active { background: var(--accent); border-color: var(--accent); color: #fff; }
/* ═══════════════════════════════════════════════════════════════════════
CAMERA / SCANNER (Add Asset tab)
═══════════════════════════════════════════════════════════════════════ */
.camera-area {
position: relative; background: #0a0b0f; border-radius: var(--radius-sm);
aspect-ratio: 16/12; display: flex; align-items: center; justify-content: center;
overflow: hidden; margin-bottom: 8px;
}
.camera-area video { width: 100%; height: 100%; object-fit: cover; position: absolute; inset: 0; }
.camera-placeholder { text-align: center; z-index: 1; cursor: pointer; }
.camera-placeholder .cam-icon { font-size: 40px; opacity: 0.5; }
.camera-placeholder .cam-hint { font-size: 13px; color: var(--text2); margin-top: 4px; }
/* ═══════════════════════════════════════════════════════════════════════
SCAN RESULT
═══════════════════════════════════════════════════════════════════════ */
.scan-result {
background: var(--card); border: 1px solid var(--accent); border-radius: var(--radius);
padding: 14px; margin-top: 10px; animation: fadeIn 0.2s;
}
@keyframes fadeIn { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } }
.scan-result .sr-name { font-size: 17px; font-weight: 700; }
.scan-result .sr-meta { font-size: 12px; color: var(--text2); margin-top: 2px; }
.scan-result .sr-actions { display: flex; gap: 8px; margin-top: 10px; }
/* ═══════════════════════════════════════════════════════════════════════
MODE TOGGLES (Add Asset tab)
═══════════════════════════════════════════════════════════════════════ */
.mode-toggles {
display: flex; gap: 4px; margin-bottom: 10px;
background: var(--card2); border-radius: var(--radius-sm); padding: 4px;
}
.mode-toggle {
flex: 1; padding: 8px 12px; border: none; background: transparent;
color: var(--text2); font-size: 13px; font-weight: 600; cursor: pointer;
border-radius: calc(var(--radius-sm) - 2px); transition: all 0.15s;
-webkit-tap-highlight-color: transparent;
}
.mode-toggle.active { background: var(--accent); color: #fff; }
.mode-toggle:active { opacity: 0.8; }
/* ═══════════════════════════════════════════════════════════════════════
ADD MODE PANELS
═══════════════════════════════════════════════════════════════════════ */
.add-mode { display: none; }
.add-mode.active { display: block; }
/* ═══════════════════════════════════════════════════════════════════════
OCR CAPTURE
═══════════════════════════════════════════════════════════════════════ */
.ocr-capture-overlay {
position: absolute; bottom: 12px; left: 50%; transform: translateX(-50%);
z-index: 2; display: flex; gap: 8px;
}
.ocr-capture-btn {
width: 56px; height: 56px; border-radius: 50%; border: 3px solid #fff;
background: rgba(255,255,255,0.2); cursor: pointer; display: flex;
align-items: center; justify-content: center; font-size: 22px;
transition: transform 0.1s; color: #fff;
}
.ocr-capture-btn:active { transform: scale(0.9); }
/* ═══════════════════════════════════════════════════════════════════════
FORM SECTIONS
═══════════════════════════════════════════════════════════════════════ */
.form-section {
background: var(--card2); border: 1px solid var(--border);
border-radius: var(--radius-sm); padding: 12px; margin-bottom: 8px;
}
.form-section-title {
font-size: 11px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.05em; color: var(--text2); margin-bottom: 10px;
display: flex; align-items: center; justify-content: space-between;
}
/* ═══════════════════════════════════════════════════════════════════════
KEY ENTRY ROW
═══════════════════════════════════════════════════════════════════════ */
.key-entry {
display: flex; gap: 6px; align-items: center; margin-bottom: 6px;
background: var(--card); border-radius: var(--radius-xs); padding: 6px 8px;
}
.key-entry select { margin-bottom: 0; flex: 1; }
.key-entry .key-remove {
background: none; border: none; color: var(--red); font-size: 18px;
cursor: pointer; padding: 2px 8px; border-radius: 4px; flex-shrink: 0;
}
/* ═══════════════════════════════════════════════════════════════════════
BADGE CHECKLIST
═══════════════════════════════════════════════════════════════════════ */
.badge-checklist { display: flex; flex-wrap: wrap; gap: 6px; }
.badge-check-item {
display: flex; align-items: center; gap: 4px;
font-size: 13px; padding: 6px 10px; border-radius: var(--radius-xs);
border: 1px solid var(--border); cursor: pointer; transition: all 0.15s;
}
.badge-check-item.checked { background: var(--accent-bg); border-color: var(--accent); color: var(--accent2); }
.badge-check-item input { display: none; }
/* ═══════════════════════════════════════════════════════════════════════
OCR RESULT
═══════════════════════════════════════════════════════════════════════ */
.ocr-result {
background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
padding: 14px; margin-top: 10px; animation: fadeIn 0.2s;
}
.ocr-result .or-id { font-family: monospace; font-size: 24px; font-weight: 700; letter-spacing: 2px; }
.ocr-result .or-meta { font-size: 12px; color: var(--text2); margin-top: 4px; }
.ocr-result .or-raw { font-size: 11px; color: var(--text3); margin-top: 8px; padding: 8px; background: #0a0b0f; border-radius: 4px; max-height: 80px; overflow-y: auto; word-break: break-all; white-space: pre-wrap; }
/* ═══════════════════════════════════════════════════════════════════════
DETAIL VIEW (shared)
═══════════════════════════════════════════════════════════════════════ */
.detail-header { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 14px; }
.detail-header .back-btn {
background: none; border: none; color: var(--accent2); font-size: 24px;
cursor: pointer; padding: 0 4px; line-height: 1;
}
.detail-header .dh-info { flex: 1; }
.detail-field { margin-bottom: 10px; }
.detail-field .df-label {
font-size: 10px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.05em; color: var(--text2); margin-bottom: 2px;
}
.detail-field .df-value { font-size: 14px; }
/* ═══════════════════════════════════════════════════════════════════════
STATS (Dashboard)
═══════════════════════════════════════════════════════════════════════ */
.stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.stats-grid.three { grid-template-columns: 1fr 1fr 1fr; }
.stat-card {
background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
padding: 16px; text-align: center;
}
.stat-card .stat-value { font-size: 28px; 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;
line-height: 1.3;
}
.stat-bar { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.stat-bar .sb-label { font-size: 12px; color: var(--text2); width: 80px; flex-shrink: 0; text-align: right; }
.stat-bar .sb-track { flex: 1; height: 8px; background: #0a0b0f; border-radius: 4px; overflow: hidden; }
.stat-bar .sb-fill { height: 100%; border-radius: 4px; background: var(--accent); transition: width 0.4s; }
.stat-bar .sb-count { font-size: 12px; font-weight: 600; width: 30px; }
/* ═══════════════════════════════════════════════════════════════════════
DASHBOARD: ACTIVITY FEED
═══════════════════════════════════════════════════════════════════════ */
.activity-item {
display: flex; align-items: flex-start; gap: 8px; 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: 28px; height: 28px; 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-text { flex: 1; min-width: 0; }
.activity-item .act-desc { color: var(--text); line-height: 1.4; }
.activity-item .act-time { font-size: 11px; color: var(--text2); margin-top: 2px; }
.activity-item .act-user { color: var(--accent2); font-weight: 600; }
/* ═══════════════════════════════════════════════════════════════════════
DASHBOARD: HIGH-VISIT TABLE
═══════════════════════════════════════════════════════════════════════ */
.hv-row {
display: flex; align-items: center; gap: 8px; padding: 10px 0;
border-bottom: 1px solid rgba(255,255,255,0.03); cursor: pointer;
transition: background 0.1s;
}
.hv-row:last-child { border-bottom: none; }
.hv-row:active { background: rgba(255,255,255,0.02); }
.hv-rank { width: 22px; height: 22px; border-radius: 50%; display: flex;
align-items: center; justify-content: center; font-size: 11px;
font-weight: 700; flex-shrink: 0; }
.hv-rank.r1 { background: var(--amber-bg); color: var(--amber); }
.hv-rank.r2, .hv-rank.r3 { background: var(--accent-bg); color: var(--accent2); }
.hv-rank.rn { background: var(--border); color: var(--text2); }
.hv-info { flex: 1; min-width: 0; }
.hv-name { font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.hv-meta { font-size: 11px; color: var(--text2); margin-top: 1px; }
.hv-visits { font-weight: 700; font-size: 14px; color: var(--accent2); flex-shrink: 0; }
/* ═══════════════════════════════════════════════════════════════════════
DASHBOARD: QUICK ACTIONS
═══════════════════════════════════════════════════════════════════════ */
.quick-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.qa-btn {
display: flex; align-items: center; justify-content: center; gap: 6px;
padding: 10px; border-radius: var(--radius-sm); border: 1px solid var(--border);
background: var(--card2); color: var(--text); font-size: 13px; font-weight: 600;
cursor: pointer; transition: background 0.15s;
}
.qa-btn:active { background: var(--border); }
.qa-btn.full { grid-column: 1 / -1; }
.qa-btn .qa-icon { font-size: 16px; }
/* ═══════════════════════════════════════════════════════════════════════
REPORT TABLES
═══════════════════════════════════════════════════════════════════════ */
.report-subtitle { font-size: 12px; font-weight: 600; color: var(--text2); margin-bottom: 6px; }
.rpt-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.rpt-table th {
text-align: left; padding: 8px 10px; 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;
}
.rpt-table th.sortable { cursor: pointer; }
.rpt-table th.sortable:hover { color: var(--accent); }
.rpt-table th .sort-arrow { font-size: 10px; margin-left: 3px; opacity: 0.4; }
.rpt-table th .sort-arrow.asc, .rpt-table th .sort-arrow.desc { opacity: 1; color: var(--accent); }
.rpt-table td {
padding: 8px 10px; border-bottom: 1px solid rgba(255,255,255,0.04);
vertical-align: middle;
}
.rpt-table tr:last-child td { border-bottom: none; }
.rpt-table .rpt-number { text-align: right; font-variant-numeric: tabular-nums; }
/* report inline bar */
.rpt-bar-wrap { display: flex; align-items: center; gap: 6px; }
.rpt-bar-track { flex: 1; height: 6px; background: #0a0b0f; border-radius: 3px; overflow: hidden; min-width: 40px; }
.rpt-bar-fill { height: 100%; border-radius: 3px; background: var(--accent); transition: width 0.4s; }
.rpt-bar-val { font-size: 12px; font-weight: 600; min-width: 40px; text-align: right; }
/* ═══════════════════════════════════════════════════════════════════════
PHOTO THUMBNAIL
═══════════════════════════════════════════════════════════════════════ */
.photo-thumb {
width: 60px; height: 60px; border-radius: 6px; object-fit: cover;
border: 1px solid var(--border);
}
/* ═══════════════════════════════════════════════════════════════════════
FILTER SCROLL + TOOLBAR
═══════════════════════════════════════════════════════════════════════ */
.assets-toolbar {
display: flex; align-items: flex-start; gap: 8px; margin-bottom: 8px;
}
.filter-scroll {
flex: 1; display: flex; gap: 6px; overflow-x: auto; padding-bottom: 4px;
scrollbar-width: none; -ms-overflow-style: none;
}
.filter-scroll::-webkit-scrollbar { display: none; }
.import-btn { white-space: nowrap; flex-shrink: 0; }
/* ═══════════════════════════════════════════════════════════════════════
CHECKIN HISTORY ITEMS
═══════════════════════════════════════════════════════════════════════ */
.checkin-item {
padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.04);
font-size: 13px;
}
.checkin-item:last-child { border-bottom: none; }
.checkin-item .ci-time { color: var(--text2); font-size: 11px; margin-bottom: 2px; }
.checkin-item .ci-coords { font-size: 11px; color: var(--text3); font-family: monospace; margin-bottom: 2px; }
.checkin-item .ci-notes { font-size: 13px; color: var(--text); }
/* ═══════════════════════════════════════════════════════════════════════
DETAIL LINKS
═══════════════════════════════════════════════════════════════════════ */
.df-link {
color: var(--accent2); cursor: pointer; text-decoration: underline;
font-weight: 500;
}
.df-link:active { opacity: 0.7; }
/* ═══════════════════════════════════════════════════════════════════════
KEY / BADGE LIST
═══════════════════════════════════════════════════════════════════════ */
.key-badge-list { display: flex; flex-wrap: wrap; gap: 4px; }
.key-tag, .badge-tag {
display: inline-block; font-size: 11px; padding: 3px 10px; border-radius: 4px;
font-weight: 500;
}
.key-tag { background: rgba(91,110,247,0.15); color: var(--accent2); }
.badge-tag { background: var(--amber-bg); color: var(--amber); }
/* ═══════════════════════════════════════════════════════════════════════
IMPORT TABLE
═══════════════════════════════════════════════════════════════════════ */
.import-table-wrap { overflow-x: auto; }
.import-table {
width: 100%; border-collapse: collapse; font-size: 11px;
}
.import-table th, .import-table td {
padding: 5px 8px; border: 1px solid var(--border); text-align: left;
white-space: nowrap;
}
.import-table th { background: var(--card2); color: var(--text2); font-weight: 600; }
.import-results {
background: var(--card2); border-radius: var(--radius-sm); padding: 12px;
margin-bottom: 12px; font-size: 13px;
}
.import-results .ir-ok { color: var(--green); }
.import-results .ir-err { color: var(--red); }
.import-results .ir-warn { color: var(--amber); }
/* ═══════════════════════════════════════════════════════════════════════
CATEGORY ICON COLORS (v2)
═══════════════════════════════════════════════════════════════════════ */
.asset-item .ai-icon.cat-Coffee { background: #2e1a0a; }
.asset-item .ai-icon.cat-Cold-Beverage { background: #0a1a2e; }
.asset-item .ai-icon.cat-Snacks { background: #2a2010; }
.asset-item .ai-icon.cat-Cold-Food { background: #1a2e1a; }
.asset-item .ai-icon.cat-Market { background: #2e1a2e; }
.asset-item .ai-icon.cat-Smart-Cooler { background: #1a2e2e; }
/* ═══════════════════════════════════════════════════════════════════════
SETTINGS TAB
═══════════════════════════════════════════════════════════════════════ */
.card-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 10px;
}
.card-header .card-title { margin-bottom: 0; }
.settings-list { }
.settings-item {
display: flex; align-items: center; gap: 10px;
padding: 10px 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: 18px; width: 24px; text-align: center; }
.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: 6px; flex-shrink: 0; }
.si-btn {
width: 28px; height: 28px; border: none; background: transparent;
color: var(--text2); font-size: 14px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
border-radius: var(--radius-xs);
-webkit-tap-highlight-color: transparent;
}
.si-btn:active { background: var(--border); }
.si-btn.delete { color: var(--red); }
/* Make expand chevron */
.settings-item.make-item { cursor: pointer; font-weight: 600; }
.settings-item.make-item .si-chevron {
transition: transform 0.2s; font-size: 12px; color: var(--text2);
}
.settings-item.make-item.expanded .si-chevron { transform: rotate(90deg); }
/* Model sub-list */
.models-sublist { display: none; padding-left: 34px; }
.models-sublist.open { display: block; }
.models-sublist .settings-item { font-size: 13px; padding: 8px 0; }
.models-sublist .settings-item .si-icon { width: 20px; }
.add-model-row {
display: flex; align-items: center; gap: 8px;
padding: 6px 0 6px 34px; border-bottom: 1px solid rgba(255,255,255,0.04);
}
.add-model-row input { flex: 1; margin-bottom: 0; font-size: 13px; padding: 7px 10px; }
/* Settings inline form */
.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: 14px; }
.settings-inline-form select { margin-bottom: 0; font-size: 14px; }
.settings-inline-form .si-actions { display: flex; gap: 4px; }
/* Emoji picker */
.emoji-picker {
display: flex; flex-wrap: wrap; gap: 4px; padding: 6px 0;
}
.emoji-picker .emoji-opt {
width: 32px; height: 32px; display: flex; align-items: center; justify-content: center;
font-size: 18px; border-radius: var(--radius-xs); cursor: pointer;
border: 1px solid transparent; background: transparent;
}
.emoji-opt:hover, .emoji-opt:active { background: var(--border); }
.emoji-opt.selected { border-color: var(--accent); background: var(--accent-bg); }
/* Setting row (config) */
.setting-row {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.04);
font-size: 14px;
}
.setting-row:last-child { border-bottom: none; }
.setting-val { color: var(--text2); font-size: 13px; }
/* Admin-only visibility */
.admin-only { display: none !important; }
.is-admin .admin-only { display: revert !important; }
/* Role badge inline */
.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); }
/* User detail inline */
.user-detail-inline {
padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.04);
}
.user-detail-inline .input-field { margin-bottom: 6px; }
/* Icon upload thumb */
.icon-thumb-small {
width: 28px; height: 28px; border-radius: 4px; object-fit: cover;
border: 1px solid var(--border);
}
</style>
<style>
/* ═══════════════════════════════════════════════════════════════════════
CUSTOMERS & LOCATIONS — list cards
═══════════════════════════════════════════════════════════════════════ */
.cust-card {
display: flex; align-items: center; gap: 12px; padding: 12px;
border-bottom: 1px solid rgba(255,255,255,0.04); cursor: pointer;
transition: background 0.15s;
}
.cust-card:hover, .cust-card:active { background: rgba(255,255,255,0.03); }
.cust-card .cc-icon {
width: 42px; height: 42px; border-radius: var(--radius-sm); display: flex;
align-items: center; justify-content: center; font-size: 20px; flex-shrink: 0;
background: var(--accent-bg);
}
.cust-card .cc-info { flex: 1; min-width: 0; }
.cust-card .cc-name { font-weight: 600; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cust-card .cc-meta { font-size: 12px; color: var(--text2); margin-top: 2px; }
.cust-card .cc-arrow { color: var(--text2); font-size: 18px; }
.loc-card {
display: flex; align-items: center; gap: 12px; padding: 12px;
border-bottom: 1px solid rgba(255,255,255,0.04); cursor: pointer;
transition: background 0.15s;
}
.loc-card:hover, .loc-card:active { background: rgba(255,255,255,0.03); }
.loc-card .lc-icon {
width: 38px; height: 38px; border-radius: var(--radius-sm); display: flex;
align-items: center; justify-content: center; font-size: 18px; flex-shrink: 0;
background: var(--green-bg);
}
.loc-card .lc-info { flex: 1; min-width: 0; }
.loc-card .lc-name { font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.loc-card .lc-meta { font-size: 12px; color: var(--text2); margin-top: 2px; }
.loc-card .lc-arrow { color: var(--text2); font-size: 18px; }
/* ═══════════════════════════════════════════════════════════════════════
CONTACTS 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: 16px;
cursor: pointer; padding: 2px 6px; border-radius: 4px;
}
.contact-row .cr-remove:active { background: var(--red-bg); }
/* ═══════════════════════════════════════════════════════════════════════
CONTACT DISPLAY (in detail views)
═══════════════════════════════════════════════════════════════════════ */
.contact-disp {
display: flex; align-items: center; gap: 10px; padding: 8px 0;
border-bottom: 1px solid rgba(255,255,255,0.03);
}
.contact-disp .cd-icon {
width: 32px; height: 32px; border-radius: 50%; display: flex;
align-items: center; justify-content: center; font-size: 14px;
background: var(--accent-bg); color: var(--accent2); flex-shrink: 0;
}
.contact-disp .cd-info { flex: 1; min-width: 0; }
.contact-disp .cd-name { font-weight: 600; font-size: 14px; }
.contact-disp .cd-detail { font-size: 12px; color: var(--text2); }
.contact-disp .cd-detail a { color: var(--accent2); text-decoration: none; }
/* ═══════════════════════════════════════════════════════════════════════
ROOM LIST ITEMS
═══════════════════════════════════════════════════════════════════════ */
.room-item {
display: flex; align-items: center; gap: 8px; padding: 9px 0;
border-bottom: 1px solid rgba(255,255,255,0.03);
}
.room-item .ri-name { flex: 1; font-size: 14px; font-weight: 500; }
.room-item .ri-floor { font-size: 11px; color: var(--text2); margin-right: 4px; }
.room-item .ri-actions { display: flex; gap: 4px; flex-shrink: 0; }
.room-item .ri-btn {
background: none; border: none; font-size: 15px; cursor: pointer;
padding: 3px 6px; border-radius: 4px; color: var(--text2);
}
.room-item .ri-btn.edit:active { color: var(--accent2); background: var(--accent-bg); }
.room-item .ri-btn.del:active { color: var(--red); background: var(--red-bg); }
/* ═══════════════════════════════════════════════════════════════════════
TAB SECTION (sub-views within Customers tab)
═══════════════════════════════════════════════════════════════════════ */
.tab-section { padding: 0; }
/* inline room editing row */
.room-edit-row { display: flex; gap: 4px; padding: 8px 0; align-items: center; }
.room-edit-row input { margin-bottom: 0; flex: 1; }
.room-edit-row button { flex-shrink: 0; }
/* ═══════════════════════════════════════════════════════════════════════
MAP STYLES
═══════════════════════════════════════════════════════════════════════ */
#mapContainer {
height: calc(100dvh - var(--header-height) - var(--tab-height) - 60px);
min-height: 300px;
width: 100%;
border-radius: var(--radius);
overflow: hidden;
border: 1px solid var(--border);
}
#mapContainer .leaflet-container {
background: var(--bg);
}
/* Geofence panel */
.geofence-panel {
margin-top: 8px;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px;
}
.geofence-panel .gp-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 8px;
}
.geofence-panel .gp-title {
font-size: 13px; font-weight: 600;
}
.geofence-item {
display: flex; align-items: center; gap: 8px;
padding: 8px 0; border-top: 1px solid var(--border);
font-size: 13px;
}
.geofence-item:first-of-type { border-top: none; }
.geofence-item .gf-color {
width: 14px; height: 14px; border-radius: 3px; flex-shrink: 0;
border: 1px solid rgba(255,255,255,0.2);
}
.geofence-item .gf-name { flex: 1; }
.geofence-item .gf-actions {
display: flex; gap: 4px;
}
.geofence-item .gf-btn {
background: none; border: none; color: var(--text2); font-size: 16px;
cursor: pointer; padding: 2px 6px; border-radius: 4px;
-webkit-tap-highlight-color: transparent;
}
.geofence-item .gf-btn:active { background: var(--border); }
.geofence-item .gf-btn.danger { color: var(--red); }
/* Map controls bar */
.map-controls {
display: flex; gap: 6px; margin-bottom: 6px; flex-wrap: wrap;
}
.map-chip {
font-size: 11px; font-weight: 600; padding: 6px 12px;
border-radius: 20px; border: 1px solid var(--border);
background: var(--card2); color: var(--text2); cursor: pointer;
transition: all 0.15s;
-webkit-tap-highlight-color: transparent;
}
.map-chip.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.map-chip.heat-on { background: var(--amber); border-color: var(--amber); color: #000; }
/* Color picker inline */
.color-picker-row {
display: flex; align-items: center; gap: 8px;
margin-top: 8px;
}
.color-picker-row input[type=color] {
width: 32px; height: 32px; border: none; border-radius: 6px;
background: transparent; cursor: pointer;
}
/* Leaflet draw toolbar dark theme overrides */
.leaflet-draw-toolbar a {
background: var(--card2) !important;
border-color: var(--border) !important;
}
.leaflet-draw-toolbar a:hover {
background: var(--accent-bg) !important;
}
.leaflet-draw-actions {
background: var(--card) !important;
border-color: var(--border) !important;
}
.leaflet-draw-actions a {
background: var(--card2) !important;
color: var(--text) !important;
border-color: var(--border) !important;
}
.leaflet-popup-content-wrapper {
background: var(--card) !important;
color: var(--text) !important;
border-radius: var(--radius) !important;
box-shadow: 0 4px 20px rgba(0,0,0,0.5) !important;
}
.leaflet-popup-tip { background: var(--card) !important; }
.leaflet-popup-close-button { color: var(--text2) !important; }
</style>
</head>
<body>
<!-- ═══════════════════════════════════════════════════════════════════════
HEADER
═══════════════════════════════════════════════════════════════════════ -->
<div class="header">
<button class="hamburger" onclick="openDrawer()" aria-label="Menu"></button>
<h1>📦 Canteen Assets</h1>
<div class="header-badges">
<span id="roleBadge" class="role-badge guest" style="display:none;">guest</span>
<span id="gpsBadge" class="gps-badge waiting">📍 GPS...</span>
<div class="user-badge" id="userBadge" onclick="openDrawer()" title="User">?</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════
DRAWER
═══════════════════════════════════════════════════════════════════════ -->
<div class="drawer-overlay" id="drawerOverlay" onclick="closeDrawer()"></div>
<div class="drawer" id="drawer">
<div class="drawer-header">
<span class="dh-title">Menu</span>
<button class="close-drawer" onclick="closeDrawer()" aria-label="Close menu"></button>
</div>
<div class="drawer-user">
<div class="du-avatar" id="drawerAvatar">?</div>
<div class="du-info">
<div class="du-name" id="drawerName">Not logged in</div>
<div class="du-role" id="drawerRole">guest</div>
</div>
</div>
<nav class="drawer-nav">
<button class="dn-item active" data-tab="tabAddAsset" onclick="navFromDrawer('tabAddAsset')">
<span class="dn-icon">📷</span> Add Asset
</button>
<button class="dn-item" data-tab="tabAssets" onclick="navFromDrawer('tabAssets')">
<span class="dn-icon">📦</span> Asset List
</button>
<button class="dn-item" data-tab="tabMap" onclick="navFromDrawer('tabMap')">
<span class="dn-icon">🗺️</span> Map
</button>
<button class="dn-item" data-tab="tabCustomers" onclick="navFromDrawer('tabCustomers')">
<span class="dn-icon">🏢</span> Customers &amp; Locations
</button>
<button class="dn-item" data-tab="tabDashboard" onclick="navFromDrawer('tabDashboard')">
<span class="dn-icon">📊</span> Dashboard
</button>
<button class="dn-item" data-tab="tabReports" onclick="navFromDrawer('tabReports')">
<span class="dn-icon">📋</span> Reports
</button>
<button class="dn-item" data-tab="tabActivity" onclick="navFromDrawer('tabActivity')">
<span class="dn-icon">📜</span> Activity Feed
</button>
<button class="dn-item" data-tab="tabSettings" onclick="navFromDrawer('tabSettings')">
<span class="dn-icon">⚙️</span> Settings
</button>
<div style="border-top:1px solid var(--border);margin:6px 16px;"></div>
<button class="dn-item" id="logoutBtn" onclick="doLogout()" style="display:none;">
<span class="dn-icon">🚪</span> Logout
</button>
</nav>
<div class="drawer-footer">Canteen Asset Tracker v2.0</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════
TAB: ADD ASSET (Barcode / OCR / Manual)
═══════════════════════════════════════════════════════════════════════ -->
<div id="tabAddAsset" class="tab-panel active">
<!-- Mode Toggles -->
<div class="mode-toggles">
<button class="mode-toggle active" data-mode="barcode" onclick="setAddAssetMode('barcode')">📷 Barcode</button>
<button class="mode-toggle" data-mode="ocr" onclick="setAddAssetMode('ocr')">🔍 OCR</button>
<button class="mode-toggle" data-mode="manual" onclick="setAddAssetMode('manual')">✏️ Manual</button>
</div>
<!-- ── BARCODE MODE ──────────────────────────────────────────────── -->
<div id="addBarcodeMode" class="add-mode active">
<div class="card">
<div class="card-title">Barcode Scanner</div>
<div id="cameraArea" class="camera-area">
<div id="cameraPlaceholder" class="camera-placeholder">
<span class="cam-icon">📷</span>
<div class="cam-hint">Tap to start camera</div>
</div>
<video id="cameraVideo" autoplay playsinline muted></video>
</div>
<div id="scanStatus" class="status-bar">Point camera at a barcode</div>
<div id="scanResult" class="scan-result" style="display:none;"></div>
</div>
<!-- New Asset form (shown when machine_id not found) -->
<div id="newAssetCard" class="card" style="display:none;">
<div class="card-title">Create New Asset</div>
<input id="newMachineId" type="text" class="input-field" placeholder="Machine ID" readonly>
<input id="newName" type="text" class="input-field" placeholder="Asset name *" required>
<textarea id="newDesc" class="input-field" placeholder="Description (optional)" rows="2"></textarea>
<div class="form-row" style="margin-bottom:8px;">
<select id="newCatSelect" class="input-field">
<option value="">Category</option>
</select>
<select id="newStatus" class="input-field">
<option value="active">Active</option>
<option value="maintenance">Maintenance</option>
<option value="retired">Retired</option>
</select>
</div>
<button class="btn btn-primary" onclick="createScannedAsset()">Create Asset</button>
</div>
<!-- Check-in form (shown when barcode found) -->
<div id="checkinCard" class="card" style="display:none;">
<div class="card-title">Check In</div>
<textarea id="checkinNotes" class="input-field" placeholder="Notes (optional)" rows="2"></textarea>
<div class="form-row">
<button class="btn btn-green" onclick="submitCheckin()" style="flex:1;">✓ Check In</button>
</div>
</div>
</div>
<!-- ── OCR MODE ──────────────────────────────────────────────────── -->
<div id="addOcrMode" class="add-mode">
<div class="card">
<div class="card-title">OCR Scanner — Photograph Sticker</div>
<div id="ocrCameraArea" class="camera-area">
<div id="ocrPlaceholder" class="camera-placeholder" onclick="startOcrCamera()">
<span class="cam-icon">📸</span>
<div class="cam-hint" style="margin-top:4px;">Tap to start camera</div>
</div>
<video id="ocrVideo" autoplay playsinline muted style="display:none;"></video>
<div class="ocr-capture-overlay" id="ocrCaptureOverlay" style="display:none;">
<button class="ocr-capture-btn" onclick="captureOcr()" title="Capture">📸</button>
</div>
</div>
<div id="ocrStatus" class="status-bar">Point camera at the machine sticker and tap capture</div>
</div>
<!-- OCR result -->
<div id="ocrResult" class="ocr-result" style="display:none;"></div>
<!-- Quick create from OCR -->
<div id="ocrCreateCard" class="card" style="display:none;">
<div class="card-title">Create from OCR</div>
<input id="ocrMachineId" type="text" class="input-field" placeholder="Machine ID" readonly>
<input id="ocrName" type="text" class="input-field" placeholder="Asset name *" required>
<select id="ocrCatSelect" class="input-field">
<option value="">Category</option>
</select>
<select id="ocrStatus" class="input-field">
<option value="active">Active</option>
<option value="maintenance">Maintenance</option>
<option value="retired">Retired</option>
</select>
<button class="btn btn-primary" onclick="createOcrAsset()">Create Asset</button>
</div>
</div>
<!-- ── MANUAL MODE ───────────────────────────────────────────────── -->
<div id="addManualMode" class="add-mode">
<div class="card">
<div class="card-title">Manual Entry</div>
<!-- Basic Info -->
<input id="manMachineId" type="text" class="input-field" placeholder="Machine ID *" required>
<input id="manSerialNumber" type="text" class="input-field" placeholder="Serial Number">
<input id="manName" type="text" class="input-field" placeholder="Asset Name *" required>
<textarea id="manDescription" class="input-field" placeholder="Description" rows="2"></textarea>
<div class="form-row" style="margin-bottom:8px;">
<select id="manCatSelect" class="input-field">
<option value="">Category</option>
</select>
<select id="manMake" class="input-field" onchange="loadModels()">
<option value="">Make</option>
</select>
</div>
<select id="manModel" class="input-field" style="margin-bottom:8px;">
<option value="">Model</option>
</select>
<select id="manStatus" class="input-field" style="margin-bottom:0;">
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="maintenance">Maintenance</option>
<option value="retired">Retired</option>
</select>
</div>
<!-- Directions -->
<div class="form-section">
<div class="form-section-title">📍 Directions &amp; Access</div>
<input id="manAddress" type="text" class="input-field" placeholder="Address / Trailer Number">
<div class="form-row">
<input id="manBuildingName" type="text" class="input-field" placeholder="Building Name">
<input id="manBuildingNumber" type="text" class="input-field" placeholder="Building #">
</div>
<div class="form-row">
<input id="manFloor" type="text" class="input-field" placeholder="Floor">
<input id="manRoom" type="text" class="input-field" placeholder="Room">
</div>
<textarea id="manWalkingDirections" class="input-field" placeholder="Walking directions..." rows="2"></textarea>
<div class="form-row">
<input id="manMapLink" type="url" class="input-field" placeholder="Map link (URL)">
<button class="btn btn-outline btn-sm" onclick="openMapPin()" style="flex-shrink:0;">📍 Pin</button>
</div>
<input id="manParkingLocation" type="text" class="input-field" placeholder="Parking location" style="margin-bottom:0;">
</div>
<!-- Keys -->
<div class="form-section">
<div class="form-section-title">
🔑 Keys
<button class="btn btn-outline btn-sm" onclick="addKeyEntry()">+ Add Key</button>
</div>
<div id="manKeysList"></div>
</div>
<!-- Security Badges -->
<div class="form-section">
<div class="form-section-title">🪪 Security Badges</div>
<div class="badge-checklist" id="manBadgesList"></div>
</div>
<!-- Customer & Location -->
<div class="form-section">
<div class="form-section-title">🏢 Customer &amp; Location</div>
<select id="manCustomer" class="input-field" onchange="loadManualLocations()">
<option value="">Customer</option>
</select>
<select id="manLocation" class="input-field" style="margin-bottom:0;">
<option value="">Location</option>
</select>
</div>
<!-- Photo -->
<div class="form-section">
<div class="form-section-title">📸 Photo (optional)</div>
<div id="manPhotoArea" class="camera-area" style="aspect-ratio:4/3;margin-bottom:8px;">
<div id="manPhotoPlaceholder" class="camera-placeholder" onclick="startManualPhoto()">
<span class="cam-icon">📸</span>
<div class="cam-hint" style="margin-top:4px;">Tap to take photo</div>
</div>
<video id="manPhotoVideo" autoplay playsinline muted style="display:none;"></video>
</div>
<div id="manPhotoCaptureRow" style="display:none;justify-content:center;margin-bottom:8px;">
<button class="ocr-capture-btn" onclick="captureManualPhoto()" title="Capture" style="position:static;width:48px;height:48px;font-size:18px;">📸</button>
</div>
<img id="manPhotoPreview" style="display:none;width:100%;border-radius:var(--radius-sm);">
<button id="manPhotoRetake" class="btn btn-outline btn-sm" style="display:none;margin-top:6px;width:100%;" onclick="retakeManualPhoto()">🔄 Retake</button>
</div>
<!-- Submit -->
<div class="form-row" style="margin-bottom:10px;">
<button class="btn btn-primary" onclick="submitManualAsset(false)" style="flex:2;">Create Asset</button>
<button class="btn btn-outline" onclick="submitManualAsset(true)" style="flex:1;">+ Add Another</button>
</div>
<!-- Post-create check-in -->
<div id="manCheckinCard" class="card" style="display:none;">
<div class="card-title">Quick Check-in</div>
<textarea id="manCheckinNotes" class="input-field" placeholder="Notes (optional)" rows="2"></textarea>
<button class="btn btn-green" onclick="quickCheckinManual()" style="width:100%;">✓ Check In Now</button>
</div>
</div> </div>
<!-- ═══════════════════════════════════════════════════════════════════════
TAB: ASSETS LIST
═══════════════════════════════════════════════════════════════════════ -->
<div id="tabAssets" class="tab-panel">
<!-- ── List View ────────────────────────────────────────────────────── -->
<div id="assetsListView">
<div class="search-bar">
<input id="assetSearch" type="text" class="input-field" placeholder="Search assets..." oninput="loadAssets()">
<button class="clear-btn" id="clearSearch" onclick="clearAssetSearch()"></button>
</div>
<div class="assets-toolbar">
<div class="filter-scroll" id="filterPills"></div>
<button class="btn btn-outline btn-sm import-btn" onclick="showImportView()">📥 Import</button>
</div>
<div id="assetList"></div>
</div>
<!-- ── Detail View ──────────────────────────────────────────────────── -->
<div id="assetsDetailView" style="display:none;">
<div class="detail-header">
<button class="back-btn" onclick="showAssetList()"></button>
<div class="dh-info">
<div id="detailName" style="font-size:18px;font-weight:700;"></div>
<div id="detailMeta" style="font-size:12px;color:var(--text2);margin-top:2px;"></div>
</div>
</div>
<!-- Info card -->
<div class="card">
<div class="card-title">Asset Details</div>
<div id="detailFields"></div>
</div>
<!-- Directions card -->
<div class="card" id="detailDirections" style="display:none;">
<div class="card-title">📍 Directions &amp; Access</div>
<div id="detailDirFields"></div>
</div>
<!-- Keys & Badges -->
<div class="card" id="detailAccess" style="display:none;">
<div class="card-title">🔑 Access</div>
<div id="detailAccessFields"></div>
</div>
<!-- Check-in History -->
<div class="card">
<div class="card-title">Check-in History <span id="checkinCount" style="color:var(--accent2);"></span></div>
<div id="checkinHistory"></div>
</div>
<!-- Actions -->
<div style="display:flex;gap:8px;margin-bottom:10px;flex-wrap:wrap;">
<button class="btn btn-green btn-sm" onclick="quickCheckin()" style="flex:1;min-width:100px;">✓ Check In</button>
<button class="btn btn-outline btn-sm" id="detailDirectionsBtn" onclick="openMapLink()" style="flex:1;min-width:100px;display:none;">🗺️ Directions</button>
</div>
<div style="display:flex;gap:8px;margin-bottom:10px;">
<button class="btn btn-outline btn-sm" onclick="editAsset()" style="flex:1;">✏️ Edit</button>
<button class="btn btn-danger btn-sm" onclick="deleteAsset()" style="flex:1;">🗑️ Delete</button>
</div>
</div>
<!-- ── Edit View ────────────────────────────────────────────────────── -->
<div id="assetsEditView" style="display:none;">
<div class="detail-header">
<button class="back-btn" onclick="showAssetList()"></button>
<div style="font-size:18px;font-weight:700;">Edit Asset</div>
</div>
<div class="card" id="editFormCard">
<input id="editBarcode" type="text" class="input-field" placeholder="Machine ID *">
<input id="editName" type="text" class="input-field" placeholder="Asset name *">
<textarea id="editDesc" class="input-field" placeholder="Description" rows="2"></textarea>
<div class="form-row" style="margin-bottom:8px;">
<select id="editCategory" class="input-field">
<option value="">Category</option>
<option>Furniture</option>
<option>Appliances</option>
<option>Utensils &amp; Serveware</option>
<option>Equipment</option>
<option>Other</option>
</select>
<select id="editStatus" class="input-field">
<option value="active">Active</option>
<option value="maintenance">Maintenance</option>
<option value="retired">Retired</option>
</select>
</div>
<select id="editAssignedTo" class="input-field">
<option value="">Assigned To (none)</option>
</select>
<button class="btn btn-primary" onclick="submitEditAsset()">Save Changes</button>
</div>
</div>
<!-- ── Import View ──────────────────────────────────────────────────── -->
<div id="assetsImportView" style="display:none;">
<div class="detail-header">
<button class="back-btn" onclick="showAssetList()"></button>
<div style="font-size:18px;font-weight:700;">Import Assets</div>
</div>
<div class="card">
<div class="card-title">CSV File</div>
<p style="font-size:13px;color:var(--text2);margin-bottom:10px;">
Upload a CSV file with columns: machine_id, name, category, make, model, serial_number, customer, location
</p>
<input type="file" id="importFileInput" accept=".csv" class="input-field" style="margin-bottom:12px;">
<div id="importPreview" style="display:none;margin-bottom:12px;">
<div style="font-size:12px;color:var(--text2);margin-bottom:4px;" id="importRowCount"></div>
<div class="import-table-wrap">
<table class="import-table" id="importTable"><thead></thead><tbody></tbody></table>
</div>
</div>
<div id="importResults" style="display:none;"></div>
<div style="display:flex;gap:8px;">
<button class="btn btn-outline btn-sm" onclick="showAssetList()" style="flex:1;">Cancel</button>
<button class="btn btn-primary btn-sm" id="importRunBtn" onclick="runImport()" style="flex:2;" disabled>Start Import</button>
</div>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════
TAB: MAP
═══════════════════════════════════════════════════════════════════════ -->
<div id="tabMap" class="tab-panel">
<div class="map-controls">
<span class="map-chip active" id="chipPins" onclick="togglePins()">📍 Pins</span>
<span class="map-chip" id="chipHeat" onclick="toggleHeatmap()">🔥 Heatmap</span>
<span class="map-chip" id="chipGeo" onclick="toggleGeofenceDraw()">✏️ Add Geofence</span>
<span class="map-chip" onclick="centerOnGPS()">◎ My Location</span>
</div>
<div id="mapContainer"></div>
<div id="geofenceColorRow" class="color-picker-row" style="display:none;">
<span style="font-size:12px;color:var(--text2);">Color:</span>
<input type="color" id="geofenceColor" value="#3388ff">
<button class="btn btn-outline btn-sm" onclick="saveDrawnGeofence()" style="flex:1;">Save Geofence</button>
<button class="btn btn-sm" onclick="cancelGeofenceDraw()" style="color:var(--text2);background:transparent;border:none;">Cancel</button>
</div>
<div class="geofence-panel" id="geofencePanel">
<div class="gp-header">
<span class="gp-title">📍 Geofences</span>
<span style="font-size:11px;color:var(--text2);" id="gfCount">0 zones</span>
</div>
<div id="geofenceList">
<div style="font-size:12px;color:var(--text3);padding:4px 0;">No geofences yet — tap ✏️ to draw one</div>
</div>
</div>
<!-- Auto-visit status indicator -->
<div id="visitTracker" style="display:none;margin-top:6px;padding:8px 12px;background:var(--green-bg);border-radius:var(--radius-sm);font-size:12px;color:var(--green);">
📍 Near <span id="vtAssetName"></span> · <span id="vtTimer">0 min</span>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════
TAB: CUSTOMERS & LOCATIONS
═══════════════════════════════════════════════════════════════════════ -->
<div id="tabCustomers" class="tab-panel">
<!-- ═══════════════════════════════════════════════════════════════════════
CUSTOMERS LIST VIEW
═══════════════════════════════════════════════════════════════════════ -->
<div id="custListView" class="tab-section">
<div class="search-bar">
<input id="custSearch" type="text" class="input-field" placeholder="Search customers..." oninput="loadCustomers()">
<button class="clear-btn" id="clearCustSearch" onclick="clearCustSearch()"></button>
</div>
<div class="card">
<div id="custList"></div>
</div>
<button class="btn btn-primary" onclick="showCustForm()" style="margin-top:6px;">+ New Customer</button>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════
CUSTOMER DETAIL VIEW
═══════════════════════════════════════════════════════════════════════ -->
<div id="custDetailView" class="tab-section" style="display:none;">
<div class="detail-header">
<button class="back-btn" onclick="showCustList()"></button>
<div class="dh-info">
<div id="custDetailName" style="font-size:18px;font-weight:700;"></div>
</div>
</div>
<div class="card" id="custDetailContacts">
<div class="card-title">Contacts</div>
<div id="custContactsList"></div>
</div>
<div class="card" id="custDetailLocations">
<div class="card-title">Locations <span id="custLocCount" style="color:var(--accent2);font-weight:400;"></span></div>
<div id="custLocationsList"></div>
</div>
<div class="form-row" style="gap:10px;margin-bottom:10px;">
<button class="btn btn-outline btn-sm" onclick="editCustomer()" style="flex:1;">Edit</button>
<button class="btn btn-danger btn-sm" onclick="deleteCustomer()" style="flex:1;">Delete</button>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════
CUSTOMER FORM VIEW (Create/Edit)
═══════════════════════════════════════════════════════════════════════ -->
<div id="custFormView" class="tab-section" style="display:none;">
<div class="detail-header">
<button class="back-btn" onclick="cancelCustForm()"></button>
<div id="custFormTitle" style="font-size:18px;font-weight:700;">New Customer</div>
</div>
<div class="card">
<input id="custFormName" type="text" class="input-field" placeholder="Customer name *" required>
<div class="card-title" style="margin-top:8px;">Contacts</div>
<div id="custFormContacts"></div>
<button class="btn btn-outline btn-sm" onclick="addContactRow()" style="width:100%;margin-top:4px;">+ Add Contact</button>
<button class="btn btn-primary" id="custFormSaveBtn" onclick="saveCustomer()" style="margin-top:12px;">Save Customer</button>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════
LOCATIONS LIST VIEW (for a customer)
═══════════════════════════════════════════════════════════════════════ -->
<div id="locListView" class="tab-section" style="display:none;">
<div class="detail-header">
<button class="back-btn" onclick="showCustList()"></button>
<div class="dh-info">
<div id="locListCustName" style="font-size:18px;font-weight:700;"></div>
<div style="font-size:12px;color:var(--text2);">Locations</div>
</div>
</div>
<div class="card">
<div id="locList"></div>
</div>
<button class="btn btn-primary" onclick="showLocForm()" style="margin-top:6px;">+ New Location</button>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════
LOCATION DETAIL VIEW
═══════════════════════════════════════════════════════════════════════ -->
<div id="locDetailView" class="tab-section" style="display:none;">
<div class="detail-header">
<button class="back-btn" onclick="backFromLocDetail()"></button>
<div class="dh-info">
<div id="locDetailName" style="font-size:18px;font-weight:700;"></div>
<div id="locDetailCust" style="font-size:12px;color:var(--text2);"></div>
</div>
</div>
<div class="card" id="locDetailFields"></div>
<div class="card">
<div class="card-title">Rooms <span id="roomCount" style="color:var(--accent2);font-weight:400;"></span></div>
<div id="roomsList"></div>
<div id="roomAddRow" style="display:flex;gap:6px;margin-top:8px;">
<input id="newRoomName" type="text" class="input-field" placeholder="Room name" style="flex:2;margin-bottom:0;">
<input id="newRoomFloor" type="text" class="input-field" placeholder="Floor" style="flex:1;margin-bottom:0;">
<button class="btn btn-green btn-sm" onclick="addRoom()" style="flex-shrink:0;">+ Add</button>
</div>
</div>
<div class="form-row" style="gap:10px;margin-bottom:10px;">
<button class="btn btn-outline btn-sm" onclick="editLocation()" style="flex:1;">Edit</button>
<button class="btn btn-danger btn-sm" onclick="deleteLocation()" style="flex:1;">Delete</button>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════
LOCATION FORM VIEW (Create/Edit)
═══════════════════════════════════════════════════════════════════════ -->
<div id="locFormView" class="tab-section" style="display:none;">
<div class="detail-header">
<button class="back-btn" onclick="cancelLocForm()"></button>
<div id="locFormTitle" style="font-size:18px;font-weight:700;">New Location</div>
</div>
<div class="card">
<select id="locFormCustomer" class="input-field">
<option value="">Select customer *</option>
</select>
<input id="locFormName" type="text" class="input-field" placeholder="Location name *" required>
<input id="locFormAddress" type="text" class="input-field" placeholder="Address">
<div class="form-row">
<input id="locFormBldgName" type="text" class="input-field" placeholder="Building name">
<input id="locFormBldgNum" type="text" class="input-field" placeholder="Building #">
</div>
<div class="form-row">
<input id="locFormFloor" type="text" class="input-field" placeholder="Floor">
<input id="locFormTrailer" type="text" class="input-field" placeholder="Trailer #">
</div>
<input id="locFormSiteHours" type="text" class="input-field" placeholder="Site hours (e.g. M-F 8am-5pm)">
<textarea id="locFormAccess" class="input-field" placeholder="Access notes (gate codes, call-ahead)" rows="2"></textarea>
<textarea id="locFormWalking" class="input-field" placeholder="Walking directions" rows="2"></textarea>
<input id="locFormMapLink" type="url" class="input-field" placeholder="Map link (URL)">
<button class="btn btn-primary" id="locFormSaveBtn" onclick="saveLocation()" style="margin-top:6px;">Save Location</button>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════
TAB: DASHBOARD
═══════════════════════════════════════════════════════════════════════ -->
<div id="tabDashboard" class="tab-panel">
<!-- Stat cards -->
<div class="stats-grid three" id="statsGrid">
<div class="stat-card"><div class="stat-value" id="statAssets"></div><div class="stat-label">Total<br>Assets</div></div>
<div class="stat-card"><div class="stat-value" id="statCheckins"></div><div class="stat-label">Total<br>Check-ins</div></div>
<div class="stat-card"><div class="stat-value" id="statTechs"></div><div class="stat-label">Active<br>Technicians</div></div>
</div>
<!-- By Category -->
<div class="card">
<div class="card-title">By Category</div>
<div id="statsCategories"></div>
</div>
<!-- By Status -->
<div class="card">
<div class="card-title">By Status</div>
<div id="statsStatuses"></div>
</div>
<!-- By Make/Model -->
<div class="card">
<div class="card-title">By Make / Manufacturer</div>
<div id="statsMakes"></div>
</div>
<!-- High Visit Rate -->
<div class="card" id="highVisitCard">
<div class="card-title">🔝 Most Visited Assets</div>
<div id="highVisitList"></div>
</div>
<!-- Quick Actions -->
<div class="card">
<div class="card-title">Quick Actions</div>
<div class="quick-actions">
<button class="qa-btn" onclick="exportCSV('assets')"><span class="qa-icon">📋</span> Assets CSV</button>
<button class="qa-btn" onclick="exportCSV('checkins')"><span class="qa-icon">📍</span> Check-ins CSV</button>
<button class="qa-btn" onclick="exportCSV('service')"><span class="qa-icon">📊</span> Service Summary</button>
<button class="qa-btn" onclick="loadDashboard()"><span class="qa-icon">🔄</span> Refresh</button>
</div>
</div>
<!-- Recent Activity Feed -->
<div class="card">
<div class="card-title">📝 Recent Activity</div>
<div id="activityFeed"></div>
<div style="margin-top:8px;text-align:center;">
<a href="javascript:void(0)" onclick="loadFullActivity()" style="color:var(--accent2);font-size:13px;font-weight:600;text-decoration:none;">View All →</a>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════
TAB: ACTIVITY FEED (Phase M)
═══════════════════════════════════════════════════════════════════════ -->
<div id="tabActivity" class="tab-panel">
<div class="card">
<div class="card-title">📜 Activity Feed</div>
<div class="search-bar">
<input id="actSearch" type="text" class="input-field" placeholder="Search activity..." oninput="loadActivity()" style="margin-bottom:0;">
<button class="clear-btn" id="clearActSearch" onclick="document.getElementById('actSearch').value='';loadActivity();"></button>
</div>
<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 Actions</option>
<option value="created">✨ Created</option>
<option value="updated">✏️ Updated</option>
<option value="deleted">🗑️ Deleted</option>
<option value="checkin">✓ Check-in</option>
</select>
</div>
<div id="actList"></div>
<div class="act-pagination" id="actPagination"></div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════
TAB: REPORTS
<!-- ═══════════════════════════════════════════════════════════════════════
TAB: REPORTS
═══════════════════════════════════════════════════════════════════════ -->
<div id="tabReports" class="tab-panel">
<!-- Service Summary -->
<div class="card">
<div class="card-title">Service Summary</div>
<div style="margin-bottom:10px;">
<div class="form-row" style="margin-bottom:8px;">
<input type="date" class="input-field" id="reportDateFrom" style="font-size:13px;">
<input type="date" class="input-field" id="reportDateTo" style="font-size:13px;">
</div>
<button class="btn btn-primary btn-sm" onclick="loadReports()">Generate Report</button>
<button class="btn btn-outline btn-sm" onclick="exportServiceSummary()">⬇ Download CSV</button>
</div>
<div class="stats-grid" id="reportSummaryCards" style="margin-bottom:10px;">
<div class="stat-card"><div class="stat-value" id="rptAssets"></div><div class="stat-label">Assets Serviced</div></div>
<div class="stat-card"><div class="stat-value" id="rptVisits"></div><div class="stat-label">Total Visits</div></div>
<div class="stat-card"><div class="stat-value" id="rptAvgTime"></div><div class="stat-label">Avg Time on Site</div></div>
<div class="stat-card"><div class="stat-value" id="rptTechs"></div><div class="stat-label">Technicians</div></div>
</div>
<div id="reportServiceTable"></div>
</div>
<!-- Visit Frequency -->
<div class="card">
<div class="card-title">Visit Frequency</div>
<div id="reportVisitTable"></div>
</div>
<!-- Time on Site -->
<div class="card">
<div class="card-title">Time on Site</div>
<div class="report-subtitle">Per Technician</div>
<div id="reportTimeTech"></div>
<div class="report-subtitle" style="margin-top:12px;">Per Asset</div>
<div id="reportTimeAsset"></div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════
TAB: SETTINGS
═══════════════════════════════════════════════════════════════════════ -->
<div id="tabSettings" class="tab-panel">
<!-- Categories -->
<div class="card" data-entity="categories">
<div class="card-header">
<div class="card-title">📂 Categories</div>
<button class="btn btn-sm btn-outline admin-only" data-action="settingsAdd" data-entity="categories">+ Add</button>
</div>
<div id="settingsCategories" class="settings-list"></div>
</div>
<!-- Makes & Models -->
<div class="card" data-entity="makes">
<div class="card-header">
<div class="card-title">🏭 Makes &amp; Models</div>
<button class="btn btn-sm btn-outline admin-only" data-action="settingsAdd" data-entity="makes">+ Add Make</button>
</div>
<div id="settingsMakes" class="settings-list"></div>
</div>
<!-- Key Names -->
<div class="card" data-entity="key_names">
<div class="card-header">
<div class="card-title">🔑 Key Names</div>
<button class="btn btn-sm btn-outline admin-only" data-action="settingsAdd" data-entity="key_names">+ Add</button>
</div>
<div id="settingsKeyNames" class="settings-list"></div>
</div>
<!-- Key Types -->
<div class="card" data-entity="key_types">
<div class="card-header">
<div class="card-title">🔐 Key Types</div>
<button class="btn btn-sm btn-outline admin-only" data-action="settingsAdd" data-entity="key_types">+ Add</button>
</div>
<div id="settingsKeyTypes" class="settings-list"></div>
</div>
<!-- Badge Types -->
<div class="card" data-entity="badge_types">
<div class="card-header">
<div class="card-title">🪪 Security Badge Requirements</div>
<button class="btn btn-sm btn-outline admin-only" data-action="settingsAdd" data-entity="badge_types">+ Add</button>
</div>
<div id="settingsBadgeTypes" class="settings-list"></div>
</div>
<!-- User Accounts (admin only) -->
<div class="card admin-only" id="settingsUsersCard">
<div class="card-header">
<div class="card-title">👥 User Accounts</div>
<button class="btn btn-sm btn-outline" data-action="addUser">+ Add</button>
</div>
<div id="settingsUsers" class="settings-list"></div>
</div>
<!-- App Configuration -->
<div class="card">
<div class="card-title">⚙️ App Configuration</div>
<div class="settings-config">
<div class="setting-row">
<span>Theme</span>
<span class="setting-val">Dark</span>
</div>
<div class="setting-row">
<button class="btn btn-sm btn-danger admin-only" data-action="resetDatabase">🗑 Reset Database</button>
</div>
<div class="about-section">
<div class="about-text" style="font-size:12px;color:var(--text3);margin-top:12px;">
Canteen Asset Tracker v2.0<br>FastAPI + SQLite · Mobile-first
</div>
</div>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════
BOTTOM TABS
═══════════════════════════════════════════════════════════════════════ -->
<div class="tabs">
<button class="tab-btn active" data-tab="tabAddAsset" onclick="switchTab('tabAddAsset')">
<span class="tab-icon">📷</span> Add Asset
</button>
<button class="tab-btn" data-tab="tabAssets" onclick="switchTab('tabAssets')">
<span class="tab-icon">📦</span> Assets
</button>
<button class="tab-btn" data-tab="tabMap" onclick="switchTab('tabMap')">
<span class="tab-icon">🗺️</span> Map
</button>
<button class="tab-btn" data-tab="tabDashboard" onclick="switchTab('tabDashboard')">
<span class="tab-icon">📊</span> Dash
</button>
<button class="tab-btn" data-tab="tabMore" onclick="openDrawer()">
<span class="tab-icon"></span> More
</button>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════
TOAST
═══════════════════════════════════════════════════════════════════════ -->
<div class="toast" id="toast"></div>
<!-- ═══════════════════════════════════════════════════════════════════════
MODAL
═══════════════════════════════════════════════════════════════════════ -->
<div class="modal-overlay" id="modalOverlay" onclick="closeModal()">
<div class="modal" onclick="event.stopPropagation()">
<div class="modal-title" id="modalTitle">Confirm</div>
<div class="modal-body" id="modalBody">Are you sure?</div>
<div class="modal-actions">
<button class="btn btn-outline btn-sm" onclick="closeModal()">Cancel</button>
<button class="btn btn-danger btn-sm" id="modalConfirmBtn">Confirm</button>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════
LOGIN OVERLAY (Phase M)
═══════════════════════════════════════════════════════════════════════ -->
<div class="login-overlay hidden" id="loginOverlay">
<div class="login-card">
<div class="login-icon">🔐</div>
<h2>Sign In</h2>
<div class="login-error" id="loginError"></div>
<input id="loginUsername" type="text" class="input-field" placeholder="Username" autocomplete="username">
<input id="loginPassword" type="password" class="input-field" placeholder="Password" autocomplete="current-password">
<label class="checkbox-row">
<input type="checkbox" id="loginRemember"> Remember me
</label>
<button class="btn btn-primary" onclick="doLogin()">Sign In</button>
<div class="login-footer">Canteen Asset Tracker v2.0</div>
</div>
</div>
<script>
// =========================================================================
// API WRAPPER
// =========================================================================
async function api(url, opts = {}) {
// Auto-attach auth token if available
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, // { id, username, role } or null
authToken: null,
gpsLat: null,
gpsLng: null,
gpsAcc: null,
currentTab: 'tabAddAsset',
currentAssetId: null,
};
function isLoggedIn() { return !!AppState.authToken; }
function isAdmin() { return AppState.currentUser && AppState.currentUser.role === 'admin'; }
function isTechnician() { return AppState.currentUser && AppState.currentUser.role === 'technician'; }
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('userBadge').textContent = initial;
document.getElementById('userBadge').title = name;
document.getElementById('drawerAvatar').textContent = initial;
document.getElementById('drawerName').textContent = name;
document.getElementById('drawerRole').textContent = role;
// Role badge in header
const rb = document.getElementById('roleBadge');
if (u && u.role) {
rb.textContent = u.role;
rb.className = 'role-badge ' + u.role;
rb.style.display = 'inline-flex';
} else {
rb.style.display = 'none';
}
// Logout button visibility
const logoutBtn = document.getElementById('logoutBtn');
if (logoutBtn) logoutBtn.style.display = isLoggedIn() ? 'flex' : 'none';
// Route guards: show/hide admin-only elements
document.querySelectorAll('.admin-only').forEach(el => {
el.style.display = isAdmin() ? '' : 'none';
});
// Technician-only elements
document.querySelectorAll('.tech-only').forEach(el => {
el.style.display = isTechnician() ? '' : 'none';
});
}
// Try to restore session from localStorage or auto-login
async function initAuth() {
// Check for stored token
const stored = localStorage.getItem('canteen_session');
if (stored) {
try {
const session = JSON.parse(stored);
AppState.authToken = session.token;
// Validate token by fetching current user
const user = await api('/api/auth/me', {
headers: { 'Authorization': 'Bearer ' + session.token }
});
AppState.currentUser = user;
// Load settings now that we're authenticated
loadSettingsCache().then(() => {
populateCategorySelect('newCatSelect');
populateCategorySelect('ocrCatSelect');
populateCategorySelect('manCatSelect');
populateMakeSelect();
populateCustomerSelect();
loadBadgeChecklist();
});
} catch (e) {
// Token expired or invalid — clear it
localStorage.removeItem('canteen_session');
AppState.authToken = null;
AppState.currentUser = null;
}
}
updateUserUI();
checkAuthGate();
}
// =========================================================================
// AUTH: Login, Logout, Auth Gate (Phase M)
// =========================================================================
async function doLogin() {
const username = document.getElementById('loginUsername').value.trim();
const password = document.getElementById('loginPassword').value;
const errEl = document.getElementById('loginError');
if (!username || !password) {
errEl.textContent = 'Please enter username and password';
errEl.classList.add('show');
return;
}
try {
const result = await api('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
AppState.authToken = result.token;
AppState.currentUser = { id: result.id, username: result.username, role: result.role };
const remember = document.getElementById('loginRemember').checked;
if (remember) {
localStorage.setItem('canteen_session', JSON.stringify({
token: result.token,
user: { id: result.id, username: result.username, role: result.role },
}));
}
updateUserUI();
hideLogin();
showToast('Welcome, ' + result.username + '!');
// Load settings now that we're authenticated
loadSettingsCache().then(() => {
populateCategorySelect('newCatSelect');
populateCategorySelect('ocrCatSelect');
populateCategorySelect('manCatSelect');
populateMakeSelect();
populateCustomerSelect();
loadBadgeChecklist();
});
} catch (e) {
errEl.textContent = e.message || 'Login failed';
errEl.classList.add('show');
}
}
function doLogout() {
localStorage.removeItem('canteen_session');
AppState.authToken = null;
AppState.currentUser = null;
updateUserUI();
showLogin();
showToast('Logged out');
}
function showLogin() {
document.getElementById('loginOverlay').classList.remove('hidden');
document.getElementById('loginUsername').value = '';
document.getElementById('loginPassword').value = '';
document.getElementById('loginError').classList.remove('show');
document.getElementById('loginUsername').focus();
}
function hideLogin() {
document.getElementById('loginOverlay').classList.add('hidden');
}
function checkAuthGate() {
if (isLoggedIn()) {
hideLogin();
} else {
showLogin();
}
}
// Allow Enter key to submit login
document.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !document.getElementById('loginOverlay').classList.contains('hidden')) {
var active = document.activeElement;
if (active && (active.id === 'loginUsername' || active.id === 'loginPassword')) {
doLogin();
}
}
});
// =========================================================================
// TECH OPTIONS: Load users for Assigned To dropdown (Phase M)
// =========================================================================
async function loadTechnicianOptions() {
try {
var users = await api('/api/users');
var sel = document.getElementById('editAssignedTo');
if (!sel) return;
var current = sel.value;
sel.innerHTML = '<option value="">Assigned To (none)</option>';
users.forEach(function(u) {
sel.innerHTML += '<option value="' + u.id + '">' + esc(u.username) + ' (' + u.role + ')</option>';
});
sel.value = current;
} catch (e) {
console.error('Failed to load users:', e);
}
}
// =========================================================================
// ACTIVITY FEED (Phase M)
// =========================================================================
var actPage = 0;
var ACT_PER_PAGE = 30;
var ACT_ICONS = {
created: '\u2728', updated: '\u270F\uFE0F', deleted: '\uD83D\uDDD1\uFE0F',
checkin: '\u2713', geofence: '\uD83D\uDCCD', customer: '\uD83C\uDFE2', user: '\uD83D\uDC64',
};
async function loadActivity() {
var userFilter = document.getElementById('actUserFilter').value;
var typeFilter = document.getElementById('actTypeFilter').value;
var searchQ = document.getElementById('actSearch').value.trim();
var clearBtn = document.getElementById('clearActSearch');
if (clearBtn) clearBtn.style.display = searchQ ? 'block' : 'none';
clearTimeout(loadActivity._debounce);
loadActivity._debounce = setTimeout(function() { _loadActivity(userFilter, typeFilter, searchQ); }, 200);
}
async function _loadActivity(userFilter, typeFilter, searchQ) {
var params = new URLSearchParams();
if (userFilter) params.set('user_id', userFilter);
if (typeFilter) params.set('entity_type', typeFilter);
params.set('limit', String(ACT_PER_PAGE));
params.set('offset', String(actPage * ACT_PER_PAGE));
try {
var items = await api('/api/activity?' + params.toString());
if (searchQ) {
var q = searchQ.toLowerCase();
items = items.filter(function(a) {
return (a.details && a.details.toLowerCase().indexOf(q) !== -1) ||
(a.user_name && a.user_name.toLowerCase().indexOf(q) !== -1) ||
(a.entity_type && a.entity_type.toLowerCase().indexOf(q) !== -1);
});
}
renderActivity(items);
renderActivityPagination(items.length);
} catch (e) {
document.getElementById('actList').innerHTML =
'<div class="empty-state">Failed to load activity</div>';
}
}
function renderActivity(items) {
var el = document.getElementById('actList');
if (!items.length) {
el.innerHTML = '<div class="empty-state">No activity yet</div>';
return;
}
el.innerHTML = items.map(function(a) {
var icon = ACT_ICONS[a.action] || '\uD83D\uDCCC';
var cssClass = a.action || 'other';
var text = a.details || (a.action + ' ' + a.entity_type + ' #' + a.entity_id);
var html = '<div class="activity-item">';
html += '<div class="act-icon ' + cssClass + '">' + icon + '</div>';
html += '<div class="act-info">';
html += '<div class="act-text">' + esc(text) + '</div>';
html += '<div class="act-time">' + timeAgo(a.created_at) + '</div>';
if (a.user_name) html += '<div class="act-user">by ' + esc(a.user_name) + '</div>';
html += '</div></div>';
return html;
}).join('');
}
function renderActivityPagination(count) {
var el = document.getElementById('actPagination');
var html = '';
if (actPage > 0) {
html += '<button class="btn btn-outline btn-sm" onclick="actPage--;loadActivity();">\u2190 Newer</button>';
}
if (count >= ACT_PER_PAGE) {
html += '<button class="btn btn-outline btn-sm" onclick="actPage++;loadActivity();">Older \u2192</button>';
}
el.innerHTML = html;
}
async function loadActivityUserFilter() {
try {
var users = await api('/api/users');
var sel = document.getElementById('actUserFilter');
if (!sel) return;
users.forEach(function(u) {
sel.innerHTML += '<option value="' + u.id + '">' + esc(u.username) + '</option>';
});
} catch (e) {
console.error('Failed to load user filter:', e);
}
}
// =========================================================================
// TECHNICIAN VIEW: My Assets (Phase M)
// =========================================================================
function showMyAssets() {
if (!isTechnician() || !AppState.currentUser) {
showToast('Only available for technicians', true);
return;
}
AppState._myAssetsFilter = AppState.currentUser.id;
assetFilters.assigned_to = AppState.currentUser.id;
switchTab('tabAssets');
loadAssets();
}
// =========================================================================
// GPS
// =========================================================================
function initGPS() {
const badge = document.getElementById('gpsBadge');
if (!navigator.geolocation) {
badge.className = 'gps-badge err';
badge.textContent = '📍 Unavailable';
return;
}
navigator.geolocation.getCurrentPosition(
pos => {
AppState.gpsLat = pos.coords.latitude;
AppState.gpsLng = pos.coords.longitude;
AppState.gpsAcc = pos.coords.accuracy;
badge.className = 'gps-badge ok';
badge.textContent = `📍 ${AppState.gpsLat.toFixed(4)}, ${AppState.gpsLng.toFixed(4)}`;
},
err => {
badge.className = 'gps-badge err';
badge.textContent = '📍 ' + err.message;
},
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 300000 }
);
}
// =========================================================================
// TOAST
// =========================================================================
let toastTimer;
function showToast(msg, isError = false) {
const t = document.getElementById('toast');
t.textContent = msg;
t.className = 'toast' + (isError ? ' error' : '');
t.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.remove('show'), 2000);
}
// =========================================================================
// MODAL
// =========================================================================
let modalResolve = null;
function showModal(title, body, confirmText = 'Confirm', confirmClass = 'btn-danger') {
document.getElementById('modalTitle').textContent = title;
const bodyEl = document.getElementById('modalBody');
bodyEl.innerHTML = '';
if (typeof body === 'string') {
bodyEl.textContent = body;
} else {
bodyEl.appendChild(body);
}
const btn = document.getElementById('modalConfirmBtn');
btn.textContent = confirmText;
btn.className = 'btn btn-sm ' + confirmClass;
document.getElementById('modalOverlay').classList.add('open');
return new Promise(resolve => {
modalResolve = resolve;
});
}
function closeModal(confirmed = false) {
document.getElementById('modalOverlay').classList.remove('open');
if (modalResolve) {
modalResolve(confirmed);
modalResolve = null;
}
}
document.getElementById('modalConfirmBtn').addEventListener('click', () => closeModal(true));
// =========================================================================
// DRAWER
// =========================================================================
function openDrawer() {
document.getElementById('drawer').classList.add('open');
document.getElementById('drawerOverlay').classList.add('open');
// Highlight current tab in drawer
document.querySelectorAll('.drawer-nav .dn-item').forEach(el => {
el.classList.toggle('active', el.dataset.tab === AppState.currentTab);
});
}
function closeDrawer() {
document.getElementById('drawer').classList.remove('open');
document.getElementById('drawerOverlay').classList.remove('open');
}
function navFromDrawer(tabId) {
closeDrawer();
switchTab(tabId);
}
// =========================================================================
// TAB SWITCHING
// =========================================================================
function switchTab(tabId) {
AppState.currentTab = tabId;
// Update tab panels
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
const panel = document.getElementById(tabId);
if (panel) panel.classList.add('active');
// Update bottom tabs — highlight the matching tab, or "More" for drawer-only tabs
const tabMap = {
tabAddAsset: 'tabAddAsset',
tabAssets: 'tabAssets',
tabMap: 'tabMap',
tabDashboard: 'tabDashboard',
};
const bottomTab = tabMap[tabId] || 'tabMore';
document.querySelectorAll('.tab-btn').forEach(b => {
b.classList.toggle('active', b.dataset.tab === bottomTab);
});
// Update drawer highlight
document.querySelectorAll('.drawer-nav .dn-item').forEach(el => {
el.classList.toggle('active', el.dataset.tab === tabId);
});
// Tab lifecycle hooks
if (tabId === 'tabAddAsset') startScanning();
else stopScanning();
if (tabId === 'tabAssets') { loadAssets(); loadTechnicianOptions(); }
if (tabId === 'tabDashboard') loadDashboard();
if (tabId === 'tabActivity') { loadActivity(); loadActivityUserFilter(); }
// Map tab lifecycle
if (tabId === 'tabMap') {
initMap();
if (map) {
setTimeout(() => map.invalidateSize(), 150);
loadAssetPins(); // Refresh pins on every tab visit
}
startVisitTracking();
} else {
stopVisitTracking();
if (drawingGeofence) cancelGeofenceDraw();
}
if (tabId === 'tabReports') loadReports();
// Dispatch event so child tab implementations can hook in
document.dispatchEvent(new CustomEvent('tabChange', { detail: { tabId } }));
}
// =========================================================================
// EVENT DELEGATION
// =========================================================================
// Global click handler for dynamic content
document.addEventListener('click', function(e) {
const el = e.target.closest('[data-action]');
if (!el) return;
const action = el.dataset.action;
// Child tabs register handlers via AppState.actions or direct listeners
const handler = AppState._actions && AppState._actions[action];
if (handler) {
e.preventDefault();
handler(el, e);
}
});
// Register an action handler for event delegation
function registerAction(name, handler) {
if (!AppState._actions) AppState._actions = {};
AppState._actions[name] = handler;
}
// =========================================================================
// Helpers
// =========================================================================
function esc(s) {
if (!s) return '';
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
function cssSafe(s) {
if (!s) return 'Other';
return s.replace(/&amp;/g, '&').replace(/[^a-zA-Z0-9]/g, '-');
}
function formatDate(iso) {
if (!iso) return '—';
try {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
+ ' ' + d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
} catch (e) { return iso; }
}
function timeAgo(iso) {
if (!iso) return '';
try {
const d = new Date(iso);
const now = new Date();
const diff = Math.floor((now - d) / 1000);
if (diff < 60) return 'just now';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
return Math.floor(diff / 86400) + 'd ago';
} catch (e) { return iso; }
}
// ═══════════════════════════════════════════════════════════════════════
// ADD ASSET TAB — 3 Modes: Barcode, OCR, Manual
// ═══════════════════════════════════════════════════════════════════════
let addAssetMode = 'barcode';
let stream = null;
let ocrStream = null;
let codeReader = null;
let scanningActive = false;
let currentScannedMachineId = null;
let barcodeDebounce = null;
let manualPhotoBlob = null;
let manKeysCounter = 0;
let settingsCache = {};
// ── Mode Switching ──────────────────────────────────────────────────────
function setAddAssetMode(mode) {
addAssetMode = mode;
document.querySelectorAll('.mode-toggle').forEach(b =>
b.classList.toggle('active', b.dataset.mode === mode));
document.querySelectorAll('.add-mode').forEach(p =>
p.classList.toggle('active', p.id === 'add' + mode.charAt(0).toUpperCase() + mode.slice(1) + 'Mode'));
// Stop any running scanners
stopScanning();
stopOcrCamera();
stopManualPhoto();
if (mode === 'barcode') {
startScanning();
}
}
// ── Shared: Load settings dropdowns ─────────────────────────────────────
async function loadSettingsCache() {
if (settingsCache._loaded) return; // already loaded successfully
try {
const [cats, makes, models, keyNames, keyTypes, badgeTypes, customers] = 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'),
api('/api/customers'),
]);
settingsCache.categories = cats || [];
settingsCache.makes = makes || [];
settingsCache.models = models || [];
settingsCache.keyNames = keyNames || [];
settingsCache.keyTypes = keyTypes || [];
settingsCache.badgeTypes = badgeTypes || [];
settingsCache.customers = customers || [];
settingsCache._loaded = true;
} catch (e) {
console.error('Failed to load settings:', e);
// Don't cache empty results — leave fields undefined so retries work
settingsCache._loaded = false;
}
}
function populateCategorySelect(selectId) {
const sel = document.getElementById(selectId);
if (!sel) return;
const cats = settingsCache.categories || [];
sel.innerHTML = '<option value="">Category</option>' +
cats.map(c => `<option value="${esc(c.name)}">${esc(c.name)}</option>`).join('');
}
function populateMakeSelect() {
const sel = document.getElementById('manMake');
if (!sel) return;
const makes = settingsCache.makes || [];
sel.innerHTML = '<option value="">Make</option>' +
makes.map(m => `<option value="${esc(m.id)}">${esc(m.name)}</option>`).join('');
}
async function loadModels() {
const makeId = document.getElementById('manMake').value;
const sel = document.getElementById('manModel');
sel.innerHTML = '<option value="">Model</option>';
if (!makeId) return;
const models = (settingsCache.models || []).filter(m => m.make_id == makeId);
sel.innerHTML += models.map(m => `<option value="${esc(m.id)}">${esc(m.name)}</option>`).join('');
}
function populateCustomerSelect() {
const sel = document.getElementById('manCustomer');
if (!sel) return;
const customers = settingsCache.customers || [];
sel.innerHTML = '<option value="">Customer</option>' +
customers.map(c => `<option value="${c.id}">${esc(c.name)}</option>`).join('');
}
async function loadManualLocations() {
const custId = document.getElementById('manCustomer').value;
const sel = document.getElementById('manLocation');
sel.innerHTML = '<option value="">Location</option>';
if (!custId) return;
try {
const locs = await api(`/api/locations?customer_id=${custId}`);
sel.innerHTML += (locs || []).map(l =>
`<option value="${l.id}">${esc(l.name)}</option>`).join('');
} catch (e) { /* ignore */ }
}
function loadBadgeChecklist() {
const container = document.getElementById('manBadgesList');
if (!container) return;
const badges = settingsCache.badgeTypes || [];
container.innerHTML = badges.map(b =>
`<label class="badge-check-item" onclick="toggleBadge(this)">
<input type="checkbox" value="${esc(b.name)}">${esc(b.name)}
</label>`
).join('');
}
function toggleBadge(el) {
el.classList.toggle('checked');
el.querySelector('input').checked = el.classList.contains('checked');
}
function getCheckedBadges() {
const checks = document.querySelectorAll('#manBadgesList .badge-check-item.checked input');
return Array.from(checks).map(c => c.value);
}
// ── Key Management ──────────────────────────────────────────────────────
function addKeyEntry(keyName = '', keyType = '') {
const list = document.getElementById('manKeysList');
const idx = manKeysCounter++;
const knOpts = (settingsCache.keyNames || []).map(k =>
`<option value="${esc(k.name)}" ${k.name === keyName ? 'selected' : ''}>${esc(k.name)}</option>`
).join('');
const ktOpts = (settingsCache.keyTypes || []).map(k =>
`<option value="${esc(k.name)}" ${k.name === keyType ? 'selected' : ''}>${esc(k.name)}</option>`
).join('');
const div = document.createElement('div');
div.className = 'key-entry';
div.id = 'keyEntry' + idx;
div.innerHTML = `
<select class="input-field" style="margin-bottom:0;">
<option value="">Key Name</option>${knOpts}
</select>
<select class="input-field" style="margin-bottom:0;">
<option value="">Type</option>${ktOpts}
</select>
<button class="key-remove" onclick="this.closest('.key-entry').remove()">✕</button>`;
list.appendChild(div);
}
function getKeysData() {
const entries = document.querySelectorAll('#manKeysList .key-entry');
const keys = [];
entries.forEach(e => {
const sels = e.querySelectorAll('select');
if (sels[0] && sels[0].value) {
keys.push({ key_name: sels[0].value, key_type: sels[1] ? sels[1].value : '' });
}
});
return keys;
}
// ═══════════════════════════════════════════════════════════════════════
// BARCODE MODE
// ═══════════════════════════════════════════════════════════════════════
async function startScanning() {
if (addAssetMode !== 'barcode') return;
if (scanningActive) return;
try {
setScanStatus('Starting camera...', 'working');
if (stream) {
stream.getTracks().forEach(t => t.stop());
stream = null;
}
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: 'environment' }, width: { ideal: 1920 }, height: { ideal: 1080 } },
audio: false,
});
const video = document.getElementById('cameraVideo');
if (!video) return;
video.srcObject = stream;
await video.play();
document.getElementById('cameraPlaceholder').style.display = 'none';
video.style.display = 'block';
scanningActive = true;
if (!codeReader) codeReader = new ZXing.BrowserMultiFormatReader();
setScanStatus('Point camera at a barcode', 'success');
codeReader.decodeFromVideoElement(video, (result, err) => {
if (result && scanningActive) {
const val = result.getText();
if (val && val !== currentScannedMachineId) {
handleBarcode(val.trim());
}
}
});
} catch (e) {
console.error('Camera error:', e);
setScanStatus('Camera failed: ' + e.message, 'error');
const ph = document.getElementById('cameraPlaceholder');
if (ph) ph.innerHTML = `<span class="cam-icon">📷</span><div class="cam-hint" style="margin-top:4px;">Camera: ${e.message}</div>`;
}
}
function stopScanning() {
scanningActive = false;
if (codeReader) {
try { codeReader.reset(); } catch (e) { /* ignore */ }
}
if (stream) {
stream.getTracks().forEach(t => t.stop());
stream = null;
}
const video = document.getElementById('cameraVideo');
if (video) video.style.display = 'none';
const ph = document.getElementById('cameraPlaceholder');
if (ph) {
ph.style.display = '';
ph.innerHTML = '<span class="cam-icon">📷</span><div class="cam-hint" style="margin-top:4px;">Tap to start camera</div>';
}
}
if (document.getElementById('cameraPlaceholder')) {
document.getElementById('cameraPlaceholder').addEventListener('click', startScanning);
}
function setScanStatus(msg, cls = '') {
const el = document.getElementById('scanStatus');
if (el) { el.textContent = msg; el.className = 'status-bar ' + cls; }
}
async function handleBarcode(machineId) {
if (barcodeDebounce) return;
barcodeDebounce = machineId;
currentScannedMachineId = machineId;
setScanStatus(`Scanned: ${machineId} — looking up...`, 'working');
document.getElementById('scanResult').style.display = 'none';
document.getElementById('newAssetCard').style.display = 'none';
document.getElementById('checkinCard').style.display = 'none';
try {
const asset = await api(`/api/assets/search?machine_id=${encodeURIComponent(machineId)}`);
showScannedAsset(asset);
} catch (e) {
if (e.message === 'Asset not found' || e.message.includes('404')) {
showNewAssetForm(machineId);
} else {
setScanStatus('Lookup error: ' + e.message, 'error');
}
}
setTimeout(() => { barcodeDebounce = null; }, 2000);
}
function showScannedAsset(asset) {
AppState.currentAssetId = asset.id;
setScanStatus('Asset found!', 'success');
const el = document.getElementById('scanResult');
el.style.display = 'block';
el.innerHTML = `
<div class="sr-name">${esc(asset.name)}</div>
<div class="sr-meta">
${esc(asset.machine_id)} · ${esc(asset.category || 'Other')} ·
<span class="status-tag ${asset.status}">${asset.status || 'active'}</span>
</div>
<div class="sr-actions">
<button class="btn btn-green btn-sm" onclick="showCheckinForm()" style="flex:1;">✓ Check In</button>
<button class="btn btn-outline btn-sm" onclick="switchTab('tabAssets');viewAsset(${asset.id});" style="flex:1;">View Details</button>
</div>
`;
document.getElementById('checkinCard').style.display = 'block';
}
function showNewAssetForm(machineId) {
currentScannedMachineId = machineId;
setScanStatus('Machine ID not in database — create asset below', 'error');
document.getElementById('newMachineId').value = machineId;
document.getElementById('newName').value = '';
document.getElementById('newDesc').value = '';
populateCategorySelect('newCatSelect');
document.getElementById('newCatSelect').value = '';
document.getElementById('newStatus').value = 'active';
document.getElementById('newAssetCard').style.display = 'block';
document.getElementById('checkinCard').style.display = 'none';
document.getElementById('scanResult').style.display = 'none';
document.getElementById('newName').focus();
}
function showCheckinForm() {
document.getElementById('checkinNotes').value = '';
document.getElementById('checkinNotes').focus();
}
async function createScannedAsset() {
const machineId = document.getElementById('newMachineId').value.trim();
const name = document.getElementById('newName').value.trim();
if (!machineId || !name) {
showToast('Machine ID and name are required', true);
return;
}
const payload = {
machine_id: machineId, name,
description: document.getElementById('newDesc').value.trim(),
category: document.getElementById('newCatSelect').value || 'Other',
status: document.getElementById('newStatus').value,
};
try {
const asset = await api('/api/assets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
showToast('Asset created!');
document.getElementById('newAssetCard').style.display = 'none';
AppState.currentAssetId = asset.id;
showScannedAsset(asset);
} catch (e) {
showToast(e.message, true);
}
}
async function submitCheckin() {
if (!AppState.currentAssetId) {
showToast('No asset selected', true);
return;
}
const payload = {
asset_id: AppState.currentAssetId,
latitude: AppState.gpsLat,
longitude: AppState.gpsLng,
accuracy: AppState.gpsAcc,
notes: document.getElementById('checkinNotes').value.trim(),
};
try {
await api('/api/checkins', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
showToast('Checked in!');
document.getElementById('checkinCard').style.display = 'none';
document.getElementById('scanResult').style.display = 'none';
setScanStatus('Ready — scan another barcode', 'success');
} catch (e) {
showToast(e.message, true);
}
}
// ═══════════════════════════════════════════════════════════════════════
// OCR MODE
// ═══════════════════════════════════════════════════════════════════════
async function startOcrCamera() {
if (ocrStream) return;
try {
setOcrStatus('Starting camera...', 'working');
ocrStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: 'environment' }, width: { ideal: 1920 }, height: { ideal: 1080 } },
audio: false,
});
const video = document.getElementById('ocrVideo');
if (!video) return;
video.srcObject = ocrStream;
await video.play();
document.getElementById('ocrPlaceholder').style.display = 'none';
video.style.display = 'block';
document.getElementById('ocrCaptureOverlay').style.display = 'flex';
setOcrStatus('Point camera at the machine sticker and tap capture', 'success');
} catch (e) {
setOcrStatus('Camera failed: ' + e.message, 'error');
}
}
function stopOcrCamera() {
if (ocrStream) {
ocrStream.getTracks().forEach(t => t.stop());
ocrStream = null;
}
const video = document.getElementById('ocrVideo');
if (video) video.style.display = 'none';
const ph = document.getElementById('ocrPlaceholder');
if (ph) ph.style.display = '';
const overlay = document.getElementById('ocrCaptureOverlay');
if (overlay) overlay.style.display = 'none';
}
function setOcrStatus(msg, cls = '') {
const el = document.getElementById('ocrStatus');
if (el) { el.textContent = msg; el.className = 'status-bar ' + cls; }
}
async function captureOcr() {
const video = document.getElementById('ocrVideo');
if (!video || !ocrStream) return;
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext('2d').drawImage(video, 0, 0);
const blob = await new Promise(r => canvas.toBlob(r, 'image/jpeg', 0.85));
setOcrStatus('Processing OCR...', 'working');
document.getElementById('ocrResult').style.display = 'none';
document.getElementById('ocrCreateCard').style.display = 'none';
try {
const formData = new FormData();
formData.append('file', blob, 'sticker.jpg');
const data = await api('/api/ocr', { method: 'POST', body: formData });
const el = document.getElementById('ocrResult');
el.style.display = 'block';
if (data.machine_id) {
const confLabel = data.confidence === 'high' ? '✅ High confidence' :
data.confidence === 'low' ? '⚠️ Low confidence' : '';
el.innerHTML = `
<div class="or-id">${esc(data.machine_id)}</div>
<div class="or-meta">${confLabel}</div>
${data.raw_text ? `<div class="or-raw">OCR text: ${esc(data.raw_text)}</div>` : ''}`;
setOcrStatus('Machine ID found!', 'success');
// Show quick create form
document.getElementById('ocrMachineId').value = data.machine_id;
document.getElementById('ocrName').value = '';
populateCategorySelect('ocrCatSelect');
document.getElementById('ocrStatus').value = 'active';
document.getElementById('ocrCreateCard').style.display = 'block';
document.getElementById('ocrName').focus();
} else {
el.innerHTML = `
<div style="font-size:16px;font-weight:600;color:var(--red);">No machine ID detected</div>
<div class="or-meta">Try again with better lighting and focus on the sticker.</div>
${data.raw_text ? `<div class="or-raw">OCR text: ${esc(data.raw_text)}</div>` : ''}`;
setOcrStatus('No machine ID found — try again', 'error');
}
} catch (e) {
setOcrStatus('OCR failed: ' + e.message, 'error');
}
}
async function createOcrAsset() {
const machineId = document.getElementById('ocrMachineId').value.trim();
const name = document.getElementById('ocrName').value.trim();
if (!machineId || !name) {
showToast('Machine ID and name are required', true);
return;
}
try {
const asset = await api('/api/assets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
machine_id: machineId, name,
category: document.getElementById('ocrCatSelect').value || 'Other',
status: document.getElementById('ocrStatus').value,
}),
});
showToast('Asset created!');
document.getElementById('ocrCreateCard').style.display = 'none';
document.getElementById('ocrResult').style.display = 'none';
setOcrStatus('Ready — take another photo', 'success');
AppState.currentAssetId = asset.id;
// Show check-in option
document.getElementById('checkinCard').style.display = 'block';
} catch (e) {
showToast(e.message, true);
}
}
// ═══════════════════════════════════════════════════════════════════════
// MANUAL MODE — Photo
// ═══════════════════════════════════════════════════════════════════════
let manualPhotoStream = null;
async function startManualPhoto() {
if (manualPhotoStream) return;
try {
manualPhotoStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: 'environment' }, width: { ideal: 1920 }, height: { ideal: 1080 } },
audio: false,
});
const video = document.getElementById('manPhotoVideo');
if (!video) return;
video.srcObject = manualPhotoStream;
await video.play();
document.getElementById('manPhotoPlaceholder').style.display = 'none';
video.style.display = 'block';
document.getElementById('manPhotoCaptureRow').style.display = 'flex';
} catch (e) {
showToast('Camera failed: ' + e.message, true);
}
}
async function captureManualPhoto() {
const video = document.getElementById('manPhotoVideo');
if (!video || !manualPhotoStream) return;
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext('2d').drawImage(video, 0, 0);
manualPhotoBlob = await new Promise(r => canvas.toBlob(r, 'image/jpeg', 0.85));
const url = URL.createObjectURL(manualPhotoBlob);
document.getElementById('manPhotoPreview').src = url;
document.getElementById('manPhotoPreview').style.display = 'block';
document.getElementById('manPhotoRetake').style.display = 'block';
stopManualPhoto();
}
function retakeManualPhoto() {
manualPhotoBlob = null;
document.getElementById('manPhotoPreview').style.display = 'none';
document.getElementById('manPhotoRetake').style.display = 'none';
startManualPhoto();
}
function stopManualPhoto() {
if (manualPhotoStream) {
manualPhotoStream.getTracks().forEach(t => t.stop());
manualPhotoStream = null;
}
const video = document.getElementById('manPhotoVideo');
if (video) video.style.display = 'none';
const ph = document.getElementById('manPhotoPlaceholder');
if (ph) ph.style.display = '';
const row = document.getElementById('manPhotoCaptureRow');
if (row) row.style.display = 'none';
}
// ═══════════════════════════════════════════════════════════════════════
// MANUAL MODE — Submit
// ═══════════════════════════════════════════════════════════════════════
async function submitManualAsset(addAnother) {
const machineId = document.getElementById('manMachineId').value.trim();
const name = document.getElementById('manName').value.trim();
if (!machineId || !name) {
showToast('Machine ID and Asset Name are required', true);
return;
}
let photoPath = null;
if (manualPhotoBlob) {
try {
const fd = new FormData();
fd.append('file', manualPhotoBlob, 'photo.jpg');
const upData = await api('/api/upload/photo', { method: 'POST', body: fd });
photoPath = upData.path;
} catch (e) { /* photo upload failed, continue without */ }
}
const makeId = document.getElementById('manMake').value;
const modelId = document.getElementById('manModel').value;
const makeName = makeId ? (settingsCache.makes.find(m => m.id == makeId) || {}).name || '' : '';
const modelName = modelId ? (settingsCache.models.find(m => m.id == modelId) || {}).name || '' : '';
const payload = {
machine_id: machineId,
name: name,
serial_number: document.getElementById('manSerialNumber').value.trim(),
description: document.getElementById('manDescription').value.trim(),
category: document.getElementById('manCatSelect').value || 'Other',
make: makeName,
model: modelName,
status: document.getElementById('manStatus').value,
address: document.getElementById('manAddress').value.trim(),
building_name: document.getElementById('manBuildingName').value.trim(),
building_number: document.getElementById('manBuildingNumber').value.trim(),
floor: document.getElementById('manFloor').value.trim(),
room: document.getElementById('manRoom').value.trim(),
trailer_number: document.getElementById('manAddress').value.trim(),
walking_directions: document.getElementById('manWalkingDirections').value.trim(),
map_link: document.getElementById('manMapLink').value.trim(),
parking_location: document.getElementById('manParkingLocation').value.trim(),
customer_id: parseInt(document.getElementById('manCustomer').value) || null,
location_id: parseInt(document.getElementById('manLocation').value) || null,
keys: getKeysData(),
badges: getCheckedBadges(),
};
if (photoPath) payload.photo_path = photoPath;
try {
const asset = await api('/api/assets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
showToast('Asset created!');
AppState.currentAssetId = asset.id;
if (addAnother) {
// Clear form
document.getElementById('manMachineId').value = '';
document.getElementById('manSerialNumber').value = '';
document.getElementById('manName').value = '';
document.getElementById('manDescription').value = '';
document.getElementById('manCatSelect').value = '';
document.getElementById('manMake').value = '';
document.getElementById('manModel').value = '';
document.getElementById('manStatus').value = 'active';
document.getElementById('manAddress').value = '';
document.getElementById('manBuildingName').value = '';
document.getElementById('manBuildingNumber').value = '';
document.getElementById('manFloor').value = '';
document.getElementById('manRoom').value = '';
document.getElementById('manWalkingDirections').value = '';
document.getElementById('manMapLink').value = '';
document.getElementById('manParkingLocation').value = '';
document.getElementById('manCustomer').value = '';
document.getElementById('manLocation').value = '';
// Reset keys
document.getElementById('manKeysList').innerHTML = '';
// Reset badges
document.querySelectorAll('#manBadgesList .badge-check-item').forEach(b => b.classList.remove('checked'));
// Reset photo
manualPhotoBlob = null;
document.getElementById('manPhotoPreview').style.display = 'none';
document.getElementById('manPhotoRetake').style.display = 'none';
document.getElementById('manMachineId').focus();
} else {
// Show check-in card
document.getElementById('manCheckinCard').style.display = 'block';
document.getElementById('manCheckinNotes').focus();
document.getElementById('manCheckinCard').scrollIntoView({ behavior: 'smooth' });
}
} catch (e) {
showToast(e.message, true);
}
}
async function quickCheckinManual() {
if (!AppState.currentAssetId) {
showToast('No asset selected', true);
return;
}
try {
await api('/api/checkins', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
asset_id: AppState.currentAssetId,
latitude: AppState.gpsLat,
longitude: AppState.gpsLng,
accuracy: AppState.gpsAcc,
notes: document.getElementById('manCheckinNotes').value.trim(),
}),
});
showToast('Checked in!');
document.getElementById('manCheckinCard').style.display = 'none';
} catch (e) {
showToast(e.message, true);
}
}
function openMapPin() {
const link = document.getElementById('manMapLink').value.trim();
if (link) window.open(link, '_blank');
else showToast('Enter a map link first', true);
}
// Settings are loaded after auth (in doLogin / initAuth)
// ═══════════════════════════════════════════════════════════════════════
// ASSETS TAB — List, Filter, Detail, Edit, Import
// ═══════════════════════════════════════════════════════════════════════
// ── Category config ───────────────────────────────────────────────────
const CATEGORY_MAP = {
'Coffee': { icon: '☕', color: '#2e1a0a' },
'Cold Beverage': { icon: '🥤', color: '#0a1a2e' },
'Snacks': { icon: '🍿', color: '#2a2010' },
'Cold Food': { icon: '🥪', color: '#1a2e1a' },
'Market': { icon: '🏪', color: '#2e1a2e' },
'Smart Cooler': { icon: '🧊', color: '#1a2e2e' },
};
function catIcon(cat) {
const entry = CATEGORY_MAP[cat];
if (entry) return entry.icon;
if (cat) return cat.charAt(0).toUpperCase();
return '📦';
}
// ── Filter state ──────────────────────────────────────────────────────
let assetFilters = { category: null, status: null, make: null, q: '' };
let assetDebounce = null;
let cachedAssets = [];
async function loadAssets() {
const q = document.getElementById('assetSearch').value.trim();
assetFilters.q = q;
const clearBtn = document.getElementById('clearSearch');
if (clearBtn) clearBtn.style.display = q ? 'block' : 'none';
clearTimeout(assetDebounce);
assetDebounce = setTimeout(_loadAssets, 200);
}
async function _loadAssets() {
const params = new URLSearchParams();
if (assetFilters.category) params.set('category', assetFilters.category);
if (assetFilters.status) params.set('status', assetFilters.status);
if (assetFilters.make) params.set('make', assetFilters.make);
if (assetFilters.q) params.set('q', assetFilters.q);
params.set('limit', '100');
try {
const assets = await api('/api/assets?' + params.toString());
cachedAssets = assets;
renderAssetList(assets);
renderFilterPills(assets);
} catch (e) {
document.getElementById('assetList').innerHTML =
'<div class="empty-state">Failed to load assets</div>';
}
}
// _loadAssets patched by Phase M to support assigned_to filter
var __loadAssets_orig = _loadAssets;
_loadAssets = async function() {
var params = new URLSearchParams();
if (assetFilters.category) params.set('category', assetFilters.category);
if (assetFilters.status) params.set('status', assetFilters.status);
if (assetFilters.q) params.set('q', assetFilters.q);
if (assetFilters.assigned_to) params.set('assigned_to', assetFilters.assigned_to);
params.set('limit', '100');
try {
var assets = await api('/api/assets?' + params.toString());
// Client-side filter fallback if server doesn't support assigned_to
var filtered = assetFilters.assigned_to
? assets.filter(function(a) { return a.assigned_to === assetFilters.assigned_to; })
: assets;
renderAssetList(filtered);
renderFilterPills(assets);
} catch (e) {
document.getElementById('assetList').innerHTML =
'<div class="empty-state">Failed to load assets</div>';
}
};
function renderAssetList(assets) {
const el = document.getElementById('assetList');
if (!assets.length) {
el.innerHTML = '<div class="empty-state"><div class="es-icon">📦</div>No assets found</div>';
return;
}
el.innerHTML = assets.map(a => {
const icon = catIcon(a.category);
const catClass = a.category ? 'cat-' + a.category.replace(/[^a-zA-Z0-9]/g, '-') : '';
return `
<div class="asset-item" onclick="viewAsset(${a.id})">
<div class="ai-icon ${catClass}">${icon}</div>
<div class="ai-info">
<div class="ai-name">${esc(a.name)}</div>
<div class="ai-meta">
${esc(a.machine_id)}${a.serial_number ? ' · S/N: ' + esc(a.serial_number) : ''}
${a.make ? ' · ' + esc(a.make) : ''}
· <span class="status-tag ${a.status || 'active'}">${a.status || 'active'}</span>
</div>
</div>
<div class="ai-arrow"></div>
</div>`;
}).join('');
}
function renderFilterPills(assets) {
const cats = new Set(), stats = new Set(), makes = new Set();
assets.forEach(a => {
if (a.category) cats.add(a.category);
if (a.status) stats.add(a.status);
if (a.make) makes.add(a.make);
});
let html = '<span class="pill' + (!assetFilters.category && !assetFilters.status && !assetFilters.make ? ' active' : '') + '" onclick="setFilter(null,null,null)">All</span>';
// Category pills
[...cats].sort().forEach(c => {
html += `<span class="pill${assetFilters.category === c ? ' active' : ''}" onclick="setFilter('${esc(c)}',null,null)">📂 ${esc(c)}</span>`;
});
// Status pills
[...stats].sort().forEach(s => {
html += `<span class="pill${assetFilters.status === s ? ' active' : ''}" onclick="setFilter(null,'${esc(s)}',null)">${s === 'active' ? '🟢' : s === 'maintenance' ? '🟡' : '🔴'} ${s}</span>`;
});
// Make pills
[...makes].sort().forEach(m => {
html += `<span class="pill${assetFilters.make === m ? ' active' : ''}" onclick="setFilter(null,null,'${esc(m)}')">🏭 ${esc(m)}</span>`;
});
document.getElementById('filterPills').innerHTML = html;
}
function setFilter(category, status, make) {
assetFilters.category = category;
assetFilters.status = status;
assetFilters.make = make;
loadAssets();
}
function clearAssetSearch() {
document.getElementById('assetSearch').value = '';
loadAssets();
}
// ── View switching ────────────────────────────────────────────────────
function hideAllAssetViews() {
['assetsListView','assetsDetailView','assetsEditView','assetsImportView'].forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
});
}
function showAssetList() {
hideAllAssetViews();
document.getElementById('assetsListView').style.display = 'block';
loadAssets();
}
function showDetailView() {
hideAllAssetViews();
document.getElementById('assetsDetailView').style.display = 'block';
}
function showEditView() {
hideAllAssetViews();
document.getElementById('assetsEditView').style.display = 'block';
}
function showImportView() {
hideAllAssetViews();
document.getElementById('assetsImportView').style.display = 'block';
// Reset import state
document.getElementById('importFileInput').value = '';
document.getElementById('importPreview').style.display = 'none';
document.getElementById('importResults').style.display = 'none';
document.getElementById('importRunBtn').disabled = true;
}
// ── Detail view ───────────────────────────────────────────────────────
function nvl(v, fallback) { return (v !== null && v !== undefined && v !== '') ? v : fallback; }
async function viewAsset(id) {
AppState.currentAssetId = id;
try {
const a = await api('/api/assets/' + id);
showDetailView();
document.getElementById('detailName').textContent = a.name;
document.getElementById('detailMeta').innerHTML =
`${esc(a.machine_id)} · <span class="status-tag ${a.status || 'active'}">${a.status || 'active'}</span>`;
// Main details
document.getElementById('detailFields').innerHTML = [
df('Machine ID', a.machine_id, true),
df('Name', a.name),
df('Serial Number', a.serial_number, true),
df('Description', a.description),
df('Category', a.category),
df('Make', a.make),
df('Model', a.model),
df('Status', statusBadge(a.status)),
].filter(Boolean).join('');
// Customer / Location / Technician
if (a.customer_name) {
document.getElementById('detailFields').innerHTML += df('Customer',
`<span class="df-link" onclick="switchTab('tabCustomers')">${esc(a.customer_name)}</span>`);
}
if (a.location_name) {
document.getElementById('detailFields').innerHTML += df('Location',
`<span class="df-link" onclick="switchTab('tabCustomers')">${esc(a.location_name)}</span>`);
}
if (a.assigned_to) {
var techName = a.assigned_user || ('ID: ' + a.assigned_to);
document.getElementById('detailFields').innerHTML += df('Assigned Technician', '👤 ' + esc(techName));
}
// Timestamps
document.getElementById('detailFields').innerHTML +=
df('Created', formatDate(a.created_at)) +
df('Updated', formatDate(a.updated_at));
// Directions
const hasDir = a.address || a.building_name || a.building_number || a.floor || a.room || a.walking_directions || a.map_link || a.parking_location;
const dirCard = document.getElementById('detailDirections');
const dirBtn = document.getElementById('detailDirectionsBtn');
if (hasDir) {
dirCard.style.display = 'block';
if (a.map_link) dirBtn.style.display = 'inline-flex';
else dirBtn.style.display = 'none';
document.getElementById('detailDirFields').innerHTML = [
df('Address', a.address),
df('Building', a.building_name),
df('Building #', a.building_number),
df('Floor', a.floor),
df('Room', a.room),
df('Walking Directions', a.walking_directions),
df('Map Link', a.map_link ? `<span class="df-link" onclick="openMapLink()">${esc(a.map_link)}</span>` : null),
df('Parking', a.parking_location),
].filter(Boolean).join('');
} else {
dirCard.style.display = 'none';
dirBtn.style.display = 'none';
}
// Keys + Badges
const keys = a.keys || [];
const badges = a.badges || (Array.isArray(a.badge_names) ? a.badge_names : []);
const accessCard = document.getElementById('detailAccess');
if (keys.length || (typeof badges === 'string' ? badges : (badges || [])).length) {
accessCard.style.display = 'block';
let accHtml = '';
if (keys.length) {
accHtml += '<div class="detail-field"><div class="df-label">Keys</div>';
accHtml += '<div class="key-badge-list">' +
keys.map(k => `<span class="key-tag">🔑 ${esc(k.key_name)}${k.key_type ? ' (' + esc(k.key_type) + ')' : ''}</span>`).join('') +
'</div></div>';
}
const badgeList = Array.isArray(badges) ? badges : (typeof badges === 'string' ? badges.split(',').map(s => s.trim()).filter(Boolean) : []);
if (badgeList.length) {
accHtml += '<div class="detail-field"><div class="df-label">Security Badges</div>';
accHtml += '<div class="key-badge-list">' +
badgeList.map(b => `<span class="badge-tag">🪪 ${esc(typeof b === 'string' ? b : b.badge_name || b)}</span>`).join('') +
'</div></div>';
}
document.getElementById('detailAccessFields').innerHTML = accHtml;
} else {
accessCard.style.display = 'none';
}
await loadCheckinHistory(id);
} catch (e) {
showToast(e.message, true);
showAssetList();
}
}
function df(label, value, mono) {
if (!value && value !== 0) return '';
const cls = mono ? ' style="font-family:monospace;"' : '';
return `<div class="detail-field"><div class="df-label">${esc(label)}</div><div class="df-value"${cls}>${value}</div></div>`;
}
function statusBadge(status) {
return `<span class="status-tag ${status || 'active'}">${status || 'active'}</span>`;
}
function openMapLink() {
// Fetch current asset to get map_link
if (!AppState.currentAssetId) return;
api('/api/assets/' + AppState.currentAssetId).then(a => {
if (a.map_link) window.open(a.map_link, '_blank');
}).catch(() => {});
}
async function quickCheckin() {
if (!AppState.currentAssetId) return;
const payload = {
asset_id: AppState.currentAssetId,
latitude: AppState.gpsLat,
longitude: AppState.gpsLng,
accuracy: AppState.gpsAcc,
notes: '',
};
try {
await api('/api/checkins', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
showToast('Checked in!');
loadCheckinHistory(AppState.currentAssetId);
} catch (e) {
showToast(e.message, true);
}
}
// ── Check-in history ──────────────────────────────────────────────────
async function loadCheckinHistory(assetId) {
try {
const checkins = await api('/api/checkins?asset_id=' + assetId + '&limit=50');
document.getElementById('checkinCount').textContent = checkins.length ? '(' + checkins.length + ')' : '';
const el = document.getElementById('checkinHistory');
if (!checkins.length) {
el.innerHTML = '<div class="empty-state">No check-ins yet</div>';
return;
}
el.innerHTML = checkins.map(c => `
<div class="checkin-item">
<div class="ci-time">🕐 ${formatDate(c.created_at)}</div>
${c.latitude ? '<div class="ci-coords">📍 ' + Number(c.latitude).toFixed(5) + ', ' + Number(c.longitude).toFixed(5) + ' ±' + Math.round(c.accuracy||0) + 'm</div>' : ''}
${c.notes ? '<div class="ci-notes">📝 ' + esc(c.notes) + '</div>' : ''}
</div>
`).join('');
} catch (e) {
document.getElementById('checkinHistory').innerHTML =
'<div class="empty-state">Failed to load check-ins</div>';
}
}
// ── Edit ──────────────────────────────────────────────────────────────
async function editAsset() {
if (!AppState.currentAssetId) return;
try {
const a = await api('/api/assets/' + AppState.currentAssetId);
showEditView();
document.getElementById('editFormCard').innerHTML = renderEditForm(a);
// Phase M: set assigned_to value after DOM is ready
setTimeout(function() {
var sel = document.getElementById('editAssignedTo');
if (sel && a.assigned_to) sel.value = a.assigned_to;
}, 0);
loadTechnicianOptions();
} catch (e) {
showToast(e.message, true);
}
}
function renderEditForm(a) {
return `
<div class="card-title">Edit Asset #${a.id}</div>
<input id="editMachineId" type="text" class="input-field" placeholder="Machine ID *" value="${esc(a.machine_id)}">
<input id="editName" type="text" class="input-field" placeholder="Asset name *" value="${esc(a.name)}">
<input id="editSerialNumber" type="text" class="input-field" placeholder="Serial number" value="${esc(a.serial_number || '')}">
<textarea id="editDesc" class="input-field" placeholder="Description" rows="2">${esc(a.description || '')}</textarea>
<div class="form-row">
<input id="editCategory" type="text" class="input-field" placeholder="Category" value="${esc(a.category || '')}" list="catList">
<input id="editMake" type="text" class="input-field" placeholder="Make" value="${esc(a.make || '')}">
</div>
<div class="form-row">
<input id="editModel" type="text" class="input-field" placeholder="Model" value="${esc(a.model || '')}">
<select id="editStatus" class="input-field">
<option value="active" ${a.status === 'active' ? 'selected' : ''}>Active</option>
<option value="maintenance" ${a.status === 'maintenance' ? 'selected' : ''}>Maintenance</option>
<option value="retired" ${a.status === 'retired' ? 'selected' : ''}>Retired</option>
</select>
</div>
<select id="editAssignedTo" class="input-field">
<option value="">Assigned To (none)</option>
</select>
<div class="card-title" style="margin-top:8px;">📍 Directions</div>
<input id="editAddress" type="text" class="input-field" placeholder="Address" value="${esc(a.address || '')}">
<div class="form-row">
<input id="editBuildingName" type="text" class="input-field" placeholder="Building name" value="${esc(a.building_name || '')}">
<input id="editBuildingNumber" type="text" class="input-field" placeholder="Building #" value="${esc(a.building_number || '')}">
</div>
<div class="form-row">
<input id="editFloor" type="text" class="input-field" placeholder="Floor" value="${esc(a.floor || '')}">
<input id="editRoom" type="text" class="input-field" placeholder="Room" value="${esc(a.room || '')}">
</div>
<input id="editWalkingDirections" type="text" class="input-field" placeholder="Walking directions" value="${esc(a.walking_directions || '')}">
<input id="editMapLink" type="text" class="input-field" placeholder="Map link (URL)" value="${esc(a.map_link || '')}">
<input id="editParking" type="text" class="input-field" placeholder="Parking location" value="${esc(a.parking_location || '')}">
<datalist id="catList">
<option value="Coffee"><option value="Cold Beverage"><option value="Snacks">
<option value="Cold Food"><option value="Market"><option value="Smart Cooler">
</datalist>
<button class="btn btn-primary" onclick="submitEditAsset()">Save Changes</button>
`;
}
async function submitEditAsset() {
if (!AppState.currentAssetId) return;
const machine_id = document.getElementById('editMachineId').value.trim();
const name = document.getElementById('editName').value.trim();
if (!machine_id || !name) {
showToast('Machine ID and name are required', true);
return;
}
const payload = {
machine_id, name,
serial_number: document.getElementById('editSerialNumber').value.trim() || null,
description: document.getElementById('editDesc').value.trim() || null,
category: document.getElementById('editCategory').value.trim() || null,
make: document.getElementById('editMake').value.trim() || null,
model: document.getElementById('editModel').value.trim() || null,
status: document.getElementById('editStatus').value,
address: document.getElementById('editAddress').value.trim() || null,
building_name: document.getElementById('editBuildingName').value.trim() || null,
building_number: document.getElementById('editBuildingNumber').value.trim() || null,
floor: document.getElementById('editFloor').value.trim() || null,
room: document.getElementById('editRoom').value.trim() || null,
walking_directions: document.getElementById('editWalkingDirections').value.trim() || null,
map_link: document.getElementById('editMapLink').value.trim() || null,
parking_location: document.getElementById('editParking').value.trim() || null,
assigned_to: document.getElementById('editAssignedTo').value ? parseInt(document.getElementById('editAssignedTo').value) : null,
};
try {
await api('/api/assets/' + AppState.currentAssetId, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
showToast('Asset updated!');
viewAsset(AppState.currentAssetId);
} catch (e) {
showToast(e.message, true);
}
}
// ── Delete ────────────────────────────────────────────────────────────
async function deleteAsset() {
if (!AppState.currentAssetId) return;
const confirmed = await showModal(
'Delete Asset',
'Delete this asset and all its check-ins? This cannot be undone.',
'Delete'
);
if (!confirmed) return;
try {
await api('/api/assets/' + AppState.currentAssetId, { method: 'DELETE' });
showToast('Asset deleted');
showAssetList();
} catch (e) {
showToast(e.message, true);
}
}
// ── CSV Import ────────────────────────────────────────────────────────
let importRows = [];
document.getElementById('importFileInput').addEventListener('change', function(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(ev) {
const text = ev.target.result;
const lines = text.split('\n').filter(l => l.trim());
if (lines.length < 2) {
showToast('CSV must have a header row and at least one data row', true);
return;
}
// Parse header
const header = lines[0].split(',').map(h => h.trim().toLowerCase());
const idx = {
machine_id: header.indexOf('machine_id'),
name: header.indexOf('name'),
category: header.indexOf('category'),
make: header.indexOf('make'),
model: header.indexOf('model'),
serial_number: header.indexOf('serial_number'),
customer: header.indexOf('customer'),
location: header.indexOf('location'),
};
if (idx.machine_id < 0 || idx.name < 0) {
showToast('CSV must have at least machine_id and name columns', true);
return;
}
importRows = [];
for (let i = 1; i < lines.length; i++) {
const cols = lines[i].split(',').map(c => c.trim());
const row = {
machine_id: cols[idx.machine_id] || '',
name: cols[idx.name] || '',
category: idx.category >= 0 ? cols[idx.category] || '' : '',
make: idx.make >= 0 ? cols[idx.make] || '' : '',
model: idx.model >= 0 ? cols[idx.model] || '' : '',
serial_number: idx.serial_number >= 0 ? cols[idx.serial_number] || '' : '',
customer_name: idx.customer >= 0 ? cols[idx.customer] || '' : '',
location_name: idx.location >= 0 ? cols[idx.location] || '' : '',
};
if (row.machine_id && row.name) importRows.push(row);
}
if (!importRows.length) {
showToast('No valid rows found (need machine_id + name)', true);
return;
}
// Show preview
document.getElementById('importRowCount').textContent = importRows.length + ' rows ready to import';
document.getElementById('importPreview').style.display = 'block';
document.getElementById('importRunBtn').disabled = false;
// Show preview table (first 10 rows)
const displayKeys = ['machine_id','name','category','make','model'];
const thead = '<tr>' + displayKeys.map(k => '<th>' + k + '</th>').join('') + '</tr>';
const tbody = importRows.slice(0, 10).map(r =>
'<tr>' + displayKeys.map(k => '<td>' + esc(r[k] || '') + '</td>').join('') + '</tr>'
).join('');
if (importRows.length > 10) {
document.getElementById('importTable').innerHTML =
thead + '<tfoot style="font-size:11px;color:var(--text2);"><tr><td colspan="' + displayKeys.length + '">... and ' + (importRows.length - 10) + ' more rows</td></tr></tfoot>' + tbody;
} else {
document.getElementById('importTable').innerHTML = thead + tbody;
}
};
reader.readAsText(file);
});
async function runImport() {
if (!importRows.length) return;
const btn = document.getElementById('importRunBtn');
btn.disabled = true;
btn.textContent = 'Importing...';
let created = 0, skipped = 0;
const errors = [];
for (let i = 0; i < importRows.length; i++) {
const r = importRows[i];
try {
await api('/api/assets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
machine_id: r.machine_id,
name: r.name,
category: r.category || 'Other',
make: r.make || '',
model: r.model || '',
serial_number: r.serial_number || '',
}),
});
created++;
} catch (e) {
if (e.message && e.message.includes('UNIQUE constraint')) {
skipped++;
} else {
errors.push('Row ' + (i + 2) + ' (' + r.machine_id + '): ' + e.message);
}
}
}
// Show results
const resEl = document.getElementById('importResults');
resEl.style.display = 'block';
let html = '<div class="import-results">';
html += '<span class="ir-ok">✓ Created: ' + created + '</span> &nbsp; ';
html += '<span class="ir-warn">⊘ Skipped (duplicate): ' + skipped + '</span> &nbsp; ';
html += '<span class="ir-err">✗ Errors: ' + errors.length + '</span>';
if (errors.length) {
html += '<div style="margin-top:6px;font-size:11px;max-height:120px;overflow-y:auto;">' +
errors.map(e => '<div class="ir-err">' + esc(e) + '</div>').join('') + '</div>';
}
html += '</div>';
resEl.innerHTML = html;
btn.textContent = 'Done';
btn.className = 'btn btn-outline btn-sm';
btn.onclick = () => showAssetList();
showToast('Import complete: ' + created + ' created, ' + skipped + ' skipped, ' + errors.length + ' errors');
}
// ═══════════════════════════════════════════════════════════════════════
// CUSTOMERS & LOCATIONS TAB
// ═══════════════════════════════════════════════════════════════════════
const CustState = {
view: 'list', // list | detail | form | locations | loc-detail | loc-form
custId: null,
locId: null,
allCusts: [],
allLocs: {}, // { custId: [locations] }
formMode: 'create', // 'create' | 'edit'
locFormMode: 'create', // 'create' | 'edit'
formContacts: [], // [{name, phone, email}]
};
// ── View management ───────────────────────────────────────────────────
function showCustView(name) {
const views = ['custListView','custDetailView','custFormView','locListView','locDetailView','locFormView'];
views.forEach(v => { const el = document.getElementById(v); if (el) el.style.display = 'none'; });
const active = document.getElementById(name);
if (active) active.style.display = 'block';
CustState.view = name === 'custListView' ? 'list'
: name === 'custDetailView' ? 'detail'
: name === 'custFormView' ? 'form'
: name === 'locListView' ? 'locations'
: name === 'locDetailView' ? 'loc-detail'
: name === 'locFormView' ? 'loc-form' : 'list';
}
// ── Customers List ────────────────────────────────────────────────────
async function loadCustomers() {
if (AppState.currentTab !== 'tabCustomers') return;
const q = (document.getElementById('custSearch').value || '').trim().toLowerCase();
const clearBtn = document.getElementById('clearCustSearch');
if (clearBtn) clearBtn.style.display = q ? 'block' : 'none';
try {
const custs = await api('/api/customers');
CustState.allCusts = custs;
// Fetch all locations to get counts
try {
const allLocs = await api('/api/locations');
CustState.allLocs = {};
allLocs.forEach(l => {
const cid = l.customer_id;
if (cid) {
if (!CustState.allLocs[cid]) CustState.allLocs[cid] = [];
CustState.allLocs[cid].push(l);
}
});
} catch (e) { CustState.allLocs = {}; }
renderCustList(q);
} catch (e) {
document.getElementById('custList').innerHTML =
'<div class="empty-state">Failed to load customers: ' + esc(e.message) + '</div>';
}
}
function renderCustList(query) {
let custs = CustState.allCusts;
if (query) {
custs = custs.filter(c => c.name.toLowerCase().includes(query));
}
const el = document.getElementById('custList');
if (!custs.length) {
el.innerHTML = '<div class="empty-state"><div class="es-icon">🏢</div>No customers found</div>';
return;
}
el.innerHTML = custs.map(c => {
const locCount = (CustState.allLocs[c.id] || []).length;
const firstContact = (c.contacts && c.contacts.length) ? c.contacts[0] : null;
const contactInfo = firstContact
? [firstContact.phone, firstContact.email].filter(Boolean).join(' · ') || firstContact.name
: '';
return `
<div class="cust-card" onclick="viewCustomer(${c.id})">
<div class="cc-icon">🏢</div>
<div class="cc-info">
<div class="cc-name">${esc(c.name)}</div>
<div class="cc-meta">
${locCount} location${locCount !== 1 ? 's' : ''}
${contactInfo ? ' · ' + esc(contactInfo) : ''}
</div>
</div>
<div class="cc-arrow"></div>
</div>`;
}).join('');
}
function clearCustSearch() {
document.getElementById('custSearch').value = '';
loadCustomers();
}
function showCustList() {
showCustView('custListView');
loadCustomers();
}
// ── Customer Detail ───────────────────────────────────────────────────
async function viewCustomer(id) {
CustState.custId = id;
try {
const cust = await api(`/api/customers/${id}`);
const locs = CustState.allLocs[id] || [];
showCustView('custDetailView');
document.getElementById('custDetailName').textContent = cust.name;
// Contacts
const contactsEl = document.getElementById('custContactsList');
if (!cust.contacts || !cust.contacts.length) {
contactsEl.innerHTML = '<div class="empty-state">No contacts added</div>';
} else {
contactsEl.innerHTML = cust.contacts.map(c => `
<div class="contact-disp">
<div class="cd-icon">👤</div>
<div class="cd-info">
<div class="cd-name">${esc(c.name || 'Unnamed')}</div>
<div class="cd-detail">
${c.phone ? `<a href="tel:${esc(c.phone)}">📞 ${esc(c.phone)}</a>` : ''}
${c.phone && c.email ? ' · ' : ''}
${c.email ? `<a href="mailto:${esc(c.email)}">📧 ${esc(c.email)}</a>` : ''}
</div>
</div>
</div>
`).join('');
}
// Locations
document.getElementById('custLocCount').textContent = locs.length ? `(${locs.length})` : '';
const locsEl = document.getElementById('custLocationsList');
if (!locs.length) {
locsEl.innerHTML = '<div class="empty-state">No locations for this customer</div>';
} else {
locsEl.innerHTML = locs.map(l => `
<div class="loc-card" onclick="event.stopPropagation();viewLocation(${l.id})">
<div class="lc-icon">📍</div>
<div class="lc-info">
<div class="lc-name">${esc(l.name)}</div>
<div class="lc-meta">${esc(l.address || '')}${l.building_name ? ' · ' + esc(l.building_name) : ''}</div>
</div>
<div class="lc-arrow"></div>
</div>
`).join('') +
`<button class="btn btn-outline btn-sm" onclick="event.stopPropagation();showLocFormForCust(${id})" style="width:100%;margin-top:8px;">+ Add Location</button>`;
}
} catch (e) {
showToast(e.message, true);
showCustList();
}
}
// ── Customer Form (Create/Edit) ───────────────────────────────────────
function showCustForm() {
CustState.formMode = 'create';
CustState.custId = null;
CustState.formContacts = [];
showCustView('custFormView');
document.getElementById('custFormTitle').textContent = 'New Customer';
document.getElementById('custFormName').value = '';
document.getElementById('custFormSaveBtn').textContent = 'Save Customer';
renderContactForm();
}
async function editCustomer() {
try {
const cust = await api(`/api/customers/${CustState.custId}`);
CustState.formMode = 'edit';
CustState.formContacts = (cust.contacts || []).map(c => ({ name: c.name || '', phone: c.phone || '', email: c.email || '' }));
showCustView('custFormView');
document.getElementById('custFormTitle').textContent = 'Edit Customer';
document.getElementById('custFormName').value = cust.name;
document.getElementById('custFormSaveBtn').textContent = 'Update Customer';
renderContactForm();
} catch (e) {
showToast(e.message, true);
}
}
function cancelCustForm() {
showCustView(CustState.formMode === 'edit' ? 'custDetailView' : 'custListView');
}
function renderContactForm() {
const el = document.getElementById('custFormContacts');
if (!CustState.formContacts.length) {
CustState.formContacts.push({ name: '', phone: '', email: '' });
}
el.innerHTML = CustState.formContacts.map((c, i) => `
<div class="contact-row">
${CustState.formContacts.length > 1 ? `<button class="cr-remove" onclick="removeContact(${i})">✕</button>` : ''}
<input type="text" class="input-field" placeholder="Contact name" value="${esc(c.name)}" oninput="updateContact(${i},'name',this.value)" style="margin-bottom:0;">
<div class="form-row" style="margin-bottom:0;">
<input type="tel" class="input-field" placeholder="Phone" value="${esc(c.phone)}" oninput="updateContact(${i},'phone',this.value)" style="margin-bottom:0;">
<input type="email" class="input-field" placeholder="Email" value="${esc(c.email)}" oninput="updateContact(${i},'email',this.value)" style="margin-bottom:0;">
</div>
</div>
`).join('');
}
function addContactRow() {
CustState.formContacts.push({ name: '', phone: '', email: '' });
renderContactForm();
}
function removeContact(i) {
if (CustState.formContacts.length <= 1) return;
CustState.formContacts.splice(i, 1);
renderContactForm();
}
function updateContact(i, field, value) {
CustState.formContacts[i][field] = value;
}
async function saveCustomer() {
const name = document.getElementById('custFormName').value.trim();
if (!name) { showToast('Customer name is required', true); return; }
const contacts = CustState.formContacts
.filter(c => c.name || c.phone || c.email)
.map(c => ({ name: c.name.trim(), phone: c.phone.trim(), email: c.email.trim() }));
const payload = { name, contacts };
try {
if (CustState.formMode === 'create') {
const cust = await api('/api/customers', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
showToast('Customer created!');
CustState.custId = cust.id;
await loadCustomers();
viewCustomer(cust.id);
} else {
await api(`/api/customers/${CustState.custId}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
showToast('Customer updated!');
await loadCustomers();
viewCustomer(CustState.custId);
}
} catch (e) {
showToast(e.message, true);
}
}
async function deleteCustomer() {
const cust = await api(`/api/customers/${CustState.custId}`);
const confirmed = await showModal(
'Delete Customer',
`Delete "${cust.name}" and all its locations? This cannot be undone.`,
'Delete'
);
if (!confirmed) return;
try {
await api(`/api/customers/${CustState.custId}`, { method: 'DELETE' });
showToast('Customer deleted');
showCustList();
} catch (e) {
showToast(e.message, true);
}
}
// ── Locations: List for a Customer ────────────────────────────────────
async function showLocationsForCust(custId) {
CustState.custId = custId;
try {
const cust = await api(`/api/customers/${custId}`);
const locs = CustState.allLocs[custId] || [];
showCustView('locListView');
document.getElementById('locListCustName').textContent = cust.name;
renderLocList(locs);
} catch (e) {
showToast(e.message, true);
}
}
function renderLocList(locs) {
const el = document.getElementById('locList');
if (!locs.length) {
el.innerHTML = '<div class="empty-state">No locations for this customer</div>';
return;
}
el.innerHTML = locs.map(l => `
<div class="loc-card" onclick="viewLocation(${l.id})">
<div class="lc-icon">📍</div>
<div class="lc-info">
<div class="lc-name">${esc(l.name)}</div>
<div class="lc-meta">${esc(l.address || '')}${l.building_name ? ' · ' + esc(l.building_name) : ''}${(l.rooms || []).length ? ' · ' + l.rooms.length + ' room' + (l.rooms.length !== 1 ? 's' : '') : ''}</div>
</div>
<div class="lc-arrow"></div>
</div>
`).join('');
}
// ── Location Detail ───────────────────────────────────────────────────
async function viewLocation(locId) {
CustState.locId = locId;
try {
const loc = await api(`/api/locations/${locId}`);
showCustView('locDetailView');
// Find customer name
const cust = CustState.allCusts.find(c => c.id === loc.customer_id);
document.getElementById('locDetailName').textContent = loc.name;
document.getElementById('locDetailCust').textContent = cust ? cust.name : '';
const fields = [
['Address', loc.address],
['Building Name', loc.building_name],
['Building Number', loc.building_number],
['Floor', loc.floor],
['Trailer Number', loc.trailer_number],
['Site Hours', loc.site_hours],
['Access Notes', loc.access_notes],
['Walking Directions', loc.walking_directions],
];
let html = '';
fields.forEach(([label, val]) => {
if (val) {
html += `<div class="detail-field"><div class="df-label">${label}</div><div class="df-value">${esc(val)}</div></div>`;
}
});
if (loc.map_link) {
html += `<div class="detail-field">
<div class="df-label">Map Link</div>
<div class="df-value"><a href="${esc(loc.map_link)}" target="_blank" rel="noopener" style="color:var(--accent2);">🗺️ Get Directions</a></div>
</div>`;
}
if (!fields.some(([,v]) => v) && !loc.map_link) {
html = '<div class="empty-state">No additional details</div>';
}
document.getElementById('locDetailFields').innerHTML = html;
// Rooms
renderRoomsList(loc.rooms || []);
} catch (e) {
showToast(e.message, true);
showCustList();
}
}
function backFromLocDetail() {
// Go back to wherever we came from (customer detail or locations list)
if (CustState.custId) {
viewCustomer(CustState.custId);
} else {
showCustList();
}
}
// ── Location Form (Create/Edit) ──────────────────────────────────────
async function showLocForm() {
CustState.locFormMode = 'create';
CustState.locId = null;
await populateCustomerSelect();
showCustView('locFormView');
document.getElementById('locFormTitle').textContent = 'New Location';
document.getElementById('locFormSaveBtn').textContent = 'Save Location';
clearLocForm();
// Pre-select customer if we're in the context of one
if (CustState.custId) {
document.getElementById('locFormCustomer').value = CustState.custId;
}
}
function showLocFormForCust(custId) {
CustState.custId = custId;
showLocForm();
}
async function editLocation() {
try {
const loc = await api(`/api/locations/${CustState.locId}`);
CustState.locFormMode = 'edit';
await populateCustomerSelect();
showCustView('locFormView');
document.getElementById('locFormTitle').textContent = 'Edit Location';
document.getElementById('locFormSaveBtn').textContent = 'Update Location';
document.getElementById('locFormCustomer').value = loc.customer_id || '';
document.getElementById('locFormName').value = loc.name || '';
document.getElementById('locFormAddress').value = loc.address || '';
document.getElementById('locFormBldgName').value = loc.building_name || '';
document.getElementById('locFormBldgNum').value = loc.building_number || '';
document.getElementById('locFormFloor').value = loc.floor || '';
document.getElementById('locFormTrailer').value = loc.trailer_number || '';
document.getElementById('locFormSiteHours').value = loc.site_hours || '';
document.getElementById('locFormAccess').value = loc.access_notes || '';
document.getElementById('locFormWalking').value = loc.walking_directions || '';
document.getElementById('locFormMapLink').value = loc.map_link || '';
} catch (e) {
showToast(e.message, true);
}
}
function clearLocForm() {
document.getElementById('locFormName').value = '';
document.getElementById('locFormAddress').value = '';
document.getElementById('locFormBldgName').value = '';
document.getElementById('locFormBldgNum').value = '';
document.getElementById('locFormFloor').value = '';
document.getElementById('locFormTrailer').value = '';
document.getElementById('locFormSiteHours').value = '';
document.getElementById('locFormAccess').value = '';
document.getElementById('locFormWalking').value = '';
document.getElementById('locFormMapLink').value = '';
}
function cancelLocForm() {
if (CustState.locFormMode === 'edit') {
viewLocation(CustState.locId);
} else if (CustState.custId) {
showLocationsForCust(CustState.custId);
} else {
showCustList();
}
}
async function populateCustomerSelect() {
const sel = document.getElementById('locFormCustomer');
const currentVal = sel.value;
sel.innerHTML = '<option value="">Select customer *</option>';
try {
const custs = await api('/api/customers');
custs.forEach(c => {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = c.name;
sel.appendChild(opt);
});
if (currentVal) sel.value = currentVal;
} catch (e) { /* ignore */ }
}
async function saveLocation() {
const custId = parseInt(document.getElementById('locFormCustomer').value);
const name = document.getElementById('locFormName').value.trim();
if (!custId) { showToast('Please select a customer', true); return; }
if (!name) { showToast('Location name is required', true); return; }
const payload = {
customer_id: custId,
name,
address: document.getElementById('locFormAddress').value.trim(),
building_name: document.getElementById('locFormBldgName').value.trim(),
building_number: document.getElementById('locFormBldgNum').value.trim(),
floor: document.getElementById('locFormFloor').value.trim(),
trailer_number: document.getElementById('locFormTrailer').value.trim(),
site_hours: document.getElementById('locFormSiteHours').value.trim(),
access_notes: document.getElementById('locFormAccess').value.trim(),
walking_directions: document.getElementById('locFormWalking').value.trim(),
map_link: document.getElementById('locFormMapLink').value.trim(),
};
try {
if (CustState.locFormMode === 'create') {
const loc = await api('/api/locations', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
showToast('Location created!');
CustState.locId = loc.id;
// Refresh location cache
try {
const allLocs = await api('/api/locations');
CustState.allLocs = {};
allLocs.forEach(l => {
const cid = l.customer_id;
if (cid) {
if (!CustState.allLocs[cid]) CustState.allLocs[cid] = [];
CustState.allLocs[cid].push(l);
}
});
} catch (e) { /* ignore */ }
viewLocation(loc.id);
} else {
await api(`/api/locations/${CustState.locId}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
showToast('Location updated!');
viewLocation(CustState.locId);
}
} catch (e) {
showToast(e.message, true);
}
}
async function deleteLocation() {
const loc = await api(`/api/locations/${CustState.locId}`);
const confirmed = await showModal(
'Delete Location',
`Delete "${loc.name}" and all its rooms? This cannot be undone.`,
'Delete'
);
if (!confirmed) return;
try {
await api(`/api/locations/${CustState.locId}`, { method: 'DELETE' });
showToast('Location deleted');
// Refresh cache
try {
const allLocs = await api('/api/locations');
CustState.allLocs = {};
allLocs.forEach(l => {
const cid = l.customer_id;
if (cid) {
if (!CustState.allLocs[cid]) CustState.allLocs[cid] = [];
CustState.allLocs[cid].push(l);
}
});
} catch (e) { /* ignore */ }
if (CustState.custId) {
viewCustomer(CustState.custId);
} else {
showCustList();
}
} catch (e) {
showToast(e.message, true);
}
}
// ── Rooms ─────────────────────────────────────────────────────────────
function renderRoomsList(rooms) {
document.getElementById('roomCount').textContent = rooms.length ? `(${rooms.length})` : '';
const el = document.getElementById('roomsList');
if (!rooms.length) {
el.innerHTML = '<div class="empty-state">No rooms added yet</div>';
return;
}
// Store room data for safe access by edit handlers
rooms.forEach(r => { CustState._roomData = CustState._roomData || {}; CustState._roomData[r.id] = r; });
el.innerHTML = rooms.map(r => `
<div class="room-item" id="roomRow${r.id}">
<span class="ri-name">🚪 ${esc(r.name)}</span>
${r.floor ? `<span class="ri-floor">Floor ${esc(r.floor)}</span>` : ''}
<span class="ri-actions">
<button class="ri-btn edit" onclick="startEditRoom(${r.id})" title="Edit">✏️</button>
<button class="ri-btn del" onclick="deleteRoom(${r.id})" title="Delete">🗑️</button>
</span>
</div>
`).join('');
}
async function addRoom() {
const name = document.getElementById('newRoomName').value.trim();
if (!name) { showToast('Room name is required', true); return; }
const floor = document.getElementById('newRoomFloor').value.trim();
try {
await api('/api/rooms', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ location_id: CustState.locId, name, floor }),
});
showToast('Room added!');
document.getElementById('newRoomName').value = '';
document.getElementById('newRoomFloor').value = '';
viewLocation(CustState.locId); // refresh
} catch (e) {
showToast(e.message, true);
}
}
function startEditRoom(roomId) {
const data = CustState._roomData && CustState._roomData[roomId];
if (!data) return;
const row = document.getElementById('roomRow' + roomId);
if (!row) return;
row.innerHTML = `
<div class="room-edit-row" style="width:100%;">
<input id="editRoomName${roomId}" type="text" class="input-field" value="${esc(data.name)}" placeholder="Room name" style="flex:2;">
<input id="editRoomFloor${roomId}" type="text" class="input-field" value="${esc(data.floor||'')}" placeholder="Floor" style="flex:1;">
<button class="btn btn-green btn-sm" onclick="saveEditRoom(${roomId})">✓</button>
<button class="btn btn-outline btn-sm" onclick="viewLocation(${CustState.locId})">✕</button>
</div>
`;
document.getElementById('editRoomName' + roomId).focus();
}
async function saveEditRoom(roomId) {
const name = document.getElementById('editRoomName' + roomId).value.trim();
if (!name) { showToast('Room name is required', true); return; }
const floor = document.getElementById('editRoomFloor' + roomId).value.trim();
try {
await api(`/api/rooms/${roomId}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, floor }),
});
showToast('Room updated!');
viewLocation(CustState.locId); // refresh
} catch (e) {
showToast(e.message, true);
}
}
async function deleteRoom(roomId) {
const confirmed = await showModal(
'Delete Room',
'Delete this room? This cannot be undone.',
'Delete'
);
if (!confirmed) return;
try {
await api(`/api/rooms/${roomId}`, { method: 'DELETE' });
showToast('Room deleted');
viewLocation(CustState.locId); // refresh
} catch (e) {
showToast(e.message, true);
}
}
// ── Hook into tab switching ───────────────────────────────────────────
document.addEventListener('tabChange', function(e) {
if (e.detail && e.detail.tabId === 'tabCustomers') {
if (CustState.view === 'list' || !CustState.view) {
showCustList();
}
}
});
// ═══════════════════════════════════════════════════════════════════════
// DASHBOARD TAB
// ═══════════════════════════════════════════════════════════════════════
async function loadDashboard() {
try {
// Fetch stats and activity in parallel
const [stats, activity] = await Promise.all([
api('/api/stats'),
api('/api/activity?limit=15'),
]);
// --- Stat cards ---
document.getElementById('statAssets').textContent = stats.total_assets || 0;
document.getElementById('statCheckins').textContent = stats.total_checkins || 0;
const activeTechs = (stats.time_on_site && stats.time_on_site.length) || 0;
document.getElementById('statTechs').textContent = activeTechs;
// --- By Category ---
const catEntries = Object.entries(stats.by_category || {});
const maxCat = Math.max(1, ...catEntries.map(([, c]) => c));
document.getElementById('statsCategories').innerHTML = catEntries.length
? catEntries.map(([cat, cnt]) => `
<div class="stat-bar">
<span class="sb-label">${esc(cat)}</span>
<div class="sb-track"><div class="sb-fill" style="width:${(cnt/maxCat*100).toFixed(0)}%;"></div></div>
<span class="sb-count">${cnt}</span>
</div>
`).join('')
: '<div class="empty-state">No data</div>';
// --- By Status ---
const statEntries = Object.entries(stats.by_status || {});
const maxStat = Math.max(1, ...statEntries.map(([, c]) => c));
document.getElementById('statsStatuses').innerHTML = statEntries.length
? statEntries.map(([st, cnt]) => {
const color = st === 'active' ? 'var(--green)' : st === 'maintenance' ? 'var(--amber)' : 'var(--red)';
return `
<div class="stat-bar">
<span class="sb-label">${esc(st)}</span>
<div class="sb-track"><div class="sb-fill" style="width:${(cnt/maxStat*100).toFixed(0)}%;background:${color};"></div></div>
<span class="sb-count">${cnt}</span>
</div>`;
}).join('')
: '<div class="empty-state">No data</div>';
// --- By Make ---
const makeEntries = Object.entries(stats.by_make || {});
const maxMake = Math.max(1, ...makeEntries.map(([, c]) => c));
document.getElementById('statsMakes').innerHTML = makeEntries.length
? makeEntries.map(([make, cnt]) => `
<div class="stat-bar">
<span class="sb-label">${esc(make)}</span>
<div class="sb-track"><div class="sb-fill" style="width:${(cnt/maxMake*100).toFixed(0)}%;background:var(--accent2);"></div></div>
<span class="sb-count">${cnt}</span>
</div>
`).join('')
: '<div class="empty-state">No manufacturer data</div>';
// --- High Visit Rate ---
const topVisited = stats.top_visited || [];
document.getElementById('highVisitList').innerHTML = topVisited.length
? topVisited.map((v, i) => {
const rank = i + 1;
const rankCls = rank === 1 ? 'r1' : rank <= 3 ? 'r2' : 'rn';
const lastDate = v.last_visit_date ? formatDate(v.last_visit_date) : '—';
return `
<div class="hv-row" onclick="navToAsset('${esc(v.machine_id || '')}')">
<div class="hv-rank ${rankCls}">${rank}</div>
<div class="hv-info">
<div class="hv-name">${esc(v.name)}</div>
<div class="hv-meta">${esc(v.machine_id || '—')} · Last: ${lastDate}</div>
</div>
<div class="hv-visits">${v.visit_count}</div>
</div>`;
}).join('')
: '<div class="empty-state">No visit data yet</div>';
// --- Recent Activity ---
renderActivityFeed(activity || []);
} catch (e) {
console.error('Dashboard error:', e);
showToast('Failed to load dashboard: ' + e.message, true);
}
// Phase M: Add My Assets quick link for technicians
if (isTechnician() && AppState.currentUser) {
var grid = document.getElementById('statsGrid');
if (grid && !document.getElementById('myAssetsLink')) {
var card = document.createElement('div');
card.id = 'myAssetsLink';
card.className = 'stat-card';
card.style.cursor = 'pointer';
card.onclick = showMyAssets;
card.innerHTML = '<div class="stat-value">👤</div><div class="stat-label">My Assets</div>';
grid.appendChild(card);
}
}
}
function renderActivityFeed(activities) {
const el = document.getElementById('activityFeed');
if (!activities.length) {
el.innerHTML = '<div class="empty-state">No recent activity</div>';
return;
}
const ICON_MAP = { created: '✨', updated: '✏️', deleted: '🗑️', checked_in: '📍', checked_out: '🚪', login: '🔑' };
el.innerHTML = activities.slice(0, 10).map(a => {
const action = a.action || '';
const icon = ICON_MAP[action] || '📌';
const userName = esc(a.user_name || 'System');
const detail = esc(a.details || `${action} ${a.entity_type || ''} #${a.entity_id || ''}`);
return `
<div class="activity-item">
<div class="act-icon ${esc(action)}">${icon}</div>
<div class="act-text">
<div class="act-desc"><span class="act-user">${userName}</span> ${detail}</div>
<div class="act-time">${timeAgo(a.created_at)}</div>
</div>
</div>`;
}).join('');
}
async function loadFullActivity() {
try {
const activities = await api('/api/activity?limit=100');
renderActivityFeed(activities);
document.getElementById('activityFeed').scrollIntoView({ behavior: 'smooth' });
} catch (e) {
showToast('Failed to load activity: ' + e.message, true);
}
}
async function exportCSV(type) {
const urls = {
assets: '/api/export/assets',
checkins: '/api/export/checkins',
service: '/api/export/service-summary',
};
const url = urls[type];
if (!url) return;
try {
showToast('Downloading...');
const opts = {};
if (AppState.authToken) {
opts.headers = { 'Authorization': 'Bearer ' + AppState.authToken };
}
const res = await fetch(url, opts);
if (!res.ok) throw new Error(`Export failed: ${res.status}`);
const blob = await res.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `${type}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
showToast(`${type} CSV downloaded`);
} catch (e) {
showToast('Export error: ' + e.message, true);
}
}
async function navToAsset(machineId) {
if (!machineId) return;
try {
// Search for asset by machine_id
const results = await api(`/api/assets/search?q=${encodeURIComponent(machineId)}`);
if (results && results.length > 0) {
AppState.currentAssetId = results[0].id;
switchTab('tabAssets');
// Delay to let the tab switch, then show detail
setTimeout(() => viewAsset(results[0].id), 100);
} else {
showToast('Asset not found', true);
}
} catch (e) {
showToast('Navigation error: ' + e.message, true);
}
}
// ═══════════════════════════════════════════════════════════════════════
// REPORTS TAB
// ═══════════════════════════════════════════════════════════════════════
let reportVisitsCache = [];
let reportSortState = { key: null, dir: 1 };
async function loadReports() {
const df = document.getElementById('reportDateFrom').value;
const dt = document.getElementById('reportDateTo').value;
const params = new URLSearchParams();
if (df) params.set('date_from', df);
if (dt) params.set('date_to', dt);
params.set('limit', '1000');
try {
const visits = await api('/api/visits?' + params.toString());
reportVisitsCache = visits;
renderServiceSummary(visits);
renderVisitFrequency(visits);
renderTimeOnSite(visits);
} catch (e) {
showToast('Failed to load reports: ' + e.message, true);
}
}
function renderServiceSummary(visits) {
const assetSet = new Set(visits.map(v => v.asset_id));
const userSet = new Set(visits.map(v => v.user_id));
const totalSecs = visits.reduce((sum, v) => {
if (v.checkout_time && v.checkin_time) {
return sum + ((new Date(v.checkout_time) - new Date(v.checkin_time)) / 1000);
}
return sum;
}, 0);
const avgMin = visits.length ? Math.round(totalSecs / visits.length / 60) : 0;
document.getElementById('rptAssets').textContent = assetSet.size;
document.getElementById('rptVisits').textContent = visits.length;
document.getElementById('rptAvgTime').textContent = avgMin + ' min';
document.getElementById('rptTechs').textContent = userSet.size;
// Build per-asset summary table
const assetMap = {};
visits.forEach(v => {
const key = v.asset_id || '?';
if (!assetMap[key]) {
assetMap[key] = { name: v.asset_name || v.machine_id || 'Unknown', visits: 0, last: null };
}
assetMap[key].visits++;
if (!assetMap[key].last || v.checkin_time > assetMap[key].last) {
assetMap[key].last = v.checkin_time;
}
});
const rows = Object.values(assetMap).sort((a, b) => b.visits - a.visits);
const html = rows.length ? `
<table class="rpt-table">
<thead><tr>
<th>Asset</th>
<th class="rpt-number">Visits</th>
<th>Last Visit</th>
</tr></thead>
<tbody>${rows.map(r => `
<tr>
<td>${esc(r.name)}</td>
<td class="rpt-number">${r.visits}</td>
<td>${formatDate(r.last)}</td>
</tr>`).join('')}</tbody>
</table>` : '<div class="empty-state">No visits in range</div>';
document.getElementById('reportServiceTable').innerHTML = html;
}
function renderVisitFrequency(visits) {
const assetMap = {};
visits.forEach(v => {
const key = v.asset_id || '?';
if (!assetMap[key]) {
assetMap[key] = { name: v.asset_name || v.machine_id || 'Unknown', visits: 0, last: null, techs: new Set() };
}
assetMap[key].visits++;
if (!assetMap[key].last || v.checkin_time > assetMap[key].last) {
assetMap[key].last = v.checkin_time;
}
if (v.user_name) assetMap[key].techs.add(v.user_name);
});
let rows = Object.values(assetMap).sort((a, b) => b.visits - a.visits);
if (reportSortState.key) {
const k = reportSortState.key;
const d = reportSortState.dir;
if (k === 'asset') rows.sort((a, b) => d * a.name.localeCompare(b.name));
else if (k === 'visits') rows.sort((a, b) => d * (a.visits - b.visits));
else if (k === 'last') {
rows.sort((a, b) => {
const da = a.last ? new Date(a.last).getTime() : 0;
const db = b.last ? new Date(b.last).getTime() : 0;
return d * (da - db);
});
} else if (k === 'tech') rows.sort((a, b) => d * (a.techs.size - b.techs.size));
}
function sortArrow(k) {
if (reportSortState.key !== k) return ' <span class="sort-arrow">↕</span>';
return reportSortState.dir === 1 ? ' <span class="sort-arrow asc">▲</span>' : ' <span class="sort-arrow desc">▼</span>';
}
const tableHtml = rows.length ? `
<table class="rpt-table">
<thead><tr>
<th class="sortable" onclick="sortReport('asset')">Asset${sortArrow('asset')}</th>
<th class="sortable rpt-number" onclick="sortReport('visits')">Visits${sortArrow('visits')}</th>
<th class="sortable" onclick="sortReport('last')">Last Visit${sortArrow('last')}</th>
<th class="sortable rpt-number" onclick="sortReport('tech')">Techs${sortArrow('tech')}</th>
</tr></thead>
<tbody>${rows.map(r => `
<tr>
<td>${esc(r.name)}</td>
<td class="rpt-number">${r.visits}</td>
<td>${formatDate(r.last)}</td>
<td class="rpt-number">${r.techs.size}</td>
</tr>`).join('')}</tbody>
</table>` : '<div class="empty-state">No visits in range</div>';
document.getElementById('reportVisitTable').innerHTML = tableHtml;
}
function sortReport(key) {
if (reportSortState.key === key) {
reportSortState.dir *= -1;
} else {
reportSortState.key = key;
reportSortState.dir = 1;
}
renderVisitFrequency(reportVisitsCache);
}
function renderTimeOnSite(visits) {
// Per technician
const techMap = {};
visits.forEach(v => {
if (!v.user_name) return;
const key = v.user_name;
if (!techMap[key]) techMap[key] = { visits: 0, totalSecs: 0 };
techMap[key].visits++;
if (v.checkout_time && v.checkin_time) {
techMap[key].totalSecs += (new Date(v.checkout_time) - new Date(v.checkin_time)) / 1000;
}
});
let techRows = Object.entries(techMap).map(([name, d]) => ({
name, visits: d.visits, totalMin: Math.round(d.totalSecs / 60), avgMin: d.visits ? Math.round(d.totalSecs / d.visits / 60) : 0,
}));
const maxAvgTech = Math.max(1, ...techRows.map(r => r.avgMin));
techRows.sort((a, b) => b.totalMin - a.totalMin);
const techHtml = techRows.length ? `
<table class="rpt-table">
<thead><tr>
<th>Technician</th>
<th class="rpt-number">Visits</th>
<th class="rpt-number">Total Time</th>
<th style="width:50%;">Avg Time</th>
</tr></thead>
<tbody>${techRows.map(r => {
const pct = (r.avgMin / maxAvgTech * 100).toFixed(0);
return `<tr>
<td>${esc(r.name)}</td>
<td class="rpt-number">${r.visits}</td>
<td class="rpt-number">${r.totalMin} min</td>
<td><div class="rpt-bar-wrap">
<div class="rpt-bar-track"><div class="rpt-bar-fill" style="width:${pct}%;"></div></div>
<span class="rpt-bar-val">${r.avgMin} min</span>
</div></td>
</tr>`;
}).join('')}</tbody>
</table>` : '<div class="empty-state">No visit data available</div>';
document.getElementById('reportTimeTech').innerHTML = techHtml;
// Per asset
const assetMap = {};
visits.forEach(v => {
const key = v.asset_id || '?';
if (!assetMap[key]) {
assetMap[key] = { name: v.asset_name || v.machine_id || 'Unknown', visits: 0, totalSecs: 0 };
}
assetMap[key].visits++;
if (v.checkout_time && v.checkin_time) {
assetMap[key].totalSecs += (new Date(v.checkout_time) - new Date(v.checkin_time)) / 1000;
}
});
let assetRows = Object.entries(assetMap).map(([id, d]) => ({
name: d.name, visits: d.visits, totalMin: Math.round(d.totalSecs / 60), avgMin: d.visits ? Math.round(d.totalSecs / d.visits / 60) : 0,
}));
const maxAvgAsset = Math.max(1, ...assetRows.map(r => r.avgMin));
assetRows.sort((a, b) => b.totalMin - a.totalMin);
const assetHtml = assetRows.length ? `
<table class="rpt-table">
<thead><tr>
<th>Asset</th>
<th class="rpt-number">Visits</th>
<th class="rpt-number">Total Time</th>
<th style="width:50%;">Avg Time</th>
</tr></thead>
<tbody>${assetRows.map(r => {
const pct = (r.avgMin / maxAvgAsset * 100).toFixed(0);
return `<tr>
<td>${esc(r.name)}</td>
<td class="rpt-number">${r.visits}</td>
<td class="rpt-number">${r.totalMin} min</td>
<td><div class="rpt-bar-wrap">
<div class="rpt-bar-track"><div class="rpt-bar-fill" style="width:${pct}%;"></div></div>
<span class="rpt-bar-val">${r.avgMin} min</span>
</div></td>
</tr>`;
}).join('')}</tbody>
</table>` : '<div class="empty-state">No visit data available</div>';
document.getElementById('reportTimeAsset').innerHTML = assetHtml;
}
function exportServiceSummary() {
if (!reportVisitsCache.length) {
showToast('No data to export. Generate a report first.', true);
return;
}
// Build CSV client-side from the filtered data
const assetMap = {};
reportVisitsCache.forEach(v => {
const key = v.asset_id || '?';
if (!assetMap[key]) {
assetMap[key] = {
asset: v.asset_name || v.machine_id || 'Unknown',
visits: 0, last: null, techs: new Set(), totalSecs: 0,
};
}
assetMap[key].visits++;
if (!assetMap[key].last || v.checkin_time > assetMap[key].last) assetMap[key].last = v.checkin_time;
if (v.user_name) assetMap[key].techs.add(v.user_name);
if (v.checkout_time && v.checkin_time) {
assetMap[key].totalSecs += (new Date(v.checkout_time) - new Date(v.checkin_time)) / 1000;
}
});
const csv = 'Asset,Visits,Last Visit,Technicians,Avg Time (min)\n'
+ Object.values(assetMap).map(r => [
'"' + r.asset.replace(/"/g, '""') + '"',
r.visits,
'"' + (r.last ? formatDate(r.last) : '') + '"',
r.techs.size,
r.visits ? Math.round(r.totalSecs / r.visits / 60) : 0,
].join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'service_summary.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast('CSV downloaded');
}
// ═══════════════════════════════════════════════════════════════════════
// MAP TAB — Leaflet map with pins, geofences, heatmap, auto-visit
// ═══════════════════════════════════════════════════════════════════════
let map = null;
let assetMarkers = []; // { asset, marker }
let geofenceLayers = []; // { geofence, polygon }
let heatLayer = null;
let heatVisible = false;
let drawnItems = null; // Leaflet FeatureGroup for drawn polygons
let drawControl = null;
let drawingGeofence = false;
let geoWatchId = null;
let visitTimers = {}; // { asset_id: { startTime, lastSeen } }
const VISIT_THRESHOLD_M = 50;
const VISIT_DURATION_MIN = 10;
// Category-to-color mapping for asset pins
const CAT_COLORS = {
'Furniture': '#4ade80',
'Appliances': '#60a5fa',
'Utensils &amp; Serveware': '#fbbf24',
'Utensils & Serveware': '#fbbf24',
'Equipment': '#f87171',
'Other': '#a78bfa',
};
function catColor(cat) {
return CAT_COLORS[cat] || '#a78bfa';
}
// Category icon emoji for markers
const CAT_MARKER_EMOJI = {
'Furniture': '🪑', 'Appliances': '🔌', 'Utensils &amp; Serveware': '🍴',
'Utensils & Serveware': '🍴', 'Equipment': '⚙️', 'Other': '📦',
};
function catEmoji(cat) {
return CAT_MARKER_EMOJI[cat] || '📦';
}
// DivIcon with colored circle + emoji
function makeAssetIcon(asset) {
const color = catColor(asset.category);
const emoji = catEmoji(asset.category);
return L.divIcon({
html: `<div style="width:32px;height:32px;border-radius:50%;background:${color};display:flex;align-items:center;justify-content:center;font-size:16px;border:2px solid #fff;box-shadow:0 2px 6px rgba(0,0,0,0.5);">${emoji}</div>`,
className: '',
iconSize: [32, 32],
iconAnchor: [16, 16],
popupAnchor: [0, -18],
});
}
// Initialize the map
function initMap() {
if (map) return;
const container = document.getElementById('mapContainer');
if (!container) return;
const lat = AppState.gpsLat || 40.7128;
const lng = AppState.gpsLng || -74.0060;
const zoom = AppState.gpsLat ? 15 : 13;
map = L.map('mapContainer', {
center: [lat, lng],
zoom: zoom,
zoomControl: true,
attributionControl: false,
});
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
}).addTo(map);
// FeatureGroup for drawn geofences
drawnItems = new L.FeatureGroup();
map.addLayer(drawnItems);
// Draw control (hidden by default, shown when user taps "Add Geofence")
drawControl = new L.Control.Draw({
draw: {
polygon: {
allowIntersection: false,
showArea: true,
shapeOptions: { color: '#3388ff', weight: 2 },
},
polyline: false,
rectangle: false,
circle: false,
circlemarker: false,
marker: false,
},
edit: { featureGroup: drawnItems },
});
// Don't add to map yet — only when drawing mode active
// Handle drawn polygon
map.on(L.Draw.Event.CREATED, function (e) {
drawnItems.addLayer(e.layer);
// Show the save/cancel row
document.getElementById('geofenceColorRow').style.display = 'flex';
});
// Handle edits
map.on(L.Draw.Event.EDITED, function () {
syncEditedGeofences();
});
map.on(L.Draw.Event.DELETED, function () {
syncDeletedGeofences();
});
// Invalidate size when tab becomes visible
setTimeout(() => map.invalidateSize(), 100);
loadAssetPins();
loadGeofences();
}
// Load all assets with coordinates and place pins
async function loadAssetPins() {
try {
const assets = await api('/api/assets?limit=1000');
clearAssetMarkers();
const withCoords = assets.filter(a => a.latitude != null && a.longitude != null);
withCoords.forEach(asset => {
addAssetMarker(asset);
});
// Update heatmap layer if visible
if (heatVisible) await loadHeatmapData();
} catch (e) {
console.error('Failed to load asset pins:', e);
}
}
function clearAssetMarkers() {
assetMarkers.forEach(({ marker }) => {
if (map) map.removeLayer(marker);
});
assetMarkers = [];
}
function addAssetMarker(asset) {
if (!map) return;
const icon = makeAssetIcon(asset);
const marker = L.marker([asset.latitude, asset.longitude], { icon }).addTo(map);
const addrParts = [
asset.address, asset.building_name,
asset.building_number ? `#${asset.building_number}` : '',
asset.floor ? `Floor ${asset.floor}` : ''
].filter(Boolean);
const addr = addrParts.length ? addrParts.join(', ') : 'No address';
const dirLink = `https://www.google.com/maps/dir/?api=1&destination=${asset.latitude},${asset.longitude}`;
marker.bindPopup(`
<div style="min-width:180px;">
<div style="font-size:15px;font-weight:700;margin-bottom:4px;">${esc(asset.name)}</div>
<div style="font-size:11px;color:var(--text2);margin-bottom:6px;">
${asset.machine_id ? esc(asset.machine_id) + ' · ' : ''}${esc(asset.category || 'Other')}
${asset.status ? ` · <span style="color:${asset.status==='active'?'var(--green)':asset.status==='maintenance'?'var(--amber)':'var(--red)'};">${esc(asset.status)}</span>` : ''}
</div>
<div style="font-size:11px;color:var(--text2);margin-bottom:8px;">📍 ${esc(addr)}</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;">
<a href="${dirLink}" target="_blank" rel="noopener" style="display:inline-flex;align-items:center;gap:4px;font-size:12px;font-weight:600;padding:6px 12px;border-radius:8px;background:var(--accent);color:#fff;text-decoration:none;">🧭 Directions</a>
<button onclick="switchTab('tabAssets');viewAsset(${asset.id});" style="font-size:12px;font-weight:600;padding:6px 12px;border-radius:8px;background:var(--card2);color:var(--text);border:1px solid var(--border);cursor:pointer;">📋 Details</button>
</div>
</div>
`);
assetMarkers.push({ asset, marker });
}
// Center map on user GPS
function centerOnGPS() {
if (!map) return;
if (AppState.gpsLat) {
map.setView([AppState.gpsLat, AppState.gpsLng], 16);
// Add a pulsing blue dot for current location
if (!map._userMarker) {
map._userMarker = L.circleMarker([AppState.gpsLat, AppState.gpsLng], {
radius: 8, fillColor: '#5b6ef7', color: '#fff', weight: 2,
fillOpacity: 0.9,
}).addTo(map);
} else {
map._userMarker.setLatLng([AppState.gpsLat, AppState.gpsLng]);
}
} else {
showToast('GPS location not available yet', true);
}
}
// Toggle asset pins visibility
function togglePins() {
const chip = document.getElementById('chipPins');
const show = !chip.classList.contains('active');
chip.classList.toggle('active', show);
assetMarkers.forEach(({ marker }) => {
if (show) marker.addTo(map);
else map.removeLayer(marker);
});
}
// ═══════════════════════════════════════════════════════════════════════
// GEOFFENCE MANAGEMENT
// ═══════════════════════════════════════════════════════════════════════
function toggleGeofenceDraw() {
if (!map) return;
const chip = document.getElementById('chipGeo');
if (drawingGeofence) {
cancelGeofenceDraw();
return;
}
drawingGeofence = true;
chip.classList.add('active');
chip.textContent = '✏️ Drawing...';
map.addControl(drawControl);
drawnItems.clearLayers();
document.getElementById('geofenceColorRow').style.display = 'none';
new L.Draw.Polygon(map, drawControl.options.draw.polygon).enable();
}
function cancelGeofenceDraw() {
drawingGeofence = false;
const chip = document.getElementById('chipGeo');
chip.classList.remove('active');
chip.textContent = '✏️ Add Geofence';
if (drawControl) map.removeControl(drawControl);
drawnItems.clearLayers();
document.getElementById('geofenceColorRow').style.display = 'none';
}
async function saveDrawnGeofence() {
const layers = drawnItems.getLayers();
if (layers.length === 0) {
showToast('Draw a polygon on the map first', true);
return;
}
const polygon = layers[0];
const latlngs = polygon.getLatLngs()[0];
// Leaflet uses [lat, lng] arrays; backend expects {lat, lng} objects
const points = latlngs.map(ll => ({ lat: ll.lat, lng: ll.lng }));
const color = document.getElementById('geofenceColor').value;
const name = prompt('Geofence name:', 'Zone ' + (geofenceLayers.length + 1));
if (!name) return;
// Ask about user assignment
let user_ids = [];
try {
const users = await api('/api/users');
if (users && users.length > 0) {
let html = users.map(u => `
<label style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--border);cursor:pointer;">
<input type="checkbox" value="${u.id}" style="width:18px;height:18px;accent-color:var(--accent);">
<span>
<span style="color:var(--text);font-size:14px;">${esc(u.username)}</span>
<span style="color:var(--text3);font-size:11px;margin-left:6px;">(${esc(u.role)})</span>
</span>
</label>
`).join('');
const container = document.createElement('div');
container.innerHTML = html;
container.style.cssText = 'max-height:300px;overflow-y:auto;';
const confirmed = await showModal(
'Assign Users to Geofence',
container,
'Save',
'btn-primary'
);
if (confirmed) {
user_ids = [...container.querySelectorAll('input[type=checkbox]:checked')].map(cb => parseInt(cb.value));
} else {
return; // user cancelled — abort save
}
}
} catch (e) { /* no users loaded — skip assignment */ }
try {
await api('/api/geofences', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name.trim(), points, color, user_ids }),
});
showToast('Geofence saved!');
cancelGeofenceDraw();
await loadGeofences();
} catch (e) {
showToast(e.message, true);
}
}
async function loadGeofences() {
try {
const geofences = await api('/api/geofences');
// Clear existing geofence layers
geofenceLayers.forEach(({ polygon }) => {
if (map) map.removeLayer(polygon);
});
geofenceLayers = [];
geofences.forEach(gf => {
const points = typeof gf.points === 'string' ? JSON.parse(gf.points) : gf.points;
const latlngs = points.map(p => [p.lat, p.lng]);
const polygon = L.polygon(latlngs, {
color: gf.color || '#3388ff',
fillColor: gf.color || '#3388ff',
fillOpacity: 0.15,
weight: 2,
}).addTo(map);
const assignedUsers = gf.assigned_users || [];
const popupBody = `<div style="min-width:150px;">
<div style="font-weight:700;margin-bottom:4px;">${esc(gf.name)}</div>
${assignedUsers.length ? `
<div style="font-size:11px;color:var(--text2);margin:4px 0;border-top:1px solid var(--border);padding-top:4px;">
👤 ${assignedUsers.map(u => `<span style="color:var(--accent2);">${esc(u.username)}</span>`).join(', ')}
</div>
` : ''}
<div style="display:flex;gap:4px;margin-top:4px;">
<button onclick="editGeofence(${gf.id})" style="font-size:11px;padding:3px 8px;border-radius:4px;background:var(--card2);color:var(--text);border:1px solid var(--border);cursor:pointer;">✏️ Edit</button>
<button onclick="assignGeofenceUsers(${gf.id})" style="font-size:11px;padding:3px 8px;border-radius:4px;background:var(--accent-bg);color:var(--accent);border:1px solid var(--accent);cursor:pointer;">👤 Assign</button>
<button onclick="deleteGeofence(${gf.id})" style="font-size:11px;padding:3px 8px;border-radius:4px;background:var(--red-bg);color:var(--red);border:1px solid var(--red);cursor:pointer;">🗑️ Delete</button>
</div>
</div>`;
polygon.bindPopup(popupBody);
geofenceLayers.push({ geofence: gf, polygon });
});
renderGeofenceList(geofences);
} catch (e) {
console.error('Failed to load geofences:', e);
}
}
function renderGeofenceList(geofences) {
document.getElementById('gfCount').textContent = geofences.length + ' zone' + (geofences.length !== 1 ? 's' : '');
const el = document.getElementById('geofenceList');
if (!geofences.length) {
el.innerHTML = '<div style="font-size:12px;color:var(--text3);padding:4px 0;">No geofences yet — tap ✏️ to draw one</div>';
return;
}
el.innerHTML = geofences.map(gf => `
<div class="geofence-item">
<div class="gf-color" style="background:${esc(gf.color || '#3388ff')};"></div>
<span class="gf-name">${esc(gf.name)}</span>
${gf.assigned_users && gf.assigned_users.length ? `
<span style="font-size:10px;color:var(--text3);margin-left:4px;flex-shrink:0;">
👤${gf.assigned_users.length}
</span>
` : ''}
<div class="gf-actions">
<button class="gf-btn" onclick="assignGeofenceUsers(${gf.id})" title="Assign Users">👤</button>
<button class="gf-btn" onclick="editGeofence(${gf.id})" title="Edit">✏️</button>
<button class="gf-btn danger" onclick="deleteGeofence(${gf.id})" title="Delete">🗑️</button>
</div>
</div>
`).join('');
}
async function editGeofence(id) {
const gf = geofenceLayers.find(g => g.geofence.id === id);
if (!gf) return;
const newName = prompt('Geofence name:', gf.geofence.name);
if (!newName) return;
try {
await api(`/api/geofences/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName.trim() }),
});
showToast('Geofence updated!');
await loadGeofences();
} catch (e) {
showToast(e.message, true);
}
}
async function deleteGeofence(id) {
const confirmed = await showModal('Delete Geofence', 'Remove this geofence zone?', 'Delete');
if (!confirmed) return;
try {
await api(`/api/geofences/${id}`, { method: 'DELETE' });
showToast('Geofence deleted');
await loadGeofences();
} catch (e) {
showToast(e.message, true);
}
}
async function assignGeofenceUsers(gfId) {
try {
const users = await api('/api/users');
const gf = geofenceLayers.find(g => g.geofence.id === gfId);
if (!gf) return;
const currentIds = (gf.geofence.assigned_users || []).map(u => u.id);
// Build a simple checkable list
let html = users.map(u => `
<label style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--border);cursor:pointer;">
<input type="checkbox" value="${u.id}" ${currentIds.includes(u.id) ? 'checked' : ''}
style="width:18px;height:18px;accent-color:var(--accent);">
<span>
<span style="color:var(--text);font-size:14px;">${esc(u.username)}</span>
<span style="color:var(--text3);font-size:11px;margin-left:6px;">(${esc(u.role)})</span>
</span>
</label>
`).join('') || '<div style="color:var(--text3);padding:8px 0;">No users found. Create users in the Settings tab first.</div>';
const container = document.createElement('div');
container.innerHTML = html;
container.style.cssText = 'max-height:300px;overflow-y:auto;';
const confirmed = await showModal(
'Assign Service Area',
container,
'Save',
'Cancel'
);
if (!confirmed) return;
const checked = [...container.querySelectorAll('input[type=checkbox]:checked')].map(cb => parseInt(cb.value));
await api(`/api/geofences/${gfId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_ids: checked }),
});
showToast('Service area assigned!');
await loadGeofences();
} catch (e) {
showToast(e.message, true);
}
}
function syncEditedGeofences() {
// After draw:edit, update the geofence on the backend
const layers = drawnItems.getLayers();
if (layers.length === 0) return;
// For simplicity, show a toast — user can re-draw
showToast('Geofence edited — changes saved to layer');
}
function syncDeletedGeofences() {
showToast('Geofence removed from map');
}
// ═══════════════════════════════════════════════════════════════════════
// HEATMAP
// ═══════════════════════════════════════════════════════════════════════
async function toggleHeatmap() {
if (!map) return;
const chip = document.getElementById('chipHeat');
if (heatVisible) {
heatVisible = false;
chip.classList.remove('heat-on');
chip.textContent = '🔥 Heatmap';
if (heatLayer) map.removeLayer(heatLayer);
heatLayer = null;
return;
}
heatVisible = true;
chip.classList.add('heat-on');
chip.textContent = '🔥 Heatmap ON';
await loadHeatmapData();
}
async function loadHeatmapData() {
try {
// Get visit stats (visit counts per asset)
const stats = await api('/api/visits/stats');
const visitMap = {};
(stats.visits_per_asset || []).forEach(v => {
visitMap[v.name] = v.count;
});
// Get all assets with coordinates
const assets = await api('/api/assets?limit=1000');
const heatData = [];
assets.forEach(a => {
if (a.latitude != null && a.longitude != null) {
const count = visitMap[a.name] || 0;
// Higher intensity for more visits; minimum 0.1 so all assets show faintly
const intensity = Math.max(0.1, Math.min(1, count / 10));
heatData.push([a.latitude, a.longitude, intensity]);
}
});
if (heatLayer) map.removeLayer(heatLayer);
if (typeof L.heatLayer === 'function') {
heatLayer = L.heatLayer(heatData, {
radius: 30,
blur: 20,
maxZoom: 17,
max: 1.0,
gradient: { 0.1: '#4ade80', 0.4: '#fbbf24', 0.7: '#f87171', 1.0: '#ef4444' },
}).addTo(map);
} else {
// Fallback: circle markers with opacity
heatLayer = L.layerGroup();
heatData.forEach(([lat, lng, intensity]) => {
L.circleMarker([lat, lng], {
radius: 12 + intensity * 20,
fillColor: '#f87171',
color: 'transparent',
fillOpacity: intensity * 0.6,
}).addTo(heatLayer);
});
heatLayer.addTo(map);
}
} catch (e) {
console.error('Failed to load heatmap:', e);
showToast('Failed to load heatmap data', true);
}
}
// ═══════════════════════════════════════════════════════════════════════
// AUTO-VISIT LOGGING (Client-side GPS tracking)
// ═══════════════════════════════════════════════════════════════════════
function startVisitTracking() {
if (!navigator.geolocation) return;
if (geoWatchId !== null) return; // already tracking
geoWatchId = navigator.geolocation.watchPosition(
pos => {
const lat = pos.coords.latitude;
const lng = pos.coords.longitude;
AppState.gpsLat = lat;
AppState.gpsLng = lng;
checkProximityToAssets(lat, lng);
},
err => { /* silently fail — GPS permission may change */ },
{ enableHighAccuracy: true, maximumAge: 30000, timeout: 20000 }
);
}
function stopVisitTracking() {
if (geoWatchId !== null) {
navigator.geolocation.clearWatch(geoWatchId);
geoWatchId = null;
}
// Clear all timers
Object.keys(visitTimers).forEach(k => clearTimeout(visitTimers[k]?._timer));
visitTimers = {};
document.getElementById('visitTracker').style.display = 'none';
}
// Haversine distance in meters
function haversineM(lat1, lng1, lat2, lng2) {
const R = 6371000;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLng = (lng2 - lng1) * Math.PI / 180;
const a = Math.sin(dLat / 2) ** 2 +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLng / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
function checkProximityToAssets(userLat, userLng) {
let nearestAsset = null;
let nearestDist = Infinity;
assetMarkers.forEach(({ asset }) => {
if (asset.latitude == null || asset.longitude == null) return;
const dist = haversineM(userLat, userLng, asset.latitude, asset.longitude);
if (dist < nearestDist) {
nearestDist = dist;
nearestAsset = asset;
}
// Check if within threshold
if (dist <= VISIT_THRESHOLD_M) {
if (!visitTimers[asset.id]) {
visitTimers[asset.id] = {
startTime: Date.now(),
lastSeen: Date.now(),
asset: asset,
};
} else {
visitTimers[asset.id].lastSeen = Date.now();
}
} else {
// Asset no longer in range — clear timer
if (visitTimers[asset.id]) {
clearTimeout(visitTimers[asset.id]._timer);
delete visitTimers[asset.id];
}
}
});
// Check if any timer has reached threshold
const now = Date.now();
const tracker = document.getElementById('visitTracker');
let trackingAsset = null;
for (const [id, timer] of Object.entries(visitTimers)) {
const elapsed = Math.floor((now - timer.startTime) / 60000);
timer._elapsed = elapsed;
if (timer.asset === nearestAsset) {
trackingAsset = timer;
}
// If 10+ minutes, auto-log a visit via checkin
if (elapsed >= VISIT_DURATION_MIN && !timer._logged) {
timer._logged = true;
logAutoVisit(timer.asset);
}
// Cleanup stale timers (not seen in 2 minutes)
if (now - timer.lastSeen > 120000) {
clearTimeout(timer._timer);
delete visitTimers[id];
}
}
// Update visit tracker UI
if (trackingAsset && trackingAsset._elapsed >= 0) {
tracker.style.display = 'block';
document.getElementById('vtAssetName').textContent = trackingAsset.asset.name;
document.getElementById('vtTimer').textContent = trackingAsset._elapsed + ' min';
if (trackingAsset._elapsed >= VISIT_DURATION_MIN) {
tracker.style.background = 'var(--accent-bg)';
tracker.style.color = 'var(--accent2)';
}
} else {
tracker.style.display = 'none';
}
}
async function logAutoVisit(asset) {
try {
// Log a visit record (auto-detected stay)
await api('/api/visits', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
asset_id: asset.id,
user_id: AppState.currentUser?.id || null,
latitude: AppState.gpsLat,
longitude: AppState.gpsLng,
duration_minutes: VISIT_DURATION_MIN,
}),
});
showToast('📍 Visit auto-logged for ' + asset.name);
// Refresh heatmap if visible
if (heatVisible) await loadHeatmapData();
} catch (e) {
console.error('Auto-visit log failed:', e);
}
}
// =========================================================================
// SETTINGS TAB — All entities CRUD
// =========================================================================
// Listen for tab changes to load/hide settings
document.addEventListener('tabChange', function(e) {
if (e.detail.tabId === 'tabSettings') loadAllSettings();
});
// Track expanded makes for the make→model tree
const SettingsState = {
categories: [], makes: [], models: [], key_names: [],
key_types: [], badge_types: [], users: [], geofences: [],
expandedMakes: {}, // { makeId: true }
addingModelFor: null, // makeId currently showing inline add form
};
const DEFAULT_EMOJIS = ['☕','🥤','🍿','🥪','🏪','❄️','🍩','🍕','🍔','🌯','🍜','🧁','📦','⚙️','🔧','🎯','🏷️','⭐'];
async function loadAllSettings() {
// Show/hide admin-only elements
document.body.classList.toggle('is-admin', isAdmin());
const usersCard = document.getElementById('settingsUsersCard');
if (usersCard) usersCard.style.display = isAdmin() ? '' : 'none';
await Promise.all([
loadEntity('categories'), loadEntity('makes'), loadEntity('models'),
loadEntity('key_names'), loadEntity('key_types'), loadEntity('badge_types'),
isAdmin() ? loadUsers() : Promise.resolve(),
isAdmin() ? loadGeofencesForSettings() : Promise.resolve(),
]);
renderAllSettings();
}
async function loadEntity(entity) {
try {
SettingsState[entity] = await api(`/api/settings/${entity}`);
} catch (e) {
SettingsState[entity] = [];
}
}
async function loadUsers() {
try { SettingsState.users = await api('/api/users'); }
catch (e) { SettingsState.users = []; }
}
async function loadGeofencesForSettings() {
try { SettingsState.geofences = await api('/api/geofences'); }
catch (e) { SettingsState.geofences = []; }
}
function renderAllSettings() {
renderSimpleList('categories', 'settingsCategories');
renderMakesModels();
renderSimpleList('key_names', 'settingsKeyNames');
renderSimpleList('key_types', 'settingsKeyTypes');
renderSimpleList('badge_types', 'settingsBadgeTypes');
if (isAdmin()) renderUsers();
}
// ── Simple list renderer (categories, key_names, key_types, badge_types) ──
function renderSimpleList(entity, containerId) {
const items = SettingsState[entity] || [];
const el = document.getElementById(containerId);
if (!items.length) {
el.innerHTML = '<div class="empty-state" style="padding:16px;font-size:12px;">No items yet</div>';
return;
}
const admin = isAdmin();
el.innerHTML = items.map(item => `
<div class="settings-item" data-id="${item.id}">
${entity === 'categories' ? `<span class="si-icon">${esc(item.icon || '📂')}</span>` : ''}
<span class="si-name">${esc(item.name)}</span>
${admin ? `
<div class="si-actions">
<button class="si-btn" data-action="settingsEdit" data-entity="${entity}" data-id="${item.id}">✏️</button>
<button class="si-btn delete" data-action="settingsDelete" data-entity="${entity}" data-id="${item.id}">🗑</button>
</div>` : ''}
</div>
`).join('');
}
// ── Makes & Models renderer (expandable tree) ──
function renderMakesModels() {
const el = document.getElementById('settingsMakes');
const makes = SettingsState.makes || [];
const models = SettingsState.models || [];
const admin = isAdmin();
if (!makes.length) {
el.innerHTML = '<div class="empty-state" style="padding:16px;font-size:12px;">No makes yet</div>';
return;
}
el.innerHTML = makes.map(make => {
const makeModels = models.filter(m => m.make_id === make.id);
const isExpanded = SettingsState.expandedMakes[make.id];
const isAdding = SettingsState.addingModelFor === make.id;
let html = `<div class="settings-item make-item ${isExpanded ? 'expanded' : ''}" data-action="toggleMake" data-id="${make.id}">
<span class="si-name">${esc(make.name)}</span>
<span class="si-meta">${makeModels.length} model${makeModels.length !== 1 ? 's' : ''}</span>
${admin ? `
<div class="si-actions" onclick="event.stopPropagation()">
<button class="si-btn" data-action="settingsEdit" data-entity="makes" data-id="${make.id}">✏️</button>
<button class="si-btn delete" data-action="settingsDelete" data-entity="makes" data-id="${make.id}">🗑</button>
</div>` : ''}
<span class="si-chevron">▶</span>
</div>`;
// Model sub-list
html += `<div class="models-sublist ${isExpanded ? 'open' : ''}" data-make-id="${make.id}">`;
makeModels.forEach(model => {
html += `<div class="settings-item">
${model.icon_path ? `<img class="icon-thumb-small" src="${esc(model.icon_path)}" alt="">` : '<span class="si-icon">📦</span>'}
<span class="si-name">${esc(model.name)}</span>
${admin ? `
<div class="si-actions">
<button class="si-btn" data-action="settingsEdit" data-entity="models" data-id="${model.id}">✏️</button>
<button class="si-btn delete" data-action="settingsDelete" data-entity="models" data-id="${model.id}">🗑</button>
</div>` : ''}
</div>`;
});
// Add model inline form
if (isAdding) {
html += `<div class="add-model-row">
<input type="text" class="input-field" id="newModelName_${make.id}" placeholder="Model name" style="margin-bottom:0;">
<input type="file" id="newModelIcon_${make.id}" accept="image/*" style="display:none;">
<button class="si-btn" onclick="document.getElementById('newModelIcon_${make.id}').click()" title="Upload icon">🖼</button>
<button class="si-btn" style="color:var(--green)" data-action="saveModel" data-make="${make.id}">✓</button>
<button class="si-btn" style="color:var(--red)" data-action="cancelAddModel">✕</button>
</div>`;
} else if (admin) {
html += `<div class="add-model-row">
<button class="btn btn-sm btn-outline" style="width:100%;font-size:12px;" data-action="showAddModel" data-make="${make.id}">+ Add Model</button>
</div>`;
}
html += '</div>';
return html;
}).join('');
}
// ── Users renderer ──
function renderUsers() {
const users = SettingsState.users || [];
const geofences = SettingsState.geofences || [];
const el = document.getElementById('settingsUsers');
if (!users.length) {
el.innerHTML = '<div class="empty-state" style="padding:16px;font-size:12px;">No users found</div>';
return;
}
el.innerHTML = users.map(u => {
const userGeofences = geofences.filter(gf =>
(gf.assigned_users || []).some(au => au.id === u.id)
);
const serviceHtml = userGeofences.length > 0
? `<div style="font-size:10px;color:var(--text2);margin-top:2px;">
📍 ${userGeofences.map(gf => `<span style="color:${esc(gf.color || '#3388ff')};">●</span> ${esc(gf.name)}`).join(' · ')}
</div>`
: '';
return `
<div class="settings-item" data-id="${u.id}">
<span class="si-icon" style="font-size:16px;">👤</span>
<div style="flex:1;min-width:0;">
<div style="display:flex;align-items:center;gap:6px;">
<span class="si-name" style="flex:none;">${esc(u.username)}</span>
<span class="role-badge ${esc(u.role)}">${esc(u.role)}</span>
</div>
${serviceHtml}
</div>
${isAdmin() ? `
<div class="si-actions">
<button class="si-btn" data-action="editUser" data-id="${u.id}">✏️</button>
<button class="si-btn delete" data-action="deleteUser" data-id="${u.id}">🗑</button>
</div>` : ''}
</div>
`}).join('');
}
// ── Settings item add (shows inline form) ──
function showAddForm(entity) {
const containerId = entity === 'makes' || entity === 'models' ? 'settingsMakes' :
entity === 'categories' ? 'settingsCategories' :
entity === 'key_names' ? 'settingsKeyNames' :
entity === 'key_types' ? 'settingsKeyTypes' :
entity === 'badge_types' ? 'settingsBadgeTypes' : null;
if (!containerId) return;
const el = document.getElementById(containerId);
const existing = el.querySelector('.settings-inline-form');
if (existing) existing.remove();
const isCategory = entity === 'categories';
const form = document.createElement('div');
form.className = 'settings-inline-form';
form.innerHTML = `
${isCategory ? `<div class="emoji-picker" id="emojiPicker_${entity}">${DEFAULT_EMOJIS.map((e,i) => `<span class="emoji-opt${i===0?' selected':''}" data-emoji="${e}">${e}</span>`).join('')}</div>` : ''}
<input type="text" class="input-field" id="addInput_${entity}" placeholder="${isCategory ? 'Category name' : 'Name'}" style="margin-bottom:0;">
<div class="si-actions">
<button class="si-btn" style="color:var(--green);" data-action="saveNewItem" data-entity="${entity}">✓</button>
<button class="si-btn" style="color:var(--red);" data-action="cancelAddForm" data-entity="${entity}">✕</button>
</div>
`;
el.insertBefore(form, el.firstChild);
// Emoji picker clicks
if (isCategory) {
form.querySelectorAll('.emoji-opt').forEach(opt => {
opt.addEventListener('click', function() {
form.querySelectorAll('.emoji-opt').forEach(o => o.classList.remove('selected'));
this.classList.add('selected');
});
});
}
setTimeout(() => form.querySelector('input').focus(), 100);
}
function cancelAddForm(entity) {
const containerId = entity === 'makes' || entity === 'models' ? 'settingsMakes' :
entity === 'categories' ? 'settingsCategories' :
entity === 'key_names' ? 'settingsKeyNames' :
entity === 'key_types' ? 'settingsKeyTypes' :
entity === 'badge_types' ? 'settingsBadgeTypes' : null;
if (!containerId) return;
const form = document.getElementById(containerId).querySelector('.settings-inline-form');
if (form) form.remove();
renderAllSettings();
}
async function saveNewItem(entity) {
const input = document.getElementById(`addInput_${entity}`);
if (!input) return;
const name = input.value.trim();
if (!name) { showToast('Name is required', true); return; }
let payload = { name };
if (entity === 'categories') {
const sel = document.querySelector(`#emojiPicker_${entity} .emoji-opt.selected`);
payload.icon = sel ? sel.dataset.emoji : '📂';
}
try {
await api(`/api/settings/${entity}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
});
showToast('Added!');
await loadEntity(entity);
renderAllSettings();
} catch (e) { showToast(e.message, true); }
}
// ── Settings item edit (show inline form pre-filled) ──
function showEditForm(entity, id) {
const item = (SettingsState[entity] || []).find(x => x.id === id);
if (!item) return;
const containerId = entity === 'makes' || entity === 'models' ? 'settingsMakes' :
entity === 'categories' ? 'settingsCategories' :
entity === 'key_names' ? 'settingsKeyNames' :
entity === 'key_types' ? 'settingsKeyTypes' :
entity === 'badge_types' ? 'settingsBadgeTypes' : null;
if (!containerId) return;
const el = document.getElementById(containerId);
const existing = el.querySelector('.settings-inline-form');
if (existing) existing.remove();
const isCategory = entity === 'categories';
const form = document.createElement('div');
form.className = 'settings-inline-form';
form.innerHTML = `
${isCategory ? `<div class="emoji-picker" id="editEmoji_${entity}_${id}">${DEFAULT_EMOJIS.map(e => `<span class="emoji-opt${e === (item.icon||'📂') ? ' selected' : ''}" data-emoji="${e}">${e}</span>`).join('')}</div>` : ''}
<input type="text" class="input-field" id="editInput_${entity}_${id}" value="${esc(item.name)}" style="margin-bottom:0;">
<div class="si-actions">
<button class="si-btn" style="color:var(--green);" data-action="saveEditItem" data-entity="${entity}" data-id="${id}">✓</button>
<button class="si-btn" style="color:var(--red);" data-action="cancelAddForm" data-entity="${entity}">✕</button>
</div>
`;
el.insertBefore(form, el.firstChild);
if (isCategory) {
form.querySelectorAll('.emoji-opt').forEach(opt => {
opt.addEventListener('click', function() {
form.querySelectorAll('.emoji-opt').forEach(o => o.classList.remove('selected'));
this.classList.add('selected');
});
});
}
setTimeout(() => form.querySelector('input').focus(), 100);
}
async function saveEditItem(entity, id) {
const input = document.getElementById(`editInput_${entity}_${id}`);
if (!input) return;
const name = input.value.trim();
if (!name) { showToast('Name is required', true); return; }
let payload = { name };
if (entity === 'categories') {
const sel = document.querySelector(`#editEmoji_${entity}_${id} .emoji-opt.selected`);
if (sel) payload.icon = sel.dataset.emoji;
}
try {
await api(`/api/settings/${entity}/${id}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
});
showToast('Updated!');
await loadEntity(entity);
renderAllSettings();
} catch (e) { showToast(e.message, true); }
}
// ── Settings item delete ──
async function deleteSettingsItem(entity, id) {
const item = (SettingsState[entity] || []).find(x => x.id === id);
const name = item ? item.name : 'this item';
const conf = await showModal('Delete', `Delete "${name}"? This cannot be undone.`, 'Delete');
if (!conf) return;
try {
await api(`/api/settings/${entity}/${id}`, { method: 'DELETE' });
showToast('Deleted');
await loadEntity(entity);
if (entity === 'models') await loadEntity('makes');
renderAllSettings();
} catch (e) { showToast(e.message, true); }
}
// ── Make expand/collapse ──
function toggleMake(makeId) {
SettingsState.expandedMakes[makeId] = !SettingsState.expandedMakes[makeId];
renderMakesModels();
}
function showAddModel(makeId) {
SettingsState.addingModelFor = makeId;
renderMakesModels();
}
function cancelAddModel() {
SettingsState.addingModelFor = null;
renderMakesModels();
}
async function saveModel(makeId) {
const nameInput = document.getElementById(`newModelName_${makeId}`);
const iconInput = document.getElementById(`newModelIcon_${makeId}`);
const name = nameInput ? nameInput.value.trim() : '';
if (!name) { showToast('Model name is required', true); return; }
let payload = { make_id: makeId, name, icon_path: '' };
// Upload icon if selected
if (iconInput && iconInput.files.length) {
try {
const formData = new FormData();
formData.append('file', iconInput.files[0]);
const result = await api('/api/upload/icon', {
method: 'POST', body: formData,
});
payload.icon_path = result.path;
} catch (e) {
showToast('Icon upload failed: ' + e.message, true);
return;
}
}
try {
await api('/api/settings/models', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
});
showToast('Model added!');
SettingsState.addingModelFor = null;
await loadEntity('models');
renderMakesModels();
} catch (e) { showToast(e.message, true); }
}
// ── User management ──
function showAddUserForm() {
const el = document.getElementById('settingsUsers');
const existing = el.querySelector('.user-detail-inline');
if (existing) existing.remove();
const form = document.createElement('div');
form.className = 'user-detail-inline';
form.innerHTML = `
<input type="text" class="input-field" id="newUsername" placeholder="Username">
<input type="password" class="input-field" id="newPassword" placeholder="Password">
<select class="input-field" id="newRole">
<option value="technician">Technician</option>
<option value="admin">Admin</option>
<option value="readonly">Read-only</option>
</select>
<div style="display:flex;gap:8px;">
<button class="btn btn-sm btn-primary" style="flex:1;" data-action="saveNewUser">Create User</button>
<button class="btn btn-sm btn-outline" style="flex:1;" data-action="cancelAddUser">Cancel</button>
</div>
`;
el.insertBefore(form, el.firstChild);
setTimeout(() => form.querySelector('#newUsername').focus(), 100);
}
async function saveNewUser() {
const username = document.getElementById('newUsername').value.trim();
const password = document.getElementById('newPassword').value.trim();
const role = document.getElementById('newRole').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 }),
});
showToast('User created!');
await loadUsers();
renderUsers();
} catch (e) { showToast(e.message, true); }
}
function cancelAddUser() { renderUsers(); }
function showEditUserForm(userId) {
const user = (SettingsState.users || []).find(u => u.id === userId);
if (!user) return;
const el = document.getElementById('settingsUsers');
const existing = el.querySelector('.user-detail-inline');
if (existing) existing.remove();
const form = document.createElement('div');
form.className = 'user-detail-inline';
form.innerHTML = `
<div style="font-weight:600;margin-bottom:6px;">Edit: ${esc(user.username)}</div>
<select class="input-field" id="editRole_${userId}">
<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>
<input type="password" class="input-field" id="editPassword_${userId}" placeholder="New password (leave blank to keep)">
<div style="display:flex;gap:8px;">
<button class="btn btn-sm btn-primary" style="flex:1;" data-action="saveEditUser" data-id="${userId}">Save</button>
<button class="btn btn-sm btn-outline" style="flex:1;" data-action="cancelAddUser">Cancel</button>
</div>
`;
el.insertBefore(form, el.firstChild);
}
async function saveEditUser(userId) {
const roleEl = document.getElementById(`editRole_${userId}`);
const pwEl = document.getElementById(`editPassword_${userId}`);
const payload = {};
if (roleEl) payload.role = roleEl.value;
if (pwEl && pwEl.value.trim()) payload.password = pwEl.value.trim();
if (!Object.keys(payload).length) { cancelAddUser(); return; }
try {
await api(`/api/users/${userId}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
});
showToast('User updated!');
await loadUsers();
renderUsers();
} catch (e) { showToast(e.message, true); }
}
async function deleteUserById(userId) {
const user = (SettingsState.users || []).find(u => u.id === userId);
const name = user ? user.username : 'this user';
const conf = await showModal('Delete User', `Delete user "${name}"? This cannot be undone.`, 'Delete');
if (!conf) return;
try {
await api(`/api/users/${userId}`, { method: 'DELETE' });
showToast('User deleted');
await loadUsers();
renderUsers();
} catch (e) { showToast(e.message, true); }
}
// ── App Configuration ──
async function resetDatabase() {
const conf = await showModal('Reset Database',
'This will DELETE ALL DATA and re-seed the database. This cannot be undone!\n\nType "RESET" to confirm.',
'Reset Database');
if (!conf) return;
// Double-check with prompt
const input = prompt('Type RESET to confirm database wipe:');
if (input !== 'RESET') { showToast('Cancelled'); return; }
try {
await api('/api/admin/reset-db', { method: 'POST' });
showToast('Database reset! Refreshing...');
setTimeout(() => location.reload(), 1500);
} catch (e) {
showToast('Reset failed: ' + e.message, true);
}
}
// ── Register settings action handlers ──
registerAction('settingsAdd', (el) => { showAddForm(el.dataset.entity); });
registerAction('settingsEdit', (el) => { showEditForm(el.dataset.entity, parseInt(el.dataset.id)); });
registerAction('settingsDelete', (el) => { deleteSettingsItem(el.dataset.entity, parseInt(el.dataset.id)); });
registerAction('toggleMake', (el) => { toggleMake(parseInt(el.dataset.id)); });
registerAction('showAddModel', (el) => { showAddModel(parseInt(el.dataset.make)); });
registerAction('saveModel', (el) => { saveModel(parseInt(el.dataset.make)); });
registerAction('cancelAddModel', () => { cancelAddModel(); });
registerAction('saveNewItem', (el) => { saveNewItem(el.dataset.entity); });
registerAction('cancelAddForm', (el) => { cancelAddForm(el.dataset.entity); });
registerAction('saveEditItem', (el) => { saveEditItem(el.dataset.entity, parseInt(el.dataset.id)); });
registerAction('addUser', () => { showAddUserForm(); });
registerAction('editUser', (el) => { showEditUserForm(parseInt(el.dataset.id)); });
registerAction('deleteUser', (el) => { deleteUserById(parseInt(el.dataset.id)); });
registerAction('saveNewUser', () => { saveNewUser(); });
registerAction('cancelAddUser', () => { cancelAddUser(); });
registerAction('saveEditUser', (el) => { saveEditUser(parseInt(el.dataset.id)); });
registerAction('resetDatabase', () => { resetDatabase(); });
// ═══════════════════════════════════════════════════════════════════════
// INIT
// ═══════════════════════════════════════════════════════════════════════
initAuth();
initGPS();
startScanning();
loadAssets();
</script>
</body>
</html>