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

8964 lines
413 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>
<!-- Marker Clustering -->
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css" />
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.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); }
.gps-badge.dismissed { background: var(--card2); color: var(--text2); }
.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.06);
box-shadow: inset 3px 0 0 var(--accent);
}
.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 (with slide transition)
═══════════════════════════════════════════════════════════════════════ */
.tab-panel {
display: none; padding: 0 12px;
opacity: 1; transform: translateX(0);
}
.tab-panel.active {
display: block;
}
.tab-panel.anim-exit {
display: block !important;
position: absolute; top: var(--header-height, 52px); left: 0; right: 0;
animation: tabSlideOutLeft 0.22s cubic-bezier(0.4, 0, 0.2, 1) forwards;
z-index: 1; pointer-events: none;
}
.tab-panel.anim-exit-reverse {
display: block !important;
position: absolute; top: var(--header-height, 52px); left: 0; right: 0;
animation: tabSlideOutRight 0.22s cubic-bezier(0.4, 0, 0.2, 1) forwards;
z-index: 1; pointer-events: none;
}
.tab-panel.anim-enter {
display: block;
animation: tabSlideIn 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.tab-panel.anim-enter-reverse {
display: block;
animation: tabSlideInReverse 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes tabSlideIn {
from { opacity: 0; transform: translateX(24px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes tabSlideInReverse {
from { opacity: 0; transform: translateX(-24px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes tabSlideOutLeft {
from { opacity: 1; transform: translateX(0); }
to { opacity: 0; transform: translateX(-24px); }
}
@keyframes tabSlideOutRight {
from { opacity: 1; transform: translateX(0); }
to { opacity: 0; transform: translateX(24px); }
}
/* ═══════════════════════════════════════════════════════════════════════
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; position: relative;
}
.tab-btn.active { color: var(--accent2); }
.tab-btn .tab-icon { font-size: 22px; transition: transform 0.15s; }
.tab-btn.active .tab-icon { transform: scale(1.1); }
/* Label text visible only on active tab (MD3 style) — always take space to prevent layout jump */
.tab-btn .tab-label { opacity: 0; font-size: 10px; transition: opacity 0.15s; }
.tab-btn.active .tab-label { opacity: 1; }
/* ═══════════════════════════════════════════════════════════════════════
MORE POPOVER (bottom sheet for overflow nav)
═══════════════════════════════════════════════════════════════════════ */
.more-popover-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.5);
z-index: 600; opacity: 0; pointer-events: none;
transition: opacity 0.2s;
}
.more-popover-overlay.open { opacity: 1; pointer-events: auto; }
.more-popover {
position: fixed; bottom: calc(var(--tab-height) + 8px);
left: 50%; transform: translateX(-50%) translateY(20px);
width: calc(100% - 24px); max-width: 456px;
background: var(--card); border: 1px solid var(--border);
border-radius: var(--radius);
z-index: 601; padding: 6px 0;
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 -4px 20px rgba(0,0,0,0.3);
}
.more-popover.open { transform: translateX(-50%) translateY(0); }
.more-popover .mp-item {
display: flex; align-items: center; gap: 12px;
padding: 13px 18px; color: var(--text); font-size: 15px;
cursor: pointer; border: none; background: transparent; width: 100%;
text-align: left; font-weight: 500;
-webkit-tap-highlight-color: transparent;
transition: background 0.1s;
}
.more-popover .mp-item:active { background: rgba(255,255,255,0.05); }
.more-popover .mp-item .mp-icon { font-size: 20px; width: 28px; text-align: center; flex-shrink: 0; }
/* ═══════════════════════════════════════════════════════════════════════
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.18s, transform 0.18s, background 0.18s, box-shadow 0.18s;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
.btn:hover { transform: scale(1.03); }
.btn:active { transform: scale(0.96); }
.btn-primary { background: var(--accent); color: #fff; width: 100%; }
.btn-primary:hover { background: var(--accent2); box-shadow: 0 2px 12px rgba(91,110,247,0.25); }
.btn-sm { padding: 8px 14px; font-size: 13px; width: auto; }
.btn-outline { background: transparent; border: 1.5px solid var(--border); color: var(--text); }
.btn-outline:hover { border-color: var(--text3); background: rgba(255,255,255,0.03); }
.btn-danger { background: transparent; border: 1.5px solid var(--red); color: var(--red); }
.btn-danger:hover { background: var(--red-bg); }
.btn-green { background: var(--green); color: #000; }
.btn-green:hover { box-shadow: 0 2px 12px rgba(74,222,128,0.25); }
.btn-icon {
width: 44px; height: 44px; padding: 0; border-radius: 50%;
background: var(--card2); border: 1px solid var(--border);
color: var(--text); font-size: 20px;
display: inline-flex; align-items: center; justify-content: center;
transition: transform 0.18s, background 0.18s, box-shadow 0.18s;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
.btn-icon:hover { transform: scale(1.08); background: var(--card); }
.btn-icon:active { transform: scale(0.92); }
.btn-pressed { transform: scale(0.94) !important; transition: transform 0.08s ease-out !important; }
/* ═══════════════════════════════════════════════════════════════════════
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%) translateY(20px);
background: var(--green); color: #000;
font-weight: 700; padding: 10px 22px; border-radius: 20px; font-size: 14px;
z-index: 400; opacity: 0; pointer-events: none;
white-space: nowrap; max-width: 90vw; overflow: hidden; text-overflow: ellipsis;
transition: opacity 0.25s, transform 0.25s;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
.toast.error { background: var(--red); color: #fff; }
.toast-undo-btn {
background: rgba(0,0,0,0.25); border: 1px solid rgba(255,255,255,0.3);
color: #fff; font-weight: 700; font-size: 13px;
padding: 4px 12px; border-radius: 12px; cursor: pointer;
margin-left: 10px; white-space: nowrap;
}
.toast-undo-btn:active { background: rgba(0,0,0,0.4); }
/* ═══════════════════════════════════════════════════════════════════════
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); } }
/* loading overlay for asset list */
.list-loading-overlay {
position: absolute; inset: 0;
background: rgba(13,14,18,0.6); display: flex;
align-items: center; justify-content: center;
z-index: 10; border-radius: var(--radius);
pointer-events: none;
}
.list-loading-overlay .overlay-spinner {
width: 32px; height: 32px; border: 3px solid var(--border);
border-top-color: var(--accent); border-radius: 50%;
animation: spin 0.7s linear infinite;
}
/* ═══════════════════════════════════════════════════════════════════════
SKELETON LOADING (shimmer)
═══════════════════════════════════════════════════════════════════════ */
@keyframes shimmer {
0% { background-position: -400px 0; }
100% { background-position: 400px 0; }
}
.skel {
background: linear-gradient(90deg,
var(--card) 0%,
var(--card2) 40%,
var(--border) 50%,
var(--card2) 60%,
var(--card) 100%
);
background-size: 800px 100%;
animation: shimmer 1.6s ease-in-out infinite;
border-radius: var(--radius-xs);
}
.skel-circle {
border-radius: 50%;
background: linear-gradient(90deg,
var(--card) 0%,
var(--card2) 40%,
var(--border) 50%,
var(--card2) 60%,
var(--card) 100%
);
background-size: 800px 100%;
animation: shimmer 1.6s ease-in-out infinite;
}
.skel-text { height: 14px; margin-bottom: 8px; }
.skel-text:last-child { margin-bottom: 0; }
.skel-text.w80 { width: 80%; }
.skel-text.w60 { width: 60%; }
.skel-text.w40 { width: 40%; }
.skel-text.w30 { width: 30%; }
/* Asset list skeleton */
.skel-asset-item {
display: flex; align-items: center; gap: 12px; padding: 12px;
border-bottom: 1px solid rgba(255,255,255,0.04);
}
.skel-asset-item .skel-circle { width: 42px; height: 42px; flex-shrink: 0; }
.skel-asset-item .skel-lines { flex: 1; min-width: 0; }
/* Customer card skeleton — same layout as asset */
.skel-cust-card {
display: flex; align-items: center; gap: 12px; padding: 12px;
border-bottom: 1px solid rgba(255,255,255,0.04);
}
.skel-cust-card .skel-circle { width: 42px; height: 42px; flex-shrink: 0; }
.skel-cust-card .skel-lines { flex: 1; min-width: 0; }
/* Activity item skeleton */
.skel-act-item {
display: flex; align-items: flex-start; gap: 12px; padding: 12px 0;
border-bottom: 1px solid rgba(255,255,255,0.04);
}
.skel-act-item .skel-circle { width: 36px; height: 36px; flex-shrink: 0; }
.skel-act-item .skel-lines { flex: 1; min-width: 0; }
/* Stat card skeleton */
.skel-stat-card {
background: var(--card); border: 1px solid var(--border);
border-radius: var(--radius); padding: 16px; text-align: center;
}
.skel-stat-value { width: 60%; height: 28px; margin: 0 auto 8px auto; }
.skel-stat-label { width: 40%; height: 10px; margin: 0 auto; }
/* Dashboard stat bar skeleton */
.skel-stat-bar {
display: flex; align-items: center; gap: 10px; padding: 8px 0;
}
.skel-stat-bar .skel-bar-label { width: 80px; height: 13px; }
.skel-stat-bar .skel-bar-track { flex: 1; height: 8px; }
/* Report table skeleton */
.skel-rpt-row {
display: flex; gap: 8px; padding: 8px 0;
border-bottom: 1px solid rgba(255,255,255,0.03);
}
.skel-rpt-row .skel-cell { height: 13px; }
.skel-rpt-row .skel-cell.c1 { flex: 1.5; }
.skel-rpt-row .skel-cell.c2 { flex: 1; }
.skel-rpt-row .skel-cell.c3 { flex: 0.7; }
/* Skeleton fade-out when content replaces it */
.skel-wrapper {
transition: opacity 0.2s ease-out;
}
.skel-wrapper.skel-hide {
opacity: 0; pointer-events: none;
}
/* ═══════════════════════════════════════════════════════════════════════
EMPTY STATE
═══════════════════════════════════════════════════════════════════════ */
.ptr-container {
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.ptr-indicator {
display: flex; align-items: center; justify-content: center;
gap: 8px; height: 0; overflow: hidden;
transition: height 0.2s ease;
font-size: 13px; color: var(--text2);
}
.ptr-indicator.pulling { height: 52px; }
.ptr-indicator.refreshing { height: 52px; }
.ptr-spinner {
width: 20px; height: 20px; border: 2px solid var(--border);
border-top-color: var(--accent); border-radius: 50%;
transition: transform 0.2s ease;
}
.ptr-indicator.pulling .ptr-spinner { transform: rotate(0deg); }
.ptr-indicator.ready .ptr-spinner { transform: rotate(180deg); }
.ptr-indicator.refreshing .ptr-spinner {
animation: spin 0.7s linear infinite;
}
.ptr-icon { font-size: 18px; transition: transform 0.2s ease; }
.ptr-indicator.ready .ptr-icon { transform: rotate(180deg); }
/* ═══════════════════════════════════════════════════════════════════════
SWIPE-TO-DELETE (mobile touch)
═══════════════════════════════════════════════════════════════════════ */
.asset-item-swipe-wrap {
position: relative; overflow: hidden;
border-bottom: 1px solid rgba(255,255,255,0.04);
}
.swipe-delete-bg {
position: absolute; inset: 0; display: flex; align-items: center;
justify-content: flex-end; padding: 0 16px;
background: var(--red); z-index: 0; pointer-events: none;
font-size: 13px; font-weight: 600; color: #fff;
}
.swipe-delete-bg span { opacity: 0; transition: opacity 0.1s; }
.swipe-delete-bg.revealed span { opacity: 1; }
.asset-item.swipeable {
position: relative; z-index: 1; background: var(--bg);
transition: transform 0.2s ease; will-change: transform;
}
.asset-item.swiped { transform: translateX(-80px); }
.asset-item.swipe-to-delete {
position: relative; z-index: 1;
}
/* ═══════════════════════════════════════════════════════════════════════
DESKTOP-ONLY REFRESH BUTTON
═══════════════════════════════════════════════════════════════════════ */
.desktop-refresh-btn {
display: none; /* hidden by default, shown on non-touch */
margin-left: auto; padding: 6px 14px;
font-size: 13px; border: 1px solid var(--border);
border-radius: var(--radius-sm); background: var(--card);
color: var(--text2); cursor: pointer;
transition: all 0.15s;
-webkit-tap-highlight-color: transparent;
}
.desktop-refresh-btn:hover { background: var(--card2); color: var(--text); }
body.has-mouse .desktop-refresh-btn { display: inline-flex; align-items: center; gap: 6px; }
/* ═══════════════════════════════════════════════════════════════════════
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; }
/* Enhanced empty-state card — full-page onboarding pattern */
.empty-state-card {
display: flex; flex-direction: column; align-items: center; justify-content: center;
text-align: center; padding: 48px 24px; min-height: 240px;
}
.esc-icon {
font-size: 56px; margin-bottom: 16px; opacity: 0.35;
width: 88px; height: 88px; border-radius: 50%;
background: var(--card2); display: flex; align-items: center; justify-content: center;
}
.esc-heading {
font-size: 17px; font-weight: 700; color: var(--text);
margin-bottom: 6px;
}
.esc-desc {
font-size: 13px; color: var(--text2); max-width: 280px;
line-height: 1.5; margin-bottom: 20px;
}
.esc-cta {
display: inline-flex; align-items: center; gap: 6px;
padding: 10px 24px; font-size: 14px; font-weight: 600;
background: var(--accent); color: #fff; border: none;
border-radius: var(--radius-sm); cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: background 0.15s;
}
.esc-cta:active { background: var(--accent2); }
.esc-cta-sm {
display: inline-flex; align-items: center; gap: 4px;
padding: 6px 14px; font-size: 12px; font-weight: 600;
background: var(--accent); color: #fff; border: none;
border-radius: var(--radius-xs); cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: background 0.15s;
}
.esc-cta-sm:active { background: var(--accent2); }
/* Onboarding welcome overlay */
.onboard-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.7);
z-index: 600; display: flex; align-items: center; justify-content: center;
padding: 20px; opacity: 0; pointer-events: none; transition: opacity 0.25s;
}
.onboard-overlay.open { opacity: 1; pointer-events: auto; }
.onboard-card {
background: var(--card); border: 1px solid var(--border);
border-radius: var(--radius); padding: 28px 24px; max-width: 340px;
text-align: center; width: 100%;
}
.onboard-card .esc-icon { margin: 0 auto 16px; }
/* Guidance card — reports empty state */
.guidance-card {
text-align: center; padding: 24px 16px;
}
.gc-icon {
font-size: 40px; margin-bottom: 12px; opacity: 0.5;
}
.gc-title {
font-size: 15px; font-weight: 600; color: var(--text);
margin-bottom: 6px;
}
.gc-hint {
font-size: 12px; color: var(--text2); line-height: 1.5;
max-width: 300px; margin: 0 auto;
}
/* ═══════════════════════════════════════════════════════════════════════
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; }
.modal-prompt-area {
margin-bottom: 12px; text-align: left;
}
.modal-prompt-label {
display: block; font-size: 12px; color: var(--text2); margin-bottom: 4px;
}
/* GPS warning banner in Navigate tab */
.gps-warning {
background: var(--amber-bg); color: var(--amber);
padding: 10px 14px; border-radius: var(--radius-sm);
font-size: 13px; margin-bottom: 12px;
display: flex; align-items: center; gap: 8px; line-height: 1.4;
}
.gps-enable-link {
text-decoration: underline; cursor: pointer; font-weight: 600;
white-space: nowrap;
}
.gps-enable-link:active { color: var(--accent2); }
.nav-route-info {
display: flex; gap: 12px; flex-wrap: wrap;
}
.nav-route-item {
flex: 1; min-width: 100px;
}
.nav-route-item .nri-label {
font-size: 11px; color: var(--text3); margin-bottom: 2px;
}
.nav-route-item .nri-value {
font-size: 20px; font-weight: 700;
}
/* Map legend */
.map-legend {
position: absolute; bottom: 10px; right: 10px; z-index: 1000;
background: var(--card); border: 1px solid var(--border);
border-radius: 8px; padding: 8px 10px; font-size: 11px;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
max-height: 200px; overflow-y: auto;
}
.map-legend .legend-title {
font-weight: 700; margin-bottom: 4px; font-size: 12px; color: var(--text);
}
.map-legend .legend-item {
display: flex; align-items: center; gap: 6px; padding: 2px 0;
}
.map-legend .legend-dot {
width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;
border: 1px solid rgba(255,255,255,0.3);
}
/* GPS badge styles */
.gps-badge {
cursor: pointer; position: relative; padding-right: 4px;
}
.gps-badge .gps-dismiss {
display: none; margin-left: 4px; font-size: 14px;
font-weight: bold; color: inherit; opacity: 0.6;
}
.gps-badge.err .gps-dismiss { display: inline; }
.gps-badge .gps-reenable {
font-size: 10px; margin-left: 4px; text-decoration: underline;
cursor: pointer;
}
/* ═══════════════════════════════════════════════════════════════════════
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-location {
font-size: 11px; color: var(--text3); margin-top: 3px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.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;
}
/* search term highlight */
.search-highlight {
background: rgba(91,110,247,0.25);
border-radius: 3px;
padding: 1px 2px;
font-weight: 700;
}
/* count bar */
.count-bar {
font-size: 12px; color: var(--text2);
padding: 6px 0; min-height: 24px;
display: flex; align-items: center; gap: 8px;
}
.count-bar .spinner {
width: 14px; height: 14px; border-width: 2px;
}
/* ═══════════════════════════════════════════════════════════════════════
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: 3/4; display: flex; align-items: center; justify-content: center;
overflow: hidden; margin: 0 -12px 8px -12px; max-height: none;
}
.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; align-items: center; }
.scan-result .sr-checkin-done { font-size: 13px; color: #22c55e; font-weight: 600; white-space: nowrap; }
/* ═══════════════════════════════════════════════════════════════════════
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;
}
/* ═══════════════════════════════════════════════════════════════════════
COLLAPSIBLE FORM SECTIONS (T2 Form UX Overhaul)
═══════════════════════════════════════════════════════════════════════ */
.form-collapse-section {
background: var(--card); border: 1px solid var(--border);
border-radius: var(--radius-sm); margin-bottom: 8px; overflow: hidden;
transition: border-color 0.2s;
}
.form-collapse-section.completed { border-color: var(--green); }
.form-collapse-section.has-error { border-color: var(--red); }
.form-collapse-header {
display: flex; align-items: center; gap: 8px;
padding: 12px 14px; cursor: pointer; user-select: none;
background: var(--card2); transition: background 0.15s;
-webkit-tap-highlight-color: transparent;
}
.form-collapse-header:active { background: var(--border); }
.form-collapse-section.open .form-collapse-header {
border-bottom: 1px solid var(--border);
}
.form-collapse-header .fch-icon { font-size: 16px; width: 22px; text-align: center; flex-shrink: 0; }
.form-collapse-header .fch-title { flex: 1; font-size: 14px; font-weight: 600; }
.form-collapse-header .fch-chevron {
font-size: 12px; color: var(--text2); transition: transform 0.2s; flex-shrink: 0;
}
.form-collapse-section.open .fch-chevron { transform: rotate(180deg); }
.form-collapse-header .fch-status {
font-size: 10px; font-weight: 600; padding: 2px 8px; border-radius: 10px; flex-shrink: 0;
}
.fch-status.complete { background: var(--green-bg); color: var(--green); }
.fch-status.incomplete { background: var(--amber-bg); color: var(--amber); }
.form-collapse-body { display: none; padding: 12px 14px; }
.form-collapse-section.open .form-collapse-body { display: block; }
/* ═══════════════════════════════════════════════════════════════════════
FIELD VALIDATION & REQUIRED INDICATORS
═══════════════════════════════════════════════════════════════════════ */
.input-field.error {
border-color: var(--red) !important;
box-shadow: 0 0 0 1px var(--red);
}
.field-error-msg {
font-size: 11px; color: var(--red); margin-top: -4px; margin-bottom: 8px;
display: none; align-items: center; gap: 4px;
}
.field-error-msg.visible { display: flex; }
.required-star { color: var(--red); font-weight: 700; }
label.field-label {
display: block; font-size: 12px; font-weight: 600;
color: var(--text2); margin-bottom: 4px; margin-top: 2px;
}
/* ═══════════════════════════════════════════════════════════════════════
BUTTON LOADING / SPINNER STATE
═══════════════════════════════════════════════════════════════════════ */
.btn.loading { pointer-events: none; opacity: 0.7; }
.btn .btn-spinner {
display: none; width: 14px; height: 14px; border: 2px solid rgba(255,255,255,0.3);
border-top-color: #fff; border-radius: 50%;
animation: spin 0.6s linear infinite; margin-right: 4px;
}
.btn.loading .btn-spinner { display: inline-block; }
.btn-outline .btn-spinner { border-color: rgba(255,255,255,0.15); border-top-color: var(--text); }
.btn-feedback {
display: none; align-items: center; gap: 4px;
font-size: 12px; color: var(--green); margin-left: 8px;
}
.btn-feedback.visible { display: inline-flex; }
/* ═══════════════════════════════════════════════════════════════════════
AUTO-SAVE INDICATOR
═══════════════════════════════════════════════════════════════════════ */
.autosave-indicator {
font-size: 10px; color: var(--text3);
display: flex; align-items: center; gap: 4px;
padding: 2px 0; margin-bottom: 8px; min-height: 16px;
}
.autosave-indicator.saving { color: var(--amber); }
.autosave-indicator.saved { color: var(--green); }
/* ═══════════════════════════════════════════════════════════════════════
PHOTO PREVIEW ENHANCED
═══════════════════════════════════════════════════════════════════════ */
.photo-preview-wrap {
position: relative; border-radius: var(--radius-sm); overflow: hidden;
margin-bottom: 8px;
}
.photo-preview-wrap img { width: 100%; display: block; border-radius: var(--radius-sm); }
.photo-actions-row { display: flex; gap: 8px; margin-top: 6px; }
.photo-actions-row .btn { flex: 1; }
/* ═══════════════════════════════════════════════════════════════════════
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; }
.stat-bar.clickable { cursor: pointer; border-radius: var(--radius-xs); padding: 4px 6px; margin-left: -6px; margin-right: -6px; transition: background 0.15s; }
.stat-bar.clickable:hover { background: rgba(255,255,255,0.04); }
.stat-bar.clickable:hover .sb-label { color: var(--accent2); }
.stat-bar.clickable:hover .sb-count { color: var(--accent2); }
.stat-bar-tooltip {
position: absolute; background: var(--card); border: 1px solid var(--accent);
border-radius: var(--radius-xs); padding: 6px 10px; font-size: 12px;
color: var(--text); z-index: 300; pointer-events: none; white-space: nowrap;
box-shadow: 0 4px 12px rgba(0,0,0,0.4); display: none;
}
/* ═══════════════════════════════════════════════════════════════════════
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; }
/* report presets */
.rpt-presets { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.rpt-preset {
padding: 5px 12px; border: 1px solid var(--border); border-radius: 16px;
background: transparent; color: var(--text2); font-size: 12px; cursor: pointer;
transition: all 0.15s; white-space: nowrap;
}
.rpt-preset:hover { border-color: var(--accent); color: var(--accent); }
.rpt-preset.active {
background: var(--accent); color: #000; border-color: var(--accent);
font-weight: 600;
}
/* report download row */
.rpt-download-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 6px; }
.rpt-download-btn { white-space: nowrap; }
/* report loading */
.rpt-loading {
display: flex; align-items: center; gap: 8px; padding: 8px 0;
font-size: 13px; color: var(--text2);
}
/* map link button in report tables */
.btn-map-link {
padding: 3px 10px; border: 1px solid var(--border); border-radius: 14px;
background: transparent; color: var(--accent); font-size: 11px;
cursor: pointer; white-space: nowrap; transition: all 0.15s;
}
.btn-map-link:hover { background: var(--accent); color: #000; border-color: var(--accent); }
/* ═══════════════════════════════════════════════════════════════════════
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; }
/* Danger Zone */
.danger-zone { margin-top: 12px; border: 1.5px solid var(--red); border-radius: var(--radius); overflow: hidden; }
.danger-zone-header {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 16px; background: var(--red-bg); cursor: pointer;
font-size: 14px; font-weight: 600; color: var(--red);
-webkit-tap-highlight-color: transparent; user-select: none;
}
.danger-zone-header:active { opacity: 0.8; }
.danger-zone-header .dz-chevron { transition: transform 0.2s; font-size: 12px; }
.danger-zone.open .danger-zone-header .dz-chevron { transform: rotate(90deg); }
.danger-zone-body {
display: none; padding: 12px 16px;
font-size: 12px; color: var(--text2); background: var(--card);
}
.danger-zone.open .danger-zone-body { display: block; }
.danger-zone-body .btn-danger { margin-top: 10px; }
/* 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);
}
/* ═══════════════════════════════════════════════════════════════════════
SKELETON LOADING & SHIMMER
═══════════════════════════════════════════════════════════════════════ */
.skeleton {
background: linear-gradient(90deg, var(--card2) 25%, var(--border) 50%, var(--card2) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite ease-in-out;
border-radius: var(--radius-xs);
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.skeleton-card {
background: var(--card); border: 1px solid var(--border);
border-radius: var(--radius); padding: 16px; margin-bottom: 10px;
}
.skeleton-line { height: 13px; margin-bottom: 10px; border-radius: 4px; }
.skeleton-line.short { width: 55%; }
.skeleton-line.medium { width: 75%; }
.skeleton-circle {
width: 42px; height: 42px; border-radius: 50%; flex-shrink: 0;
}
.skeleton-block { height: 70px; border-radius: var(--radius-sm); margin-bottom: 10px; }
/* Asset list skeleton rows */
.asset-skeleton {
display: flex; align-items: center; gap: 12px;
padding: 14px 12px; border-bottom: 1px solid rgba(255,255,255,0.04);
}
.asset-skeleton .skel-icon {
width: 42px; height: 42px; border-radius: var(--radius-sm);
}
.asset-skeleton .skel-lines { flex: 1; display: flex; flex-direction: column; gap: 7px; }
.asset-skeleton .skel-line { height: 12px; border-radius: 4px; }
.asset-skeleton .skel-line.s1 { width: 65%; }
.asset-skeleton .skel-line.s2 { width: 45%; }
.asset-skeleton .skel-line.s3 { width: 30%; }
/* Dashboard shimmer placeholders */
.shimmer-stat {
height: 40px; border-radius: var(--radius);
}
.shimmer-bar {
height: 13px; border-radius: 4px; margin-bottom: 8px;
}
/* ═══════════════════════════════════════════════════════════════════════
PAGE TRANSITION ENHANCEMENTS
═══════════════════════════════════════════════════════════════════════ */
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeInScale {
from { opacity: 0; transform: scale(0.96); }
to { opacity: 1; transform: scale(1); }
}
.fade-in-up { animation: fadeInUp 0.35s ease-out; }
.fade-in-scale { animation: fadeInScale 0.3s ease-out; }
.page-enter {
animation: tabSlideIn 0.28s cubic-bezier(0.4, 0, 0.2, 1);
}
/* ═══════════════════════════════════════════════════════════════════════
PULL-TO-REFRESH
═══════════════════════════════════════════════════════════════════════ */
.undo-toast {
position: fixed; bottom: calc(var(--tab-height) + 12px);
left: 50%; transform: translateX(-50%);
background: var(--card2); border: 1px solid var(--border);
color: var(--text); font-weight: 600;
padding: 10px 18px; border-radius: 20px; font-size: 13px;
z-index: 460; opacity: 0; transition: opacity 0.3s, transform 0.3s;
pointer-events: none; white-space: nowrap; display: flex;
align-items: center; gap: 10px; box-shadow: 0 4px 16px rgba(0,0,0,0.3);
}
.undo-toast.show { opacity: 1; pointer-events: auto; transform: translateX(-50%) translateY(0); }
.undo-toast .undo-btn {
background: var(--accent); color: #fff; border: none;
padding: 5px 14px; border-radius: 12px; font-size: 12px;
font-weight: 600; cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
/* ═══════════════════════════════════════════════════════════════════════
FLOATING ACTION BUTTON
═══════════════════════════════════════════════════════════════════════ */
.fab {
position: fixed; bottom: calc(var(--tab-height) + 20px); right: 20px;
width: 56px; height: 56px; border-radius: 50%; background: var(--accent);
color: #fff; border: none; display: flex; align-items: center;
justify-content: center; font-size: 24px; cursor: pointer;
z-index: 150; box-shadow: 0 4px 18px rgba(91,110,247,0.45);
transition: transform 0.2s, box-shadow 0.2s, opacity 0.25s;
-webkit-tap-highlight-color: transparent;
}
.fab:active { transform: scale(0.88); box-shadow: 0 2px 8px rgba(91,110,247,0.3); }
.fab:hover { transform: scale(1.08); box-shadow: 0 6px 24px rgba(91,110,247,0.55); }
@keyframes fabPulse {
0%, 100% { box-shadow: 0 4px 18px rgba(91,110,247,0.45); }
50% { box-shadow: 0 4px 28px rgba(91,110,247,0.65); }
}
.fab.pulse { animation: fabPulse 2.5s ease-in-out infinite; }
.fab.hidden-fab { opacity: 0; pointer-events: none; transform: scale(0.5); }
/* ═══════════════════════════════════════════════════════════════════════
HAPTIC BUTTON MICRO-INTERACTIONS
═══════════════════════════════════════════════════════════════════════ */
.btn-haptic:active {
animation: hapticTap 0.15s ease-out;
}
@keyframes hapticTap {
0% { transform: scale(0.96); }
50% { transform: scale(0.94); }
100% { transform: scale(0.98); }
}
.btn, .tab-btn, .qa-btn, .pill, .mode-toggle, .fab {
-webkit-tap-highlight-color: transparent;
user-select: none;
}
/* Ripple effect for buttons */
.btn-ripple-wrap {
position: relative; overflow: hidden;
}
@keyframes btnRipple {
to { transform: scale(4); opacity: 0; }
}
.ripple-effect {
position: absolute; border-radius: 50%;
background: rgba(255,255,255,0.25);
transform: scale(0); animation: btnRipple 0.5s linear;
pointer-events: none;
}
/* ═══════════════════════════════════════════════════════════════════════
SUCCESS / CONFIRMATION FEEDBACK
═══════════════════════════════════════════════════════════════════════ */
.success-overlay {
position: fixed; inset: 0; background: rgba(74,222,128,0.18);
z-index: 470; display: flex; align-items: center; justify-content: center;
opacity: 0; pointer-events: none; transition: opacity 0.3s;
backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px);
}
.success-overlay.show { opacity: 1; }
.success-check {
width: 72px; height: 72px; border-radius: 50%;
background: var(--green); display: flex; align-items: center;
justify-content: center; font-size: 36px; color: #000;
animation: successPop 0.45s cubic-bezier(0.68, -0.55, 0.27, 1.55);
}
@keyframes successPop {
0% { transform: scale(0); opacity: 0; }
60% { transform: scale(1.15); }
100% { transform: scale(1); opacity: 1; }
}
.success-overlay.warning .success-check {
background: var(--amber); color: #000;
}
/* ═══════════════════════════════════════════════════════════════════════
EMPTY STATE ANIMATION
═══════════════════════════════════════════════════════════════════════ */
.empty-state-card { animation: fadeInUp 0.45s ease-out; }
.empty-state { animation: fadeInUp 0.35s ease-out; }
.guidance-card { animation: fadeInUp 0.4s ease-out; }
/* ═══════════════════════════════════════════════════════════════════════
ENHANCED TOAST ANIMATION
═══════════════════════════════════════════════════════════════════════ */
.toast {
transform: translateX(-50%) translateY(20px);
transition: opacity 0.3s, transform 0.3s;
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.toast.success-toast { background: var(--green); color: #000; }
.toast.error { background: var(--red); color: #fff; }
/* =========================================================================
PREFERS REDUCED MOTION
========================================================================= */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
</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%;
position: relative;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius);
}
#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 class="gps-dismiss" onclick="event.stopPropagation();dismissGPS()"></span></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> Assets
</button>
<button class="dn-item" data-tab="tabMap" onclick="navFromDrawer('tabMap')">
<span class="dn-icon">🗺️</span> Map
</button>
<button class="dn-item" data-tab="tabNavigate" onclick="navFromDrawer('tabNavigate')">
<span class="dn-icon">🧭</span> Navigate
</button>
<button class="dn-item" data-tab="tabDashboard" onclick="navFromDrawer('tabDashboard')">
<span class="dn-icon">📊</span> Dashboard
</button>
<div style="border-top:1px solid var(--border);margin:6px 16px;"></div>
<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="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">
<!-- Autosave indicator -->
<div id="autosaveIndicator" class="autosave-indicator"></div>
<!-- ── SECTION: IDENTITY ──────────────────────────────────── -->
<div class="form-collapse-section open" id="sectIdentity">
<div class="form-collapse-header" onclick="toggleFormSection('sectIdentity')">
<span class="fch-icon">📋</span>
<span class="fch-title">Identity</span>
<span class="fch-status incomplete" id="statIdentity">required</span>
<span class="fch-chevron"></span>
</div>
<div class="form-collapse-body">
<label class="field-label"><span class="required-star">*</span> Machine ID</label>
<input id="manMachineId" type="text" class="input-field" placeholder="e.g. CAT-001" required
oninput="clearFieldError('manMachineId'); autoSaveDraft();">
<div class="field-error-msg" id="errManMachineId">⚠️ Machine ID is required</div>
<label class="field-label">Serial Number</label>
<input id="manSerialNumber" type="text" class="input-field" placeholder="Serial Number"
oninput="autoSaveDraft();">
<label class="field-label"><span class="required-star">*</span> Asset Name</label>
<input id="manName" type="text" class="input-field" placeholder="e.g. Vending Machine #3" required
oninput="clearFieldError('manName'); autoSaveDraft();">
<div class="field-error-msg" id="errManName">⚠️ Asset Name is required</div>
<label class="field-label">Description</label>
<textarea id="manDescription" class="input-field" placeholder="Description" rows="2"
oninput="autoSaveDraft();"></textarea>
<div class="form-row" style="margin-bottom:8px;">
<select id="manCatSelect" class="input-field" onchange="autoSaveDraft();">
<option value="">Category</option>
</select>
<select id="manMake" class="input-field" onchange="loadModels();autoSaveDraft();">
<option value="">Make</option>
</select>
</div>
<select id="manModel" class="input-field" style="margin-bottom:8px;" onchange="autoSaveDraft();">
<option value="">Model</option>
</select>
<select id="manStatus" class="input-field" style="margin-bottom:0;" onchange="autoSaveDraft();">
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="maintenance">Maintenance</option>
<option value="retired">Retired</option>
</select>
</div>
</div>
<!-- ── SECTION: LOCATION ──────────────────────────────────── -->
<div class="form-collapse-section" id="sectLocation">
<div class="form-collapse-header" onclick="toggleFormSection('sectLocation')">
<span class="fch-icon">📍</span>
<span class="fch-title">Location & Directions</span>
<span class="fch-chevron"></span>
</div>
<div class="form-collapse-body">
<label class="field-label">Address / Trailer Number</label>
<input id="manAddress" type="text" class="input-field" placeholder="Address / Trailer Number"
oninput="autoSaveDraft();">
<div class="form-row">
<input id="manBuildingName" type="text" class="input-field" placeholder="Building Name"
oninput="autoSaveDraft();">
<input id="manBuildingNumber" type="text" class="input-field" placeholder="Building #"
oninput="document.getElementById('manAddress').value = this.value; autoSaveDraft();">
</div>
<div class="form-row">
<input id="manFloor" type="text" class="input-field" placeholder="Floor" oninput="autoSaveDraft();">
<input id="manRoom" type="text" class="input-field" placeholder="Room" oninput="autoSaveDraft();">
</div>
<label class="field-label">Walking Directions</label>
<textarea id="manWalkingDirections" class="input-field" placeholder="Walking directions from parking..." rows="2"
oninput="autoSaveDraft();"></textarea>
<div class="form-row" style="margin-bottom:6px;">
<input id="manMapLink" type="url" class="input-field" placeholder="Map link (URL)"
oninput="autoSaveDraft();">
<button class="btn btn-outline btn-sm" id="btnMapPin" onclick="openMapPin()"
style="flex-shrink:0; min-width: 72px;">
<span class="btn-spinner"></span>📍 Pin
</button>
<span class="btn-feedback" id="fbMapPin">✓ Done</span>
</div>
<div class="form-row">
<input id="manParkingLocation" type="text" class="input-field" placeholder="Parking location"
oninput="autoSaveDraft();">
<button class="btn btn-outline btn-sm" id="btnGpsParking" onclick="fillGpsParking()"
style="flex-shrink:0; min-width: 72px;">
<span class="btn-spinner"></span>📍 GPS
</button>
<span class="btn-feedback" id="fbGpsParking">✓ Done</span>
</div>
</div>
</div>
<!-- ── SECTION: KEYS & BADGES ─────────────────────────────── -->
<div class="form-collapse-section" id="sectAccess">
<div class="form-collapse-header" onclick="toggleFormSection('sectAccess')">
<span class="fch-icon">🔑</span>
<span class="fch-title">Keys & Badges</span>
<span class="fch-chevron"></span>
</div>
<div class="form-collapse-body">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;">
<span style="font-size:12px;font-weight:600;color:var(--text2);">🔑 Keys</span>
<button class="btn btn-outline btn-sm" onclick="addKeyEntry()">+ Add Key</button>
</div>
<div id="manKeysList"></div>
<div style="font-size:12px;font-weight:600;color:var(--text2);margin:10px 0 6px;">🪪 Security Badges</div>
<div class="badge-checklist" id="manBadgesList"></div>
</div>
</div>
<!-- ── SECTION: CUSTOMER ──────────────────────────────────── -->
<div class="form-collapse-section" id="sectCustomer">
<div class="form-collapse-header" onclick="toggleFormSection('sectCustomer')">
<span class="fch-icon">🏢</span>
<span class="fch-title">Customer & Location</span>
<span class="fch-chevron"></span>
</div>
<div class="form-collapse-body">
<label class="field-label">Customer</label>
<select id="manCustomer" class="input-field" onchange="loadManualLocations();autoSaveDraft();">
<option value="">Customer</option>
</select>
<label class="field-label">Location</label>
<select id="manLocation" class="input-field" style="margin-bottom:0;" onchange="autoSaveDraft();">
<option value="">Location</option>
</select>
</div>
</div>
<!-- ── SECTION: PHOTO ─────────────────────────────────────── -->
<div class="form-collapse-section" id="sectPhoto">
<div class="form-collapse-header" onclick="toggleFormSection('sectPhoto')">
<span class="fch-icon">📸</span>
<span class="fch-title">Photo <span style="font-size:11px;color:var(--text3);font-weight:400;">(optional)</span></span>
<span class="fch-chevron"></span>
</div>
<div class="form-collapse-body">
<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>
<div class="photo-preview-wrap" id="manPhotoPreviewWrap" style="display:none;">
<img id="manPhotoPreview" style="width:100%;border-radius:var(--radius-sm);">
<div class="photo-actions-row">
<button class="btn btn-outline btn-sm" onclick="retakeManualPhoto()">🔄 Retake</button>
<button class="btn btn-outline btn-sm" onclick="clearManualPhoto()" style="color:var(--text2);">✕ Remove</button>
</div>
</div>
</div>
</div>
<!-- ── SUBMIT ─────────────────────────────────────────────── -->
<div class="form-row" style="margin-bottom:10px;">
<button class="btn btn-primary" id="btnCreateAsset" onclick="submitManualAsset(false)" style="flex:2;">
<span class="btn-spinner"></span>Create Asset
</button>
<button class="btn btn-outline" onclick="submitManualAsset(true)" style="flex:1;">
<span class="btn-spinner"></span>+ 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">
<!-- Pull-to-refresh indicator (mobile touch) -->
<div class="ptr-indicator" id="ptrIndicator">
<div class="ptr-spinner"></div>
<span class="ptr-icon"></span>
<span class="ptr-label">Pull to refresh</span>
</div>
<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" id="clearFiltersBtn" onclick="clearFilters()" style="display:none;">✕ Clear</button>
<button class="btn btn-outline btn-sm import-btn" onclick="showImportView()">📥 Import</button>
<button class="desktop-refresh-btn" id="desktopRefreshBtn" onclick="loadAssets()" title="Refresh">🔄 Refresh</button>
</div>
<div class="count-bar" id="assetCountBar"></div>
<div id="assetListWrap" style="position:relative; min-height:60px;">
<div id="assetList"></div>
</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>
<button class="btn btn-outline btn-sm" id="detailNavigateBtn" onclick="navigateToAsset()" style="flex:1;min-width:100px;display:none;">🧭 Navigate</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: NAVIGATE
═══════════════════════════════════════════════════════════════════════ -->
<div id="tabNavigate" class="tab-panel">
<div class="detail-header">
<button class="back-btn" onclick="navBackToPrevious()">← Back</button>
<div class="dh-info">
<div id="navDestName" style="font-size:18px;font-weight:700;">Navigate</div>
<div id="navDestAddr" style="font-size:12px;color:var(--text2);"></div>
</div>
</div>
<div id="navMapContainer" style="height:280px;border-radius:12px;overflow:hidden;margin-bottom:12px;"></div>
<!-- GPS warning banner -->
<div id="navGpsWarning" class="gps-warning" style="display:none;">
<span>📍</span> GPS location not available — <span class="gps-enable-link" onclick="reEnableGPS()">enable location access</span> to get turn-by-turn directions
</div>
<!-- Empty state — shown when no route is active -->
<div id="navEmptyState" class="card" style="text-align:center;">
<div style="font-size:40px;margin-bottom:8px;opacity:0.5;" id="navEmptyIcon">🧭</div>
<div style="font-size:14px;color:var(--text);margin-bottom:8px;" id="navEmptyTitle">Find your way</div>
<div style="font-size:13px;color:var(--text2);margin-bottom:12px;" id="navEmptyDesc">
Go to an asset and tap <b>Navigate</b>, or pick a destination below
</div>
<div class="search-bar">
<input id="navSearchInput" type="text" class="input-field" oninput="searchNavAssets()"
placeholder="Search assets to navigate to..." style="margin-bottom:0;">
<button class="clear-btn" id="clearNavSearch" onclick="clearNavSearch()"></button>
</div>
<div id="navSearchResults" style="text-align:left;"></div>
</div>
<!-- Route info — shown when a route is active -->
<div class="card" id="navInfoCard" style="display:none;">
<div class="card-title">📏 Route Info</div>
<div style="display:flex;gap:12px;flex-wrap:wrap;">
<div style="flex:1;min-width:100px;">
<div style="font-size:11px;color:var(--text3);">Distance</div>
<div id="navDistance" style="font-size:20px;font-weight:700;"></div>
</div>
<div style="flex:1;min-width:100px;">
<div style="font-size:11px;color:var(--text3);">Direction</div>
<div id="navCardinal" style="font-size:20px;font-weight:700;"></div>
</div>
<div style="flex:1;min-width:100px;">
<div style="font-size:11px;color:var(--text3);">From You</div>
<div id="navBearing" style="font-size:20px;font-weight:700;"></div>
</div>
<div style="flex:1;min-width:100px;" id="navDurationCol">
<div style="font-size:11px;color:var(--text3);">ETA</div>
<div id="navDuration" style="font-size:20px;font-weight:700;"></div>
</div>
</div>
<!-- Mode toggle -->
<div id="navModeToggle" style="display:flex;gap:6px;margin-top:10px;">
<button id="navModeDriving" class="btn btn-sm" style="flex:1;" onclick="switchNavMode('driving')">🚗 Driving</button>
<button id="navModeWalking" class="btn btn-sm btn-outline" style="flex:1;" onclick="switchNavMode('walking')">🚶 Walking</button>
</div>
</div>
<div id="navDirectionsCard" class="card" style="display:none;">
<div class="card-title" id="navDirectionsLabel">🚶 Walking Directions</div>
<div id="navDirections" style="font-size:13px;color:var(--text2);line-height:1.6;white-space:pre-wrap;"></div>
</div>
<a id="navGoogleMapsLink" href="#" target="_blank" rel="noopener" class="btn btn-primary" style="display:none;text-decoration:none;text-align:center;margin-top:8px;">🧭 Open in Google Maps</a>
</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 #" oninput="document.getElementById('locFormTrailer').value = this.value">
</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="switchTab('tabAssets')"><span class="qa-icon">🪙</span> Assets</button>
<button class="qa-btn" onclick="switchTab('tabCustomers')"><span class="qa-icon">🏢</span> Sites</button>
<button class="qa-btn" onclick="switchTab('tabAddAsset')"><span class="qa-icon">📍</span> Check-in</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>
<div class="rpt-presets" id="rptPresets">
<button class="rpt-preset" onclick="setDatePreset('today')">Today</button>
<button class="rpt-preset" onclick="setDatePreset('week')">This Week</button>
<button class="rpt-preset" onclick="setDatePreset('month')">This Month</button>
<button class="rpt-preset" onclick="setDatePreset('lastmonth')">Last Month</button>
<button class="rpt-preset" onclick="setDatePreset('all')">All Time</button>
</div>
<div class="rpt-download-row">
<button class="btn btn-primary btn-sm" id="rptGenerateBtn" onclick="loadReports()">Generate Report</button>
<button class="btn btn-outline btn-sm rpt-download-btn" id="rptDownloadBtn" onclick="exportServiceSummary()" style="display:none;">⬇ Download CSV</button>
</div>
<div class="rpt-loading" id="rptLoading" style="display:none;">
<div class="spinner"></div> Generating report...
</div>
</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>
<!-- Danger Zone -->
<div class="danger-zone admin-only">
<div class="danger-zone-header" onclick="this.parentElement.classList.toggle('open')">
<span>⚠️ Danger Zone</span>
<span class="dz-chevron"></span>
</div>
<div class="danger-zone-body">
<p style="margin-bottom:8px;">Resetting the database will <strong style="color:var(--red);">DELETE ALL DATA</strong> and re-seed with defaults. This cannot be undone.</p>
<button class="btn btn-sm btn-danger" data-action="resetDatabase">🗑 Reset Database</button>
</div>
</div>
<!-- About -->
<div class="about-section" style="padding:0 16px 16px;">
<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>
<!-- ═══════════════════════════════════════════════════════════════════════
BOTTOM TABS
═══════════════════════════════════════════════════════════════════════ -->
<div class="tabs">
<button class="tab-btn active" data-tab="tabAddAsset" onclick="switchTab('tabAddAsset')">
<span class="tab-icon">📷</span><span class="tab-label">Add Asset</span>
</button>
<button class="tab-btn" data-tab="tabAssets" onclick="switchTab('tabAssets')">
<span class="tab-icon">📦</span><span class="tab-label">Assets</span>
</button>
<button class="tab-btn" data-tab="tabMap" onclick="switchTab('tabMap')">
<span class="tab-icon">🗺️</span><span class="tab-label">Map</span>
</button>
<button class="tab-btn" data-tab="tabNavigate" onclick="switchTab('tabNavigate')">
<span class="tab-icon">🧭</span><span class="tab-label">Nav</span>
</button>
<button class="tab-btn" data-tab="tabDashboard" onclick="switchTab('tabDashboard')">
<span class="tab-icon">📊</span><span class="tab-label">Dashboard</span>
</button>
<button class="tab-btn" data-tab="tabMore" onclick="toggleMorePopover(event)">
<span class="tab-icon"></span><span class="tab-label">More</span>
</button>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════
MORE POPOVER
═══════════════════════════════════════════════════════════════════════ -->
<div class="more-popover-overlay" id="morePopoverOverlay" onclick="closeMorePopover()"></div>
<div class="more-popover" id="morePopover">
<button class="mp-item" onclick="closeMorePopover();switchTab('tabCustomers')">
<span class="mp-icon">🏢</span> Customers
</button>
<button class="mp-item" onclick="closeMorePopover();switchTab('tabReports')">
<span class="mp-icon">📋</span> Reports
</button>
<button class="mp-item" onclick="closeMorePopover();switchTab('tabActivity')">
<span class="mp-icon">📜</span> Activity
</button>
<button class="mp-item" onclick="closeMorePopover();switchTab('tabSettings')">
<span class="mp-icon">⚙️</span> Settings
</button>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════
TOAST
═══════════════════════════════════════════════════════════════════════ -->
<div class="toast" id="toast"></div>
<!-- ═══════════════════════════════════════════════════════════════════════
MODAL
═══════════════════════════════════════════════════════════════════════ -->
<div class="modal-overlay" id="modalOverlay" aria-hidden="true">
<div class="modal" id="modalDialog" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="modal-title" id="modalTitle">Confirm</div>
<div class="modal-body" id="modalBody">Are you sure?</div>
<div class="modal-prompt-area" id="modalPromptArea" style="display:none;">
<label class="modal-form-field">
<span id="modalPromptLabel">Value</span>
<input type="text" id="modalPromptInput" placeholder="" />
</label>
</div>
<div class="modal-actions" id="modalActions">
<button class="btn btn-outline btn-sm" id="modalCancelBtn">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';
// Show skeleton loading
renderActivitySkeletons();
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 = renderEmptyState('📜', 'No Activity Yet',
'Activity will appear here when assets are created, updated, or checked in. Start by adding your first asset.',
'Add Asset', "switchTab('tabAddAsset')");
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(force = false) {
const badge = document.getElementById('gpsBadge');
// If previously dismissed and not forced, don't re-request
if (AppState._gpsDismissed && !force) return;
if (!navigator.geolocation) {
badge.className = 'gps-badge err';
badge.innerHTML = '📍 Unavailable<span class="gps-dismiss" onclick="event.stopPropagation();dismissGPS()">✕</span>';
badge.onclick = null;
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.innerHTML = `📍 ${AppState.gpsLat.toFixed(4)}, ${AppState.gpsLng.toFixed(4)}`;
badge.onclick = null;
AppState._gpsDismissed = false;
},
err => {
AppState._gpsDismissed = false; // not dismissed, just denied
badge.className = 'gps-badge err';
const msg = {1:'Location blocked',2:'GPS unavailable',3:'GPS timed out'}[err.code] || 'Location error';
badge.innerHTML = '📍 ' + msg + '<span class="gps-dismiss" onclick="event.stopPropagation();dismissGPS()">✕</span>';
badge.onclick = null;
},
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 300000 }
);
}
function dismissGPS() {
const badge = document.getElementById('gpsBadge');
AppState._gpsDismissed = true;
badge.className = 'gps-badge dismissed';
badge.innerHTML = '📍 Off <span class="gps-reenable">Tap to reenable</span>';
badge.onclick = reEnableGPS;
}
function reEnableGPS() {
AppState._gpsDismissed = false;
const badge = document.getElementById('gpsBadge');
badge.className = 'gps-badge waiting';
badge.innerHTML = '📍 GPS...';
badge.onclick = null;
initGPS(true);
}
// =========================================================================
// TOAST
// =========================================================================
let toastTimer;
function showToast(msg, isError = false, isSuccess = false) {
const t = document.getElementById('toast');
t.textContent = msg;
t.className = 'toast' + (isError ? ' error' : '') + (isSuccess ? ' success-toast' : '');
t.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.remove('show'), isSuccess ? 2500 : 2000);
}
// Success overlay + toast combo for create/delete confirmation
function showSuccessFeedback(msg, isWarning = false) {
const overlay = document.getElementById('successOverlay');
overlay.className = 'success-overlay' + (isWarning ? ' warning' : '');
overlay.classList.add('show');
setTimeout(() => overlay.classList.remove('show'), 1200);
showToast(msg, false, true);
}
// Enhanced empty state renderer with animation
function renderEmptyStateCard(icon, heading, desc, btnLabel, btnAction, animate = true) {
const animClass = animate ? ' fade-in-up' : '';
const cta = btnLabel
? `<button class="esc-cta" onclick="${btnAction}">${esc(btnLabel)}</button>`
: '';
return `<div class="empty-state-card${animClass}">
<div class="esc-icon">${icon}</div>
<div class="esc-heading">${esc(heading)}</div>
<div class="esc-desc">${esc(desc)}</div>
${cta}
</div>`;
}
let undoToastTimer;
let undoToastCallback = null;
function showUndoToast(msg, onUndo) {
const t = document.getElementById('undoToast');
if (!t) return;
undoToastCallback = onUndo;
t.querySelector('span').textContent = '🗑️ ' + msg;
t.classList.add('show');
clearTimeout(undoToastTimer);
clearTimeout(toastTimer);
undoToastTimer = setTimeout(() => {
t.classList.remove('show');
if (undoToastCallback) { undoToastCallback = null; }
}, 5000);
}
function undoDelete() {
clearTimeout(undoToastTimer);
const t = document.getElementById('undoToast');
if (t) t.classList.remove('show');
if (undoToastCallback) { const cb = undoToastCallback; undoToastCallback = null; cb(); }
}
function dismissUndo() {
clearTimeout(undoToastTimer);
const t = document.getElementById('undoToast');
if (t) t.classList.remove('show');
undoToastCallback = null;
}
// =========================================================================
// PULL-TO-REFRESH
// =========================================================================
let ptrState = 'idle'; // idle | pulling | ready | refreshing
let ptrStartY = 0;
let ptrCurrentY = 0;
let ptrTouchActive = false;
let _ptrInited = false;
const PTR_THRESHOLD = 60;
const PTR_MAX_PULL = 120;
function updatePtrIndicator(pull) {
const ind = document.getElementById('ptrIndicator');
if (!ind) return;
ind.classList.remove('pulling', 'ready', 'refreshing');
ind.style.height = '0';
const label = ind.querySelector('.ptr-label');
if (ptrState === 'refreshing') {
ind.classList.add('refreshing');
ind.style.height = '52px';
if (label) label.textContent = 'Refreshing...';
} else if (ptrState === 'ready') {
ind.classList.add('ready');
ind.style.height = pull + 'px';
if (label) label.textContent = 'Release to refresh';
} else if (ptrState === 'pulling') {
ind.classList.add('pulling');
ind.style.height = pull + 'px';
if (label) label.textContent = 'Pull to refresh';
} else {
ind.style.height = '0';
if (label) label.textContent = 'Pull to refresh';
}
}
function onPtrTouchStart(e) {
if (ptrState === 'refreshing') return;
if (e.touches.length !== 1) return;
// Only enable pull-to-refresh when at the very top of the page
if (window.scrollY > 0 || document.scrollingElement.scrollTop > 0) return;
ptrTouchActive = true;
ptrStartY = e.touches[0].clientY;
ptrCurrentY = ptrStartY;
}
function onPtrTouchMove(e) {
if (!ptrTouchActive) return;
if (ptrState === 'refreshing') return;
if (e.touches.length !== 1) { ptrTouchActive = false; return; }
ptrCurrentY = e.touches[0].clientY;
const delta = ptrCurrentY - ptrStartY;
if (delta <= 0) {
ptrState = 'idle';
updatePtrIndicator(0);
return;
}
// Only prevent default when pulling down significantly (avoid blocking scroll)
const pull = Math.min(delta * 0.45, PTR_MAX_PULL);
if (pull >= PTR_THRESHOLD) {
ptrState = 'ready';
} else {
ptrState = 'pulling';
}
updatePtrIndicator(pull);
if (pull > 8) {
e.preventDefault();
}
}
function onPtrTouchEnd(e) {
ptrTouchActive = false;
if (ptrState === 'ready') {
ptrState = 'refreshing';
updatePtrIndicator(PTR_THRESHOLD);
loadAssets().then(() => {
setTimeout(() => {
ptrState = 'idle';
updatePtrIndicator(0);
}, 200);
});
} else {
ptrState = 'idle';
updatePtrIndicator(0);
}
}
function initPullToRefresh() {
const listView = document.getElementById('assetsListView');
if (!listView) return;
listView.addEventListener('touchstart', onPtrTouchStart, { passive: false });
listView.addEventListener('touchmove', onPtrTouchMove, { passive: false });
listView.addEventListener('touchend', onPtrTouchEnd, { passive: false });
}
// =========================================================================
// SWIPE-TO-DELETE
// =========================================================================
let swipeTouchItem = null;
let swipeStartX = 0;
let swipeCurrentX = 0;
let swipeActive = false;
const SWIPE_THRESHOLD = 60;
let swipedItem = null; // currently swiped-open item
function closeSwipeItem() {
if (swipedItem) {
const assetEl = swipedItem.querySelector('.asset-item');
if (assetEl) assetEl.classList.remove('swiped');
const bg = swipedItem.querySelector('.swipe-delete-bg');
if (bg) bg.classList.remove('revealed');
swipedItem = null;
}
}
function onSwipeTouchStart(e) {
if (e.touches.length !== 1) return;
const item = e.target.closest('.asset-item-swipe-wrap');
if (!item) { closeSwipeItem(); return; }
// If another item is swiped open, close it
if (swipedItem && swipedItem !== item) closeSwipeItem();
swipeTouchItem = item;
swipeStartX = e.touches[0].clientX;
swipeCurrentX = swipeStartX;
swipeActive = true;
}
function onSwipeTouchMove(e) {
if (!swipeActive || !swipeTouchItem) return;
if (e.touches.length !== 1) { swipeActive = false; return; }
swipeCurrentX = e.touches[0].clientX;
const deltaX = swipeCurrentX - swipeStartX;
// Only handle left swipe (negative delta)
if (deltaX > 10) return; // right swipe, ignore
const assetEl = swipeTouchItem.querySelector('.asset-item');
const bg = swipeTouchItem.querySelector('.swipe-delete-bg');
const absDelta = Math.abs(deltaX);
const clamped = Math.min(absDelta, 90);
if (assetEl) {
assetEl.style.transition = 'none';
assetEl.style.transform = 'translateX(-' + clamped + 'px)';
}
if (bg) {
bg.classList.toggle('revealed', clamped > SWIPE_THRESHOLD);
}
if (absDelta > 10) {
e.preventDefault();
}
}
function onSwipeTouchEnd(e) {
if (!swipeActive || !swipeTouchItem) return;
swipeActive = false;
const deltaX = swipeCurrentX - swipeStartX;
const absDelta = Math.abs(deltaX);
const assetEl = swipeTouchItem.querySelector('.asset-item');
const bg = swipeTouchItem.querySelector('.swipe-delete-bg');
if (assetEl) assetEl.style.transition = 'transform 0.2s ease';
if (absDelta > SWIPE_THRESHOLD && deltaX < 0) {
// Swipe completed — show delete action
if (assetEl) assetEl.classList.add('swiped');
if (bg) bg.classList.add('revealed');
swipedItem = swipeTouchItem;
} else {
// Snap back
if (assetEl) {
assetEl.style.transform = '';
assetEl.classList.remove('swiped');
}
if (bg) bg.classList.remove('revealed');
swipedItem = null;
}
swipeTouchItem = null;
}
async function swipeDeleteAsset(assetId, assetName) {
if (!assetId) return;
const id = assetId;
const name = assetName || 'asset';
// Optimistic UI: remove from list
const itemEl = document.querySelector('.asset-item-swipe-wrap[data-asset-id="' + id + '"]');
if (itemEl) {
itemEl.style.opacity = '0.3';
itemEl.style.pointerEvents = 'none';
}
showUndoToast('Deleted ' + name, async () => {
// Undo: restore the item
if (itemEl) {
itemEl.style.opacity = '1';
itemEl.style.pointerEvents = '';
const assetE = itemEl.querySelector('.asset-item');
if (assetE) { assetE.style.transform = ''; assetE.classList.remove('swiped'); }
const bg = itemEl.querySelector('.swipe-delete-bg');
if (bg) bg.classList.remove('revealed');
swipedItem = null;
}
return;
});
// Delay actual delete to allow undo
await new Promise(r => setTimeout(r, 5200));
try {
await api('/api/assets/' + id, { method: 'DELETE' });
showToast(name + ' deleted');
loadAssets();
} catch (e) {
showToast(e.message, true);
loadAssets(); // reload to restore state
}
}
function initSwipeToDelete() {
document.addEventListener('touchstart', onSwipeTouchStart, { passive: false });
document.addEventListener('touchmove', onSwipeTouchMove, { passive: false });
document.addEventListener('touchend', onSwipeTouchEnd, { passive: false });
// Close swiped item on click elsewhere
document.addEventListener('click', function(e) {
if (!e.target.closest('.asset-item-swipe-wrap')) {
closeSwipeItem();
}
});
}
// =========================================================================
// MOUSE DETECTION (for desktop-only elements)
// =========================================================================
function initMouseDetection() {
let hasMouse = false;
const onMouseMove = function() {
if (!hasMouse) {
hasMouse = true;
document.body.classList.add('has-mouse');
document.removeEventListener('mousemove', onMouseMove);
}
};
document.addEventListener('mousemove', onMouseMove, { once: false });
// Also listen for touch — if touch happens, remove has-mouse
document.addEventListener('touchstart', function() {
hasMouse = false;
document.body.classList.remove('has-mouse');
}, { once: true });
}
// =========================================================================
// MODAL — custom dialog system (alert, confirm, prompt, form)
// =========================================================================
let modalResolve = null;
let modalFocusRestore = null;
let modalType = 'confirm';
function showModal(title, bodyOrOptions, confirmText, confirmClass) {
// Backward-compatible: if second arg is string or Element, treat as body
let opts;
if (typeof bodyOrOptions === 'string' || bodyOrOptions instanceof HTMLElement) {
opts = { body: bodyOrOptions, confirmText, confirmClass };
} else if (bodyOrOptions && typeof bodyOrOptions === 'object') {
opts = bodyOrOptions;
} else {
opts = {};
}
// Apply defaults
opts.type = opts.type || 'confirm';
opts.title = opts.title || title || 'Confirm';
opts.body = opts.body || '';
opts.confirmText = opts.confirmText || (opts.type === 'alert' ? 'OK' : 'Confirm');
opts.confirmClass = opts.confirmClass || 'btn-danger';
opts.cancelText = opts.cancelText || 'Cancel';
opts.hideCancel = opts.hideCancel || opts.type === 'alert';
opts.inputLabel = opts.inputLabel || 'Value';
opts.inputValue = opts.inputValue || '';
opts.inputPlaceholder = opts.inputPlaceholder || '';
opts.inputType = opts.inputType || 'text';
opts.inputs = opts.inputs || [];
opts.actions = opts.actions || [];
modalType = opts.type;
document.getElementById('modalTitle').textContent = opts.title;
const bodyEl = document.getElementById('modalBody');
bodyEl.innerHTML = '';
if (typeof opts.body === 'string') {
bodyEl.textContent = opts.body;
} else {
bodyEl.appendChild(opts.body);
}
// Build prompt area for prompt type
const promptArea = document.getElementById('modalPromptArea');
const promptInput = document.getElementById('modalPromptInput');
if (opts.type === 'prompt') {
document.getElementById('modalPromptLabel').textContent = opts.inputLabel;
promptInput.type = opts.inputType;
promptInput.value = opts.inputValue;
promptInput.placeholder = opts.inputPlaceholder;
promptArea.style.display = 'block';
} else if (opts.type === 'form' && opts.inputs.length) {
// Build form fields in the body
bodyEl.innerHTML = '';
opts.inputs.forEach(inp => {
const div = document.createElement('div');
div.className = 'modal-form-field';
if (inp.type === 'checkbox') {
div.className = 'modal-form-checkbox';
div.innerHTML = `<input type="checkbox" id="modal_fld_${inp.name}" ${inp.value ? 'checked' : ''} />
<label for="modal_fld_${inp.name}">${esc(inp.label || inp.name)}</label>`;
} else {
div.innerHTML = `<label for="modal_fld_${inp.name}">${esc(inp.label || inp.name)}</label>
<${inp.type === 'textarea' ? 'textarea rows="3"' : 'input type="' + (inp.type || 'text') + '"'}
id="modal_fld_${inp.name}" placeholder="${esc(inp.placeholder || '')}" />`;
const field = div.querySelector('input, textarea');
if (inp.value !== undefined) field.value = inp.value;
if (inp.required) field.required = true;
}
bodyEl.appendChild(div);
});
promptArea.style.display = 'none';
} else {
promptArea.style.display = 'none';
}
// Build actions
const actionsEl = document.getElementById('modalActions');
actionsEl.innerHTML = '';
if (opts.actions.length > 0) {
// Custom actions
opts.actions.forEach((act, i) => {
const btn = document.createElement('button');
btn.className = 'btn btn-sm ' + (act.class || 'btn-outline');
btn.textContent = act.text;
btn.addEventListener('click', () => resolveModal(act.value !== undefined ? act.value : act.text));
actionsEl.appendChild(btn);
});
} else {
// Standard cancel + confirm
if (!opts.hideCancel) {
const cancelBtn = document.createElement('button');
cancelBtn.className = 'btn btn-sm btn-outline';
cancelBtn.textContent = opts.cancelText;
cancelBtn.addEventListener('click', () => resolveModal(null));
actionsEl.appendChild(cancelBtn);
}
const confirmBtn = document.createElement('button');
confirmBtn.className = 'btn btn-sm ' + opts.confirmClass;
confirmBtn.textContent = opts.confirmText;
if (opts.type === 'prompt') {
confirmBtn.addEventListener('click', () => resolveModal(promptInput.value));
} else {
confirmBtn.addEventListener('click', () => resolveModal(true));
}
actionsEl.appendChild(confirmBtn);
}
// Save focus and show
modalFocusRestore = document.activeElement;
const overlay = document.getElementById('modalOverlay');
overlay.setAttribute('aria-hidden', 'false');
overlay.classList.add('open');
// Focus the right element
if (opts.type === 'prompt') {
setTimeout(() => promptInput.focus(), 100);
} else {
// Focus first button
const firstBtn = actionsEl.querySelector('button');
if (firstBtn) setTimeout(() => firstBtn.focus(), 100);
}
return new Promise(resolve => {
modalResolve = resolve;
});
}
function resolveModal(value) {
const overlay = document.getElementById('modalOverlay');
overlay.classList.remove('open');
overlay.setAttribute('aria-hidden', 'true');
if (modalResolve) {
// For form type, collect input values
if (modalType === 'form') {
const result = {};
document.querySelectorAll('#modalBody [id^="modal_fld_"]').forEach(el => {
const name = el.id.replace('modal_fld_', '');
if (el.type === 'checkbox') {
result[name] = el.checked;
} else {
result[name] = el.value;
}
});
modalResolve(result);
} else {
modalResolve(value);
}
modalResolve = null;
}
if (modalFocusRestore) {
setTimeout(() => { if (modalFocusRestore) modalFocusRestore.focus(); }, 150);
}
}
function closeModal() {
resolveModal(null);
}
// Click-outside on overlay
document.getElementById('modalOverlay').addEventListener('click', function(e) {
if (e.target === this) closeModal();
});
// ESC key to close
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && document.getElementById('modalOverlay').classList.contains('open')) {
e.preventDefault();
closeModal();
}
});
// Enter key on prompt input to submit
document.getElementById('modalPromptInput').addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
resolveModal(this.value);
}
});
// Focus trap — keep Tab/Shift+Tab cycling inside modal
document.addEventListener('keydown', function(e) {
if (e.key !== 'Tab') return;
const overlay = document.getElementById('modalOverlay');
if (!overlay.classList.contains('open')) return;
const modal = document.getElementById('modalDialog');
const focusable = modal.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) { e.preventDefault(); last.focus(); }
} else {
if (document.activeElement === last) { e.preventDefault(); first.focus(); }
}
});
// =========================================================================
// 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);
}
// =========================================================================
// MORE POPOVER
// =========================================================================
function toggleMorePopover(e) {
e.stopPropagation();
const overlay = document.getElementById('morePopoverOverlay');
const popover = document.getElementById('morePopover');
if (popover.classList.contains('open')) {
closeMorePopover();
} else {
overlay.classList.add('open');
popover.classList.add('open');
}
}
function closeMorePopover() {
document.getElementById('morePopoverOverlay').classList.remove('open');
document.getElementById('morePopover').classList.remove('open');
}
// =========================================================================
// TAB SWITCHING
// =========================================================================
function switchTab(tabId) {
// Close more popover if open
closeMorePopover();
// Detect reverse direction: going back to where we just came from
const isReverse = (tabId === AppState._prevTab);
// Track previous tab for back navigation
if (AppState.currentTab !== tabId && AppState.currentTab !== 'tabNavigate') {
AppState._prevTab = AppState.currentTab;
}
// Cleanup nav page when leaving (closeNavigate only resets nav state,
// it does NOT switch panels — we must NOT return early here)
if (AppState.currentTab === 'tabNavigate' && tabId !== 'tabNavigate') {
closeNavigate();
}
AppState.currentTab = tabId;
// Update tab panels with slide animation
const oldPanel = document.querySelector('.tab-panel.active');
const newPanel = document.getElementById(tabId);
if (oldPanel && oldPanel !== newPanel) {
// Start entrance animation on new panel immediately
if (newPanel) {
newPanel.classList.add('active', isReverse ? 'anim-enter-reverse' : 'anim-enter');
newPanel.addEventListener('animationend', function handler() {
newPanel.removeEventListener('animationend', handler);
newPanel.classList.remove(isReverse ? 'anim-enter-reverse' : 'anim-enter');
}, { once: true });
}
// Animate old panel out while new slides in
oldPanel.classList.add(isReverse ? 'anim-exit-reverse' : 'anim-exit');
oldPanel.classList.remove('active');
oldPanel.addEventListener('animationend', function handler() {
oldPanel.removeEventListener('animationend', handler);
oldPanel.classList.remove(isReverse ? 'anim-exit-reverse' : 'anim-exit');
}, { once: true });
} else if (newPanel) {
newPanel.classList.add('active');
}
// Update bottom tabs — highlight the matching tab, or "More" for drawer-only tabs
const tabMap = {
tabAddAsset: 'tabAddAsset',
tabAssets: 'tabAssets',
tabMap: 'tabMap',
tabNavigate: 'tabNavigate',
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();
if (tabId === 'tabNavigate') renderNavPage();
// Dispatch event so child tab implementations can hook in
document.dispatchEvent(new CustomEvent('tabChange', { detail: { tabId } }));
}
// ── Floating Action Button ────────────────────────────────────────────
function fabAction() {
const fab = document.getElementById('fabAdd');
// Haptic tap animation
fab.style.transform = 'scale(0.85)';
setTimeout(() => { fab.style.transform = ''; }, 150);
// Navigate to Add Asset tab
switchTab('tabAddAsset');
// After switching, pulse the FAB briefly
fab.classList.add('pulse');
setTimeout(() => fab.classList.remove('pulse'), 2500);
}
// Show/hide FAB based on current tab
function updateFabVisibility(tabId) {
const fab = document.getElementById('fabAdd');
if (!fab) return;
// Show FAB everywhere except Add Asset tab
if (tabId === 'tabAddAsset') {
fab.classList.add('hidden-fab');
} else {
fab.classList.remove('hidden-fab');
}
}
// Hook into tab switching
document.addEventListener('tabChange', function(e) {
updateFabVisibility(e.detail.tabId);
});
// Also update on direct switchTab calls - override the function
const _origSwitchTab = switchTab;
switchTab = function(tabId) {
const result = _origSwitchTab(tabId);
updateFabVisibility(tabId);
return result;
};
// =========================================================================
// 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; }
}
// ═══════════════════════════════════════════════════════════════════════
// EMPTY STATE RENDERER
// ═══════════════════════════════════════════════════════════════════════
function renderEmptyState(icon, heading, desc, btnLabel, btnAction) {
const cta = btnLabel
? `<button class="esc-cta" onclick="${btnAction}">${esc(btnLabel)}</button>`
: '';
return `<div class="empty-state-card fade-in-up">
<div class="esc-icon">${icon}</div>
<div class="esc-heading">${esc(heading)}</div>
<div class="esc-desc">${esc(desc)}</div>
${cta}
</div>`;
}
// ═══════════════════════════════════════════════════════════════════════
// ONBOARDING
// ═══════════════════════════════════════════════════════════════════════
function initOnboarding() {
if (localStorage.getItem('canteen_onboarded')) return;
const overlay = document.getElementById('onboardOverlay');
if (overlay) {
overlay.classList.add('open');
}
}
function closeOnboarding() {
localStorage.setItem('canteen_onboarded', '1');
const overlay = document.getElementById('onboardOverlay');
if (overlay) {
overlay.classList.remove('open');
setTimeout(function() { overlay.style.display = 'none'; }, 300);
}
}
// ═══════════════════════════════════════════════════════════════════════
// 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();
}
// T2: Load draft when switching to manual mode
if (mode === 'manual') {
setTimeout(() => {
if (!loadDraft()) {
// No draft - just ensure categories are populated
populateCategorySelect('manCatSelect');
}
initKeyBadges();
}, 100);
}
}
// ── 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 initKeyBadges() {
loadBadgeChecklist();
// Add a default key entry if the list is empty
const list = document.getElementById('manKeysList');
if (list && list.children.length === 0) {
addKeyEntry();
}
}
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
// ═══════════════════════════════════════════════════════════════════════
function _barcodeHints() {
// Restrict to 1D barcode formats only — much faster and more accurate
// than scanning all formats (QR, DataMatrix, PDF417, Aztec, etc.)
const hints = new Map();
const fmts = [
ZXing.BarcodeFormat.CODE_128,
ZXing.BarcodeFormat.CODE_39,
ZXing.BarcodeFormat.EAN_13,
ZXing.BarcodeFormat.EAN_8,
ZXing.BarcodeFormat.UPC_A,
ZXing.BarcodeFormat.UPC_E,
ZXing.BarcodeFormat.CODE_93,
ZXing.BarcodeFormat.ITF,
ZXing.BarcodeFormat.CODABAR,
];
hints.set(ZXing.DecodeHintType.POSSIBLE_FORMATS, fmts);
hints.set(ZXing.DecodeHintType.TRY_HARDER, true);
return hints;
}
function _barcodeOptions() {
return {
delayBetweenScanAttempts: 50, // scan every 50ms (was default ~500ms)
delayBetweenScanSuccess: 2000, // 2s cooldown after a successful scan
};
}
async function startScanning() {
if (addAssetMode !== 'barcode') return;
if (scanningActive) return;
try {
setScanStatus('Starting camera...', 'working');
// Stop any previous reader
if (codeReader) {
try { codeReader.reset(); } catch (e) { /* ignore */ }
codeReader = null;
}
const video = document.getElementById('cameraVideo');
if (!video) return;
// Create reader with 1D-only hints for fast, accurate scanning
codeReader = new ZXing.BrowserMultiFormatReader(_barcodeHints(), _barcodeOptions());
// Pick the rear-facing camera (environment-facing on phones)
let deviceId = null;
try {
const devices = await codeReader.listVideoInputDevices();
if (devices && devices.length > 0) {
const rear = devices.find(d =>
d.label && /back|rear|environment/i.test(d.label)
);
deviceId = rear ? rear.deviceId : devices[0].deviceId;
}
} catch (e) {
// Fallback: pass null to let decodeFromVideoDevice pick default
console.warn('listVideoInputDevices failed, using default camera:', e);
}
document.getElementById('cameraPlaceholder').style.display = 'none';
video.style.display = 'block';
scanningActive = true;
setScanStatus('Point camera at a barcode', 'success');
// decodeFromVideoDevice handles camera + scanning in one call
codeReader.decodeFromVideoDevice(deviceId, 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 */ }
codeReader = 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)}`);
autoCheckin(asset.id);
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">
<span class="sr-checkin-done">✓ Auto checked in</span>
<button class="btn btn-outline btn-sm" onclick="switchTab('tabAssets');viewAsset(${asset.id});" style="flex:1;">View Details</button>
</div>
`;
}
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,
};
// F2: Auto-populate address from GPS
if (AppState.gpsLat) {
const geo = await reverseGeocode(AppState.gpsLat, AppState.gpsLng);
if (geo) {
payload.address = geo.address || geo.formatted || '';
payload.building_number = geo.building_number || '';
payload.building_name = geo.building_name || '';
payload.trailer_number = geo.building_number || ''; // F4
}
}
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;
autoCheckin(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);
}
}
// ── Auto check-in on asset creation (GPS grab) ──────────────────────────
async function autoCheckin(assetId) {
if (!AppState.gpsLat) return; // No GPS — skip silently
try {
await api('/api/checkins', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
asset_id: assetId,
latitude: AppState.gpsLat,
longitude: AppState.gpsLng,
accuracy: AppState.gpsAcc,
notes: 'Auto check-in on creation',
}),
});
// Don't show toast — just silently grab the location
} catch (e) {
// GPS check-in is best-effort; never block creation for it
console.warn('Auto check-in failed:', e);
}
}
// ── Reverse geocode GPS → address (Nominatim) ─────────────────────────
async function reverseGeocode(lat, lng) {
if (!lat || !lng) return null;
try {
const data = await api(`/api/geocode?lat=${lat}&lng=${lng}`);
return data; // { address, building_number, building_name, ... }
} catch (e) {
console.warn('Reverse geocode failed:', e);
return null;
}
}
// ── Populate address fields from geocode result ────────────────────────
function applyGeocodeToFields(geo, prefix = 'man') {
if (!geo) return;
const set = (suffix, val) => {
const el = document.getElementById(prefix + suffix);
if (el && !el.value) el.value = val || '';
};
set('Address', geo.address || geo.formatted || '');
set('BuildingNumber', geo.building_number || '');
set('BuildingName', geo.building_name || '');
set('TrailerNumber', geo.building_number || ''); // F4: sync building# → trailer
// Don't overwrite floor/room — those are interior details GPS can't know
}
// ═══════════════════════════════════════════════════════════════════════
// 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;
}
const payload = {
machine_id: machineId, name,
category: document.getElementById('ocrCatSelect').value || 'Other',
status: document.getElementById('ocrStatus').value,
};
// F2: Auto-populate address from GPS
if (AppState.gpsLat) {
const geo = await reverseGeocode(AppState.gpsLat, AppState.gpsLng);
if (geo) {
payload.address = geo.address || geo.formatted || '';
payload.building_number = geo.building_number || '';
payload.building_name = geo.building_name || '';
payload.trailer_number = geo.building_number || '';
}
}
try {
const asset = await api('/api/assets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
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;
autoCheckin(asset.id);
// Show check-in option
document.getElementById('checkinCard').style.display = 'block';
} catch (e) {
showToast(e.message, true);
}
}
// ═══════════════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════════════
// MANUAL MODE — Photo (T2 UX Overhaul)
// ═══════════════════════════════════════════════════════════════════════
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';
// Auto-open photo section when camera starts
openFormSection('sectPhoto');
} 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);
const preview = document.getElementById('manPhotoPreview');
preview.src = url;
const wrap = document.getElementById('manPhotoPreviewWrap');
wrap.style.display = 'block';
stopManualPhoto();
// Scroll to preview
wrap.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
function retakeManualPhoto() {
manualPhotoBlob = null;
document.getElementById('manPhotoPreviewWrap').style.display = 'none';
startManualPhoto();
}
function clearManualPhoto() {
manualPhotoBlob = null;
document.getElementById('manPhotoPreviewWrap').style.display = 'none';
document.getElementById('manPhotoPreview').src = '';
}
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';
}
// ═══════════════════════════════════════════════════════════════════════
// COLLAPSIBLE FORM SECTIONS
// ═══════════════════════════════════════════════════════════════════════
function toggleFormSection(sectionId) {
const sect = document.getElementById(sectionId);
if (!sect) return;
sect.classList.toggle('open');
}
function openFormSection(sectionId) {
const sect = document.getElementById(sectionId);
if (sect && !sect.classList.contains('open')) {
sect.classList.add('open');
}
}
// ═══════════════════════════════════════════════════════════════════════
// INLINE FIELD VALIDATION
// ═══════════════════════════════════════════════════════════════════════
function showFieldError(fieldId, msg) {
const field = document.getElementById(fieldId);
const errEl = document.getElementById('err' + fieldId.charAt(0).toUpperCase() + fieldId.slice(1));
if (field) field.classList.add('error');
if (errEl) { errEl.textContent = '⚠️ ' + msg; errEl.classList.add('visible'); }
// Mark parent section
const section = field ? field.closest('.form-collapse-section') : null;
if (section) { section.classList.add('has-error'); openFormSection(section.id); }
}
function clearFieldError(fieldId) {
const field = document.getElementById(fieldId);
const errEl = document.getElementById('err' + fieldId.charAt(0).toUpperCase() + fieldId.slice(1));
if (field) field.classList.remove('error');
if (errEl) errEl.classList.remove('visible');
const section = field ? field.closest('.form-collapse-section') : null;
if (section) section.classList.remove('has-error');
}
function clearAllFieldErrors() {
document.querySelectorAll('.input-field.error').forEach(f => f.classList.remove('error'));
document.querySelectorAll('.field-error-msg.visible').forEach(e => e.classList.remove('visible'));
document.querySelectorAll('.form-collapse-section.has-error').forEach(s => s.classList.remove('has-error'));
}
function validateManualForm() {
clearAllFieldErrors();
let firstError = null;
const fields = [
{ id: 'manMachineId', msg: 'Machine ID is required' },
{ id: 'manName', msg: 'Asset Name is required' },
];
for (const { id, msg } of fields) {
const val = document.getElementById(id).value.trim();
if (!val) {
showFieldError(id, msg);
if (!firstError) firstError = document.getElementById(id);
}
}
return firstError === null;
}
// ═══════════════════════════════════════════════════════════════════════
// AUTO-SAVE DRAFT TO localStorage
// ═══════════════════════════════════════════════════════════════════════
let autoSaveTimer = null;
const DRAFT_KEY = 'canteen_asset_draft';
function autoSaveDraft() {
clearTimeout(autoSaveTimer);
const indicator = document.getElementById('autosaveIndicator');
if (indicator) { indicator.textContent = '💾 Saving...'; indicator.className = 'autosave-indicator saving'; }
autoSaveTimer = setTimeout(() => {
saveDraftNow();
}, 600);
}
function saveDraftNow() {
const draft = {
machineId: document.getElementById('manMachineId').value,
serialNumber: document.getElementById('manSerialNumber').value,
name: document.getElementById('manName').value,
description: document.getElementById('manDescription').value,
category: document.getElementById('manCatSelect').value,
make: document.getElementById('manMake').value,
model: document.getElementById('manModel').value,
status: document.getElementById('manStatus').value,
address: document.getElementById('manAddress').value,
buildingName: document.getElementById('manBuildingName').value,
buildingNumber: document.getElementById('manBuildingNumber').value,
floor: document.getElementById('manFloor').value,
room: document.getElementById('manRoom').value,
walkingDirections: document.getElementById('manWalkingDirections').value,
mapLink: document.getElementById('manMapLink').value,
parkingLocation: document.getElementById('manParkingLocation').value,
customer: document.getElementById('manCustomer').value,
location: document.getElementById('manLocation').value,
};
try {
localStorage.setItem(DRAFT_KEY, JSON.stringify(draft));
const indicator = document.getElementById('autosaveIndicator');
if (indicator) { indicator.textContent = '✓ Draft saved'; indicator.className = 'autosave-indicator saved'; }
setTimeout(() => {
if (indicator && indicator.className === 'autosave-indicator saved') {
indicator.textContent = '';
indicator.className = 'autosave-indicator';
}
}, 2000);
} catch (e) { /* localStorage full or unavailable */ }
}
function loadDraft() {
try {
const raw = localStorage.getItem(DRAFT_KEY);
if (!raw) return false;
const d = JSON.parse(raw);
if (d.machineId) document.getElementById('manMachineId').value = d.machineId || '';
if (d.serialNumber) document.getElementById('manSerialNumber').value = d.serialNumber || '';
if (d.name) document.getElementById('manName').value = d.name || '';
if (d.description) document.getElementById('manDescription').value = d.description || '';
if (d.category) { populateCategorySelect('manCatSelect'); setTimeout(() => { document.getElementById('manCatSelect').value = d.category; }, 100); }
if (d.make) { document.getElementById('manMake').value = d.make; loadModels(); setTimeout(() => { document.getElementById('manModel').value = d.model || ''; }, 200); }
if (d.status) document.getElementById('manStatus').value = d.status;
if (d.address) document.getElementById('manAddress').value = d.address;
if (d.buildingName) document.getElementById('manBuildingName').value = d.buildingName;
if (d.buildingNumber) document.getElementById('manBuildingNumber').value = d.buildingNumber;
if (d.floor) document.getElementById('manFloor').value = d.floor;
if (d.room) document.getElementById('manRoom').value = d.room;
if (d.walkingDirections) document.getElementById('manWalkingDirections').value = d.walkingDirections;
if (d.mapLink) document.getElementById('manMapLink').value = d.mapLink;
if (d.parkingLocation) document.getElementById('manParkingLocation').value = d.parkingLocation;
if (d.customer) { document.getElementById('manCustomer').value = d.customer; loadManualLocations(); setTimeout(() => { document.getElementById('manLocation').value = d.location || ''; }, 100); }
return true;
} catch (e) { return false; }
}
function clearDraft() {
localStorage.removeItem(DRAFT_KEY);
const indicator = document.getElementById('autosaveIndicator');
if (indicator) { indicator.textContent = ''; indicator.className = 'autosave-indicator'; }
}
// ═══════════════════════════════════════════════════════════════════════
// MANUAL MODE — Submit (T2 UX Overhaul)
// ═══════════════════════════════════════════════════════════════════════
async function submitManualAsset(addAnother) {
// Validate
if (!validateManualForm()) {
// Scroll to first error
const firstErr = document.querySelector('.input-field.error');
if (firstErr) {
firstErr.scrollIntoView({ behavior: 'smooth', block: 'center' });
firstErr.focus();
}
return;
}
const machineId = document.getElementById('manMachineId').value.trim();
const name = document.getElementById('manName').value.trim();
// Set loading state on submit button
const btnCreate = document.getElementById('btnCreateAsset');
if (btnCreate) btnCreate.classList.add('loading');
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 || '' : '';
// F2: Auto-populate address from GPS (only if address field is empty)
if (AppState.gpsLat && !document.getElementById('manAddress').value.trim()) {
const geo = await reverseGeocode(AppState.gpsLat, AppState.gpsLng);
if (geo) {
if (!document.getElementById('manAddress').value.trim()) {
document.getElementById('manAddress').value = geo.address || geo.formatted || '';
document.getElementById('manBuildingNumber').value = geo.building_number || '';
document.getElementById('manBuildingName').value = geo.building_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!');
clearDraft();
AppState.currentAssetId = asset.id;
autoCheckin(asset.id);
if (addAnother) {
clearManualForm();
document.getElementById('manMachineId').focus();
} else {
document.getElementById('manCheckinCard').style.display = 'block';
document.getElementById('manCheckinNotes').focus();
document.getElementById('manCheckinCard').scrollIntoView({ behavior: 'smooth' });
}
} catch (e) {
showToast(e.message, true);
} finally {
if (btnCreate) btnCreate.classList.remove('loading');
}
}
function clearManualForm() {
const fields = ['manMachineId', 'manSerialNumber', 'manName', 'manDescription',
'manAddress', 'manBuildingName', 'manBuildingNumber', 'manFloor', 'manRoom',
'manWalkingDirections', 'manMapLink', 'manParkingLocation'];
fields.forEach(id => { const el = document.getElementById(id); if (el) el.value = ''; });
document.getElementById('manCatSelect').value = '';
document.getElementById('manMake').value = '';
document.getElementById('manModel').value = '';
document.getElementById('manStatus').value = 'active';
document.getElementById('manCustomer').value = '';
document.getElementById('manLocation').value = '';
document.getElementById('manKeysList').innerHTML = '';
document.querySelectorAll('#manBadgesList .badge-check-item').forEach(b => b.classList.remove('checked'));
clearManualPhoto();
clearAllFieldErrors();
clearDraft();
}
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);
}
}
// ═══════════════════════════════════════════════════════════════════════
// GPS / PIN BUTTONS WITH SPINNERS
// ═══════════════════════════════════════════════════════════════════════
function openMapPin() {
const link = document.getElementById('manMapLink').value.trim();
if (link) {
const btn = document.getElementById('btnMapPin');
const fb = document.getElementById('fbMapPin');
if (btn) btn.classList.add('loading');
window.open(link, '_blank');
setTimeout(() => {
if (btn) btn.classList.remove('loading');
if (fb) { fb.classList.add('visible'); setTimeout(() => fb.classList.remove('visible'), 2000); }
}, 500);
} else {
showToast('Enter a map link first', true);
}
}
async function fillGpsParking() {
const btn = document.getElementById('btnGpsParking');
const fb = document.getElementById('fbGpsParking');
if (!AppState.gpsLat) {
if (btn) btn.classList.add('loading');
// Try to get a fresh GPS fix
try {
const pos = await new Promise((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, {
enableHighAccuracy: true, timeout: 10000, maximumAge: 0
});
});
AppState.gpsLat = pos.coords.latitude;
AppState.gpsLng = pos.coords.longitude;
AppState.gpsAcc = pos.coords.accuracy;
} catch (e) {
if (btn) btn.classList.remove('loading');
showToast('GPS not available — allow location access', true);
return;
}
}
document.getElementById('manParkingLocation').value =
AppState.gpsLat.toFixed(6) + ', ' + AppState.gpsLng.toFixed(6);
if (btn) btn.classList.remove('loading');
if (fb) { fb.classList.add('visible'); setTimeout(() => fb.classList.remove('visible'), 2000); }
}
// Settings are loaded after auth (in doLogin / initAuth)
// ═══════════════════════════════════════════════════════════════════════
// SKELETON LOADING — reusable shimmer placeholders for all views
// ═══════════════════════════════════════════════════════════════════════
function _skelItem(cls, iconSize, linesHtml) {
return '<div class="' + cls + '"><div class="skel-circle" style="width:' + iconSize + 'px;height:' + iconSize + 'px;"></div><div class="skel-lines">' + linesHtml + '</div></div>';
}
function _skelTxt(w) { return '<div class="skel skel-text ' + w + '"></div>'; }
// ── Asset list skeletons ───────────────────────────────────────────────
function renderAssetSkeletons(count) {
count = count || 8;
var lines = _skelTxt('w60') + _skelTxt('w80') + _skelTxt('w40');
var html = '';
for (var i = 0; i < count; i++) {
html += _skelItem('skel-asset-item', 42, lines);
}
document.getElementById('assetList').innerHTML = '<div class="skel-wrapper">' + html + '</div>';
document.getElementById('assetCountBar').innerHTML = '<div class="skel skel-text w30" style="height:16px;margin:8px 0;"></div>';
document.getElementById('filterPills').innerHTML = '';
}
// ── Customer list skeletons ────────────────────────────────────────────
function renderCustomerSkeletons(count) {
count = count || 6;
var lines = _skelTxt('w60') + _skelTxt('w80');
var html = '';
for (var i = 0; i < count; i++) {
html += _skelItem('skel-cust-card', 42, lines);
}
document.getElementById('custList').innerHTML = '<div class="skel-wrapper">' + html + '</div>';
}
// ── Activity list skeletons ────────────────────────────────────────────
function renderActivitySkeletons(count) {
count = count || 8;
var lines = _skelTxt('w80') + _skelTxt('w60') + _skelTxt('w30');
var html = '';
for (var i = 0; i < count; i++) {
html += _skelItem('skel-act-item', 36, lines);
}
document.getElementById('actList').innerHTML = '<div class="skel-wrapper">' + html + '</div>';
}
// ── Dashboard skeletons ────────────────────────────────────────────────
function renderDashboardSkeletons() {
var html = '';
var i;
// Stat cards row
html += '<div class="stats-grid three" style="margin-bottom:12px;">';
for (i = 0; i < 3; i++) {
html += '<div class="skel-stat-card"><div class="skel skel-stat-value"></div><div class="skel skel-stat-label"></div></div>';
}
html += '</div>';
// By Category card
html += '<div class="skel-stat-card" style="margin-bottom:10px;text-align:left;padding:16px;">';
html += '<div class="skel skel-text w40" style="height:12px;margin-bottom:12px;"></div>';
for (i = 0; i < 3; i++) {
html += '<div class="skel-stat-bar"><div class="skel skel-bar-label"></div><div class="skel skel-bar-track"></div></div>';
}
html += '</div>';
// By Status card
html += '<div class="skel-stat-card" style="margin-bottom:10px;text-align:left;padding:16px;">';
html += '<div class="skel skel-text w40" style="height:12px;margin-bottom:12px;"></div>';
for (i = 0; i < 3; i++) {
html += '<div class="skel-stat-bar"><div class="skel skel-bar-label"></div><div class="skel skel-bar-track"></div></div>';
}
html += '</div>';
// Activity feed skeleton
html += '<div class="skel-stat-card" style="text-align:left;padding:16px;">';
html += '<div class="skel skel-text w40" style="height:12px;margin-bottom:12px;"></div>';
for (i = 0; i < 3; i++) {
html += _skelItem('skel-act-item', 28, _skelTxt('w80') + _skelTxt('w40'));
}
html += '</div>';
var dashEl = document.getElementById('tabDashboard');
var cards = dashEl.querySelectorAll('.card, #statsGrid, .quick-actions, .stats-grid');
for (var c = 0; c < cards.length; c++) cards[c].style.display = 'none';
var es = document.getElementById('dashEmptyState');
if (es) es.style.display = 'none';
var skelWrap = document.getElementById('dashSkelWrap');
if (!skelWrap) {
skelWrap = document.createElement('div');
skelWrap.id = 'dashSkelWrap';
skelWrap.className = 'skel-wrapper';
dashEl.insertBefore(skelWrap, dashEl.firstChild);
} else {
skelWrap.style.display = '';
}
skelWrap.innerHTML = html;
}
function restoreDashboardContent() {
var dashEl = document.getElementById('tabDashboard');
var cards = dashEl.querySelectorAll('.card, #statsGrid, .quick-actions, .stats-grid');
for (var c = 0; c < cards.length; c++) cards[c].style.display = '';
var skelWrap = document.getElementById('dashSkelWrap');
if (skelWrap) skelWrap.style.display = 'none';
var es = document.getElementById('dashEmptyState');
// don't touch dashEmptyState here — renderDashboard manages it
}
// ── Report skeletons ───────────────────────────────────────────────────
function renderReportSkeletons() {
var html = '';
var i;
html += '<div class="stats-grid" style="margin-bottom:10px;">';
for (i = 0; i < 4; i++) {
html += '<div class="skel-stat-card"><div class="skel skel-stat-value"></div><div class="skel skel-stat-label"></div></div>';
}
html += '</div>';
html += '<div class="skel-stat-card" style="margin-bottom:10px;text-align:left;padding:16px;">';
html += '<div class="skel skel-text w60" style="height:12px;margin-bottom:10px;"></div>';
for (i = 0; i < 5; i++) {
html += '<div class="skel-rpt-row"><div class="skel skel-cell c1"></div><div class="skel skel-cell c2"></div><div class="skel skel-cell c3"></div></div>';
}
html += '</div>';
html += '<div class="skel-stat-card" style="text-align:left;padding:16px;">';
html += '<div class="skel skel-text w60" style="height:12px;margin-bottom:10px;"></div>';
for (i = 0; i < 4; i++) {
html += '<div class="skel-rpt-row"><div class="skel skel-cell c1"></div><div class="skel skel-cell c2"></div><div class="skel skel-cell c3"></div></div>';
}
html += '</div>';
var rptTab = document.getElementById('tabReports');
var rptCards = rptTab.querySelectorAll('.card');
for (var r = 0; r < rptCards.length; r++) rptCards[r].style.display = 'none';
var rptWrap = document.getElementById('reportSkelWrap');
if (!rptWrap) {
rptWrap = document.createElement('div');
rptWrap.id = 'reportSkelWrap';
rptWrap.className = 'skel-wrapper';
rptWrap.style.marginBottom = '10px';
rptTab.insertBefore(rptWrap, rptTab.firstChild);
} else {
rptWrap.style.display = '';
}
rptWrap.innerHTML = html;
}
function restoreReportContent() {
var rptCards = document.querySelectorAll('#tabReports .card');
for (var r = 0; r < rptCards.length; r++) rptCards[r].style.display = '';
var rptWrap = document.getElementById('reportSkelWrap');
if (rptWrap) rptWrap.style.display = 'none';
}
// ── Fade-out helper ────────────────────────────────────────────────────
function hideSkeletons(selector) {
var wrappers = document.querySelectorAll(selector);
for (var i = 0; i < wrappers.length; i++) {
wrappers[i].classList.add('skel-hide');
}
setTimeout(function() {
for (i = 0; i < wrappers.length; i++) {
if (wrappers[i].parentNode) wrappers[i].parentNode.removeChild(wrappers[i]);
}
}, 250);
}
/* ═══════════════════════════════════════════════════════════════════════
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 = [];
let masterCategories = [];
let totalAssetCount = 0;
let isLoading = false;
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';
updateClearFiltersBtn();
// Show skeleton loading
isLoading = true;
renderAssetSkeletons();
clearTimeout(assetDebounce);
assetDebounce = setTimeout(_loadAssets, 200);
}
function showListLoading(show) {
const wrap = document.getElementById('assetListWrap');
if (!wrap) return;
// Remove existing overlay
const existing = wrap.querySelector('.list-loading-overlay');
if (existing) existing.remove();
if (show) {
const overlay = document.createElement('div');
overlay.className = 'list-loading-overlay';
overlay.innerHTML = '<div class="overlay-spinner"></div>';
wrap.appendChild(overlay);
}
}
async function _loadAssets() {
// Fetch categories master list once
if (masterCategories.length === 0) {
try { masterCategories = await api('/api/settings/categories'); }
catch (e) { masterCategories = []; }
}
// Fetch total count once
if (totalAssetCount === 0) {
try { const c = await api('/api/assets/count'); totalAssetCount = c.count; }
catch (e) { totalAssetCount = 0; }
}
var 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);
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;
cachedAssets = filtered;
renderAssetList(filtered);
renderFilterPills(assets);
updateCountBar(filtered.length);
} catch (e) {
showListLoading(false);
document.getElementById('assetList').innerHTML =
'<div class="empty-state">Failed to load assets</div>';
document.getElementById('assetCountBar').innerHTML = '';
isLoading = false;
}
}
// Initialize PTR when assets tab loads
document.addEventListener('tabChange', function(e) {
if (e.detail.tabId === 'tabAssets') {
setTimeout(initPullToRefresh, 100);
}
});
function updateCountBar(filteredCount) {
isLoading = false;
showListLoading(false);
const bar = document.getElementById('assetCountBar');
if (totalAssetCount > 0) {
const hasFilters = assetFilters.category || assetFilters.status || assetFilters.make || assetFilters.q || assetFilters.assigned_to;
if (hasFilters) {
bar.innerHTML = filteredCount + ' of ' + totalAssetCount + ' assets';
} else {
bar.innerHTML = totalAssetCount + ' asset' + (totalAssetCount !== 1 ? 's' : '');
}
} else {
bar.innerHTML = filteredCount + ' asset' + (filteredCount !== 1 ? 's' : '');
}
}
// ── Skeleton loading helpers ──────────────────────────────────────────
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;
}
const q = assetFilters.q;
el.innerHTML = assets.map(a => {
const icon = catIcon(a.category);
const catClass = a.category ? 'cat-' + a.category.replace(/[^a-zA-Z0-9]/g, '-') : '';
const nameHtml = q ? highlightText(a.name, q) : esc(a.name);
const machineIdHtml = q ? highlightText(a.machine_id, q) : esc(a.machine_id);
const locParts = buildLocationParts(a);
const locHtml = locParts ? '<div class="ai-location">📍 ' + esc(locParts) + '</div>' : '';
const escName = esc(a.name).replace(/'/g, "\'");
return `
<div class="asset-item-swipe-wrap" data-asset-id="${a.id}">
<div class="swipe-delete-bg">
<button class="btn btn-danger btn-sm" onclick="event.stopPropagation(); swipeDeleteAsset(${a.id}, '${escName}')" style="pointer-events:auto;font-size:12px;padding:6px 12px;">🗑️ Delete</button>
</div>
<div class="asset-item" onclick="viewAsset(${a.id})">
<div class="ai-icon ${catClass}">${icon}</div>
<div class="ai-info">
<div class="ai-name">${nameHtml}</div>
<div class="ai-meta">
${machineIdHtml}${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>
${locHtml}
</div>
<div class="ai-arrow"></div>
</div>
</div>`;
}).join('');
}
function buildLocationParts(a) {
const parts = [];
if (a.building_name) parts.push(a.building_name);
else if (a.building_number) parts.push('Bldg ' + a.building_number);
if (a.floor) parts.push('Floor ' + a.floor);
if (a.room) parts.push('Room ' + a.room);
if (a.address && parts.length === 0) parts.push(a.address);
return parts.join(' · ');
}
function highlightText(text, query) {
if (!query) return esc(text);
const escaped = esc(text);
const escapedQuery = esc(query).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp('(' + escapedQuery + ')', 'gi');
return escaped.replace(re, '<span class="search-highlight">$1</span>');
}
function renderFilterPills(assets) {
const stats = new Set(), makes = new Set();
assets.forEach(a => {
if (a.status) stats.add(a.status);
if (a.make) makes.add(a.make);
});
// Use master categories list if available, fall back to asset categories
const catNames = masterCategories.length
? masterCategories.map(c => (typeof c === 'string') ? c : c.name || c.value || c.label || '').filter(Boolean).sort()
: [...new Set(assets.map(a => a.category).filter(Boolean))].sort();
const escJs = s => String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
let html = '<span class="pill' + (!assetFilters.category && !assetFilters.status && !assetFilters.make ? ' active' : '') + '" onclick="setFilter(null,null,null)">All</span>';
// Category pills from master list
catNames.forEach(c => {
html += `<span class="pill${assetFilters.category === c ? ' active' : ''}" onclick="setFilter('${escJs(c)}',null,null)">📂 ${esc(c)}</span>`;
});
// Status pills
[...stats].sort().forEach(s => {
html += `<span class="pill${assetFilters.status === s ? ' active' : ''}" onclick="setFilter(null,'${escJs(s)}',null)">${s === 'active' ? '🟢' : s === 'maintenance' ? '🟡' : '🔴'} ${esc(s)}</span>`;
});
// Make pills
[...makes].sort().forEach(m => {
html += `<span class="pill${assetFilters.make === m ? ' active' : ''}" onclick="setFilter(null,null,'${escJs(m)}')">🏭 ${esc(m)}</span>`;
});
document.getElementById('filterPills').innerHTML = html;
}
function setFilter(category, status, make) {
assetFilters.category = category;
assetFilters.status = status;
assetFilters.make = make;
updateClearFiltersBtn();
loadAssets();
}
function clearFilters() {
assetFilters.category = null;
assetFilters.status = null;
assetFilters.make = null;
assetFilters.assigned_to = null;
document.getElementById('assetSearch').value = '';
assetFilters.q = '';
const clearBtn = document.getElementById('clearSearch');
if (clearBtn) clearBtn.style.display = 'none';
updateClearFiltersBtn();
loadAssets();
}
function updateClearFiltersBtn() {
const btn = document.getElementById('clearFiltersBtn');
if (!btn) return;
const q = assetFilters.q || '';
const hasFilters = assetFilters.category || assetFilters.status || assetFilters.make || q || assetFilters.assigned_to;
btn.style.display = hasFilters ? 'inline-flex' : 'none';
}
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();
const fab = document.getElementById('fabAdd');
if (fab) fab.style.display = '';
}
function showDetailView() {
hideAllAssetViews();
document.getElementById('assetsDetailView').style.display = 'block';
const fab = document.getElementById('fabAdd');
if (fab) fab.style.display = 'none';
}
function showEditView() {
hideAllAssetViews();
document.getElementById('assetsEditView').style.display = 'block';
const fab = document.getElementById('fabAdd');
if (fab) fab.style.display = 'none';
}
function showImportView() {
hideAllAssetViews();
document.getElementById('assetsImportView').style.display = 'block';
const fab = document.getElementById('fabAdd');
if (fab) fab.style.display = 'none';
// 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';
}
// Navigate button — show when asset has GPS and user has GPS
const navBtn = document.getElementById('detailNavigateBtn');
if (a.latitude != null && a.longitude != null && AppState.gpsLat != null) {
navBtn.style.display = 'inline-flex';
} else {
navBtn.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');
else if (a.latitude && a.longitude) {
// Fallback: open Google Maps from current position
const origin = AppState.gpsLat ? `${AppState.gpsLat},${AppState.gpsLng}` : '';
const dest = `${a.latitude},${a.longitude}`;
const url = origin
? `https://www.google.com/maps/dir/?api=1&origin=${origin}&destination=${dest}&travelmode=driving`
: `https://www.google.com/maps/search/?api=1&query=${dest}`;
window.open(url, '_blank');
} else {
showToast('No location data for this asset', true);
}
}).catch(() => {});
}
async function navigateToAsset() {
if (!AppState.currentAssetId) return;
if (!AppState.gpsLat || !AppState.gpsLng) {
showToast('GPS location not available — enable location access to navigate', true);
// Still go to navigate tab to show the GPS warning UI
AppState._navData = null;
switchTab('tabNavigate');
return;
}
try {
const nav = await api('/api/assets/' + AppState.currentAssetId +
'/navigation?lat=' + AppState.gpsLat + '&lng=' + AppState.gpsLng);
// Store nav data for dedicated page
AppState._navData = nav;
// Switch to dedicated navigate tab
switchTab('tabNavigate');
renderNavPage();
} catch (e) {
showToast(e.message, true);
}
}
let navMap = null;
function initNavMap(lat, lng) {
if (navMap) { navMap.remove(); navMap = null; }
const container = document.getElementById('navMapContainer');
navMap = L.map(container, {
attributionControl: false,
zoomControl: false,
dragging: true,
tap: true,
}).setView([lat, lng], 15);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
}).addTo(navMap);
// Invalidate size after container is visible
setTimeout(() => { if (navMap) navMap.invalidateSize(); }, 100);
}
function renderNavPage() {
const nav = AppState._navData;
const emptyEl = document.getElementById('navEmptyState');
const mapCont = document.getElementById('navMapContainer');
const infoCard = document.getElementById('navInfoCard');
const dirsCard = document.getElementById('navDirectionsCard');
const gmapsLink = document.getElementById('navGoogleMapsLink');
const gpsWarn = document.getElementById('navGpsWarning');
const hasGPS = !!(AppState.gpsLat && AppState.gpsLng);
if (!nav) {
// No route active — show empty state with GPS awareness
if (gpsWarn) gpsWarn.style.display = hasGPS ? 'none' : 'flex';
// Update empty state messaging based on GPS
const iconEl = document.getElementById('navEmptyIcon');
const titleEl = document.getElementById('navEmptyTitle');
const descEl = document.getElementById('navEmptyDesc');
if (hasGPS) {
if (iconEl) iconEl.textContent = '🧭';
if (titleEl) titleEl.textContent = 'Find your way';
if (descEl) descEl.innerHTML = 'Go to an asset and tap <b>Navigate</b>, or pick a destination below';
} else {
if (iconEl) iconEl.textContent = '📍';
if (titleEl) titleEl.textContent = 'Location needed';
if (descEl) descEl.innerHTML = 'Enable GPS to get turn-by-turn directions. Still searchable below.';
}
if (emptyEl) emptyEl.style.display = '';
if (infoCard) infoCard.style.display = 'none';
if (dirsCard) dirsCard.style.display = 'none';
if (gmapsLink) gmapsLink.style.display = 'none';
document.getElementById('navDestName').textContent = 'Navigate';
document.getElementById('navDestAddr').textContent = '';
// Show user location on map if GPS is available
if (hasGPS) {
if (mapCont) mapCont.style.display = '';
initNavMap(AppState.gpsLat, AppState.gpsLng);
setTimeout(() => {
if (!navMap) return;
const userIcon = L.divIcon({
html: '<div style="width:16px;height:16px;border-radius:50%;background:#5b6ef7;border:3px solid #fff;box-shadow:0 0 10px rgba(91,110,247,0.6);"></div>',
className: '', iconSize: [16, 16], iconAnchor: [8, 8],
});
L.marker([AppState.gpsLat, AppState.gpsLng], { icon: userIcon })
.addTo(navMap).bindPopup('<b>You are here</b>');
if (AppState.gpsAcc) {
L.circle([AppState.gpsLat, AppState.gpsLng], {
radius: AppState.gpsAcc, color: '#5b6ef7', fillColor: '#5b6ef7',
fillOpacity: 0.1, weight: 1, opacity: 0.5,
}).addTo(navMap);
}
}, 200);
} else {
if (mapCont) mapCont.style.display = 'none';
}
return;
}
// Hide empty state and GPS warning, show map
if (emptyEl) emptyEl.style.display = 'none';
if (gpsWarn) gpsWarn.style.display = 'none';
if (mapCont) mapCont.style.display = '';
// Set header info
document.getElementById('navDestName').textContent = nav.asset_name || 'Navigate';
document.getElementById('navDestAddr').textContent = nav.asset_address || '';
// Init dedicated map centered on route midpoint
const midLat = (nav.origin_lat + nav.asset_lat) / 2;
const midLng = (nav.origin_lng + nav.asset_lng) / 2;
initNavMap(midLat, midLng);
// Give map time to init, then draw
setTimeout(() => {
if (!navMap) return;
// Origin (user) marker — blue
const userIcon = L.divIcon({
html: '<div style="width:14px;height:14px;border-radius:50%;background:#5b6ef7;border:2px solid #fff;box-shadow:0 0 6px rgba(91,110,247,0.6);"></div>',
className: '', iconSize: [14, 14], iconAnchor: [7, 7],
});
L.marker([nav.origin_lat, nav.origin_lng], { icon: userIcon })
.addTo(navMap).bindPopup('<b>You are here</b>');
// Destination marker — green pin
const destIcon = L.divIcon({
html: '<div style="width:20px;height:20px;border-radius:50%;background:#4ade80;border:2px solid #fff;box-shadow:0 0 10px rgba(74,222,128,0.6);font-size:14px;line-height:20px;text-align:center;">📍</div>',
className: '', iconSize: [20, 20], iconAnchor: [10, 10],
});
const destMarker = L.marker([nav.asset_lat, nav.asset_lng], { icon: destIcon }).addTo(navMap);
if (nav.asset_address) {
destMarker.bindPopup('<div style="min-width:140px;"><b>' + esc(nav.asset_name) + '</b><br><span style="font-size:11px;color:var(--text2);">📍 ' + esc(nav.asset_address) + '</span></div>');
}
// Route line — use OSRM road route if available
if (nav.route_coords && nav.route_coords.length > 1) {
const latlngs = nav.route_coords.map(c => [c[1], c[0]]);
L.polyline(latlngs, { color: '#5b6ef7', weight: 4, opacity: 0.9 }).addTo(navMap);
} else {
const isWalking = nav.route_mode === 'walking';
L.polyline([[nav.origin_lat, nav.origin_lng], [nav.asset_lat, nav.asset_lng]], {
color: isWalking ? '#4ade80' : '#5b6ef7', weight: 4, opacity: 0.8,
dashArray: isWalking ? '6, 8' : '10, 6',
}).addTo(navMap);
}
// Fit bounds
navMap.fitBounds([[nav.origin_lat, nav.origin_lng], [nav.asset_lat, nav.asset_lng]], { padding: [40, 40], maxZoom: 16 });
}, 200);
// Populate info card
const distM = nav.distance_meters || nav.straight_distance_m || 0;
const distKm = distM / 1000;
const distText = distKm >= 1 ? distKm.toFixed(2) + ' km' : Math.round(distM) + ' m';
document.getElementById('navDistance').textContent = distText;
document.getElementById('navCardinal').textContent = nav.cardinal || '—';
document.getElementById('navBearing').textContent = nav.bearing ? nav.bearing + '°' : '—';
document.getElementById('navInfoCard').style.display = '';
// ETA
const durationEl = document.getElementById('navDuration');
const durationCol = document.getElementById('navDurationCol');
if (durationEl && nav.route_duration_s) {
const mins = Math.round(nav.route_duration_s / 60);
durationEl.textContent = mins + ' min';
if (durationCol) durationCol.style.display = '';
} else if (durationCol) {
durationCol.style.display = 'none';
}
// Mode toggle highlighting
const mode = nav.route_mode || 'driving';
const btnDriving = document.getElementById('navModeDriving');
const btnWalking = document.getElementById('navModeWalking');
if (btnDriving) btnDriving.className = mode === 'driving' ? 'btn btn-sm' : 'btn btn-sm btn-outline';
if (btnWalking) btnWalking.className = mode === 'walking' ? 'btn btn-sm' : 'btn btn-sm btn-outline';
// Directions
const dirsLabel = document.getElementById('navDirectionsLabel');
if (nav.walking_directions) {
document.getElementById('navDirections').textContent = nav.walking_directions;
if (dirsLabel) dirsLabel.textContent = mode === 'walking' ? '🚶 Walking Directions' : '🚗 Driving Directions';
document.getElementById('navDirectionsCard').style.display = '';
} else {
document.getElementById('navDirectionsCard').style.display = 'none';
}
// Google Maps link
if (nav.google_maps_url) {
const link = document.getElementById('navGoogleMapsLink');
link.href = nav.google_maps_url;
link.textContent = mode === 'walking' ? '🧭 Open Walking in Google Maps' : '🧭 Open in Google Maps';
link.style.display = '';
} else {
document.getElementById('navGoogleMapsLink').style.display = 'none';
}
}
function closeNavigate() {
if (navMap) { navMap.remove(); navMap = null; }
AppState._navData = null;
// Reset navigate tab UI to empty state
const infoCard = document.getElementById('navInfoCard');
if (infoCard) infoCard.style.display = 'none';
const directionsCard = document.getElementById('navDirectionsCard');
if (directionsCard) directionsCard.style.display = 'none';
const gmapsLink = document.getElementById('navGoogleMapsLink');
if (gmapsLink) gmapsLink.style.display = 'none';
const gpsWarn = document.getElementById('navGpsWarning');
if (gpsWarn) gpsWarn.style.display = 'none';
document.getElementById('navDestName').textContent = 'Navigate';
document.getElementById('navDestAddr').textContent = '';
}
function navBackToPrevious() {
// Called from back button — resets nav state and goes to previous tab
closeNavigate();
const prevTab = AppState._prevTab || 'tabMap';
AppState._prevTab = null;
// Avoid closeNavigate recursion in switchTab
AppState.currentTab = null;
switchTab(prevTab);
}
// ── Navigate page asset search ────────────────────────────────────────
let navSearchTimer = null;
async function searchNavAssets() {
clearTimeout(navSearchTimer);
navSearchTimer = setTimeout(async () => {
const q = document.getElementById('navSearchInput').value.trim();
const results = document.getElementById('navSearchResults');
const clearBtn = document.getElementById('clearNavSearch');
if (clearBtn) clearBtn.style.display = q ? '' : 'none';
if (!q) { results.innerHTML = ''; return; }
const hasGPS = !!(AppState.gpsLat && AppState.gpsLng);
try {
const assets = await api('/api/assets?q=' + encodeURIComponent(q) + '&limit=8');
// Build results header with GPS status
let html = '';
if (!hasGPS) {
html += '<div style="font-size:12px;color:var(--amber);padding:4px 4px 8px;">📍 GPS off — straight-line routing only · <span style="text-decoration:underline;cursor:pointer;" onclick="reEnableGPS()">enable</span></div>';
}
if (!assets.length) {
html += '<div class="empty-state" style="padding:12px;">No assets found</div>';
results.innerHTML = html;
return;
}
html += assets.map(a => {
const hasLocation = a.latitude != null && a.longitude != null;
const canNavigate = hasLocation && hasGPS;
const clickAttr = canNavigate ? ` onclick="navToSearchedAsset(${a.id})" style="cursor:pointer;"` : ' style="opacity:0.45;cursor:default;"';
const badge = !hasLocation ? '⚠ No GPS' : (!hasGPS ? '📍' : '🧭');
return `<div class="asset-item"${clickAttr}>
<div class="ai-icon cat-${cssSafe(a.category)}">${catIcon(a.category)}</div>
<div class="ai-info">
<div class="ai-name">${esc(a.name)}</div>
<div class="ai-meta">${esc(a.machine_id || '')} · ${badge} ${hasLocation ? esc(a.address || 'GPS tagged') : 'No coordinates'}</div>
</div>
<div class="ai-arrow">${canNavigate ? '→' : ''}</div>
</div>`;
}).join('');
results.innerHTML = html;
} catch (e) {
results.innerHTML = '<div class="empty-state" style="padding:12px;">Search failed</div>';
}
}, 300);
}
function clearNavSearch() {
const input = document.getElementById('navSearchInput');
input.value = '';
document.getElementById('navSearchResults').innerHTML = '';
document.getElementById('clearNavSearch').style.display = 'none';
input.focus();
}
async function navToSearchedAsset(assetId) {
// Fetch asset details to get navigation data
if (!AppState.gpsLat || !AppState.gpsLng) {
showToast('GPS location not available — enable location access to navigate', true);
return;
}
// Show loading feedback
const btn = document.querySelector(`#navSearchResults .asset-item[onclick*="${assetId}"]`);
if (btn) btn.style.opacity = '0.5';
try {
const nav = await api('/api/assets/' + assetId +
'/navigation?lat=' + AppState.gpsLat + '&lng=' + AppState.gpsLng);
AppState._navData = nav;
renderNavPage();
// Clear search
document.getElementById('navSearchInput').value = '';
document.getElementById('navSearchResults').innerHTML = '';
document.getElementById('clearNavSearch').style.display = 'none';
} catch (e) {
showToast(e.message, true);
}
}
async function switchNavMode(mode) {
// Re-fetch navigation with new mode
const nav = AppState._navData;
if (!nav) return;
try {
const newNav = await api('/api/assets/' + nav.asset_id +
'/navigation?lat=' + AppState.gpsLat + '&lng=' + AppState.gpsLng + '&mode=' + mode);
AppState._navData = newNav;
renderNavPage();
} catch (e) {
showToast(e.message, true);
}
}
function clearRouteLine() {
if (!map) return;
if (map._routeLine) { map.removeLayer(map._routeLine); map._routeLine = null; }
if (map._routeOriginMarker) { map.removeLayer(map._routeOriginMarker); map._routeOriginMarker = null; }
if (map._routeDestMarker) { map.removeLayer(map._routeDestMarker); map._routeDestMarker = null; }
if (map._routeDistLabel) { map.removeLayer(map._routeDistLabel); map._routeDistLabel = null; }
}
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 || '')}">
<div class="form-row">
<input id="editParking" type="text" class="input-field" placeholder="Parking location" value="${esc(a.parking_location || '')}">
<button class="btn btn-outline btn-sm" onclick="fillGpsParking('editParking')" style="flex-shrink:0;">📍 GPS</button>
</div>
<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' });
showSuccessFeedback('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';
// Show skeleton loading
renderCustomerSkeletons();
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 = renderEmptyState('🏢', 'No Customers Yet', 'Add your first customer to start organizing assets by site. Customers group locations and contacts for service tracking.', 'Add Customer', 'showCustForm()');
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 = renderEmptyState('📍', 'No Locations Yet', 'Add a location to this customer to track where assets are placed. Locations include addresses, buildings, and room details.', 'Add Location', 'showLocFormForCust(' + id + ')');
} 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 = renderEmptyState('📍', 'No Locations Yet', 'Add the first location for this customer. Locations are physical sites where assets are installed.', 'Add Location', 'showLocForm()');
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
// ═══════════════════════════════════════════════════════════════════════
// Dashboard cache (30-second TTL on frontend)
let _dashCache = { data: null, ts: 0 };
const DASH_CACHE_TTL = 30000; // 30 seconds in ms
async function loadDashboard(force = false) {
try {
// Check frontend cache first
const now = Date.now();
if (!force && _dashCache.data && (now - _dashCache.ts) < DASH_CACHE_TTL) {
restoreDashboardContent();
renderDashboard(_dashCache.data.stats, _dashCache.data.activity);
return;
}
// Show skeleton loading
if (!_dashCache.data) {
renderDashboardSkeletons();
}
// Fetch stats and activity in parallel
const [stats, activity] = await Promise.all([
api('/api/stats'),
api('/api/activity?limit=15'),
]);
// Cache the response
_dashCache = { data: { stats, activity }, ts: now };
restoreDashboardContent();
renderDashboard(stats, activity);
} catch (e) {
console.error('Dashboard error:', e);
showToast('Failed to load dashboard: ' + e.message, true);
}
}
function renderDashboard(stats, activity) {
// If no assets at all, show a comprehensive empty state
if (!stats.total_assets) {
var dashEl = document.getElementById('tabDashboard');
// Hide stat cards but not the activity card
var hideEls = dashEl.querySelectorAll('#statsGrid, #statsCategories, #statsStatuses, #statsMakes, #highVisitCard, .quick-actions');
var cards = dashEl.querySelectorAll('.card');
for (var i = 0; i < cards.length; i++) {
if (cards[i].querySelector('#activityFeed')) continue;
cards[i].style.display = 'none';
}
for (var k = 0; k < hideEls.length; k++) hideEls[k].style.display = 'none';
var existing = document.getElementById('dashEmptyState');
if (!existing) {
var es = document.createElement('div');
es.id = 'dashEmptyState';
es.innerHTML = renderEmptyState('📊', 'No Data Yet',
'Add your first asset and check it in to unlock the dashboard. Stats, charts, and activity will appear here.',
'Add Asset', "switchTab('tabAddAsset')");
dashEl.insertBefore(es, dashEl.firstChild);
} else {
existing.style.display = '';
}
renderActivityFeed(activity || []);
return;
}
// Restore dashboard content
var dashEl2 = document.getElementById('tabDashboard');
var allCards = dashEl2.querySelectorAll('.card, #statsGrid, .quick-actions');
for (var j = 0; j < allCards.length; j++) allCards[j].style.display = '';
var es2 = document.getElementById('dashEmptyState');
if (es2) es2.style.display = 'none';
// --- Stat cards ---
document.getElementById('statAssets').textContent = stats.total_assets || 0;
document.getElementById('statCheckins').textContent = stats.total_checkins || 0;
document.getElementById('statTechs').textContent = stats.active_technicians || 0;
// --- By Category (interactive: hover tooltip + click drill-down) ---
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 clickable" onclick="filterAssetsByCategory('${esc(cat)}')"
onmouseenter="showBarTooltip(event, '${esc(cat)}', ${cnt}, 'assets')"
onmouseleave="hideBarTooltip()">
<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" style="padding:16px;"><span style="font-size:28px;display:block;margin-bottom:6px;opacity:0.4;">📂</span>After adding assets, category breakdowns will appear here</div>';
// --- By Status (interactive) ---
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 clickable" onclick="filterAssetsByStatus('${esc(st)}')"
onmouseenter="showBarTooltip(event, '${esc(st)}', ${cnt}, 'status')"
onmouseleave="hideBarTooltip()">
<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" style="padding:16px;"><span style="font-size:28px;display:block;margin-bottom:6px;opacity:0.4;">🏷️</span>When assets are added, status breakdowns (active, maintenance, retired) will appear here</div>';
// --- By Make (interactive) ---
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 clickable" onclick="filterAssetsByMake('${esc(make)}')"
onmouseenter="showBarTooltip(event, '${esc(make)}', ${cnt}, 'make')"
onmouseleave="hideBarTooltip()">
<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('')
: ''; // Hide section when no manufacturer data
// Show/hide the makes card based on data
var makesCard = document.getElementById('statsMakes').parentElement;
if (makesCard && !makeEntries.length) {
makesCard.style.display = 'none';
} else if (makesCard) {
makesCard.style.display = '';
}
// --- 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('')
: renderEmptyState('📍', 'No visit data yet',
'Most-visited assets will appear here once technicians start checking in at locations. Add a check-in to begin tracking.',
'Add Check-in', "switchTab('tabAddAsset')");
// --- Recent Activity ---
renderActivityFeed(activity || []);
// 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);
}
}
}
// --- Bar tooltip ---
function showBarTooltip(e, label, count, type) {
var tip = document.getElementById('barTooltip');
if (!tip) {
tip = document.createElement('div');
tip.id = 'barTooltip';
tip.className = 'stat-bar-tooltip';
document.body.appendChild(tip);
}
var typeLabel = type === 'make' ? 'manufacturer' : type;
tip.textContent = label + ': ' + count + ' asset' + (count !== 1 ? 's' : '');
tip.style.display = 'block';
tip.style.left = e.clientX + 12 + 'px';
tip.style.top = e.clientY - 30 + 'px';
}
function hideBarTooltip() {
var tip = document.getElementById('barTooltip');
if (tip) tip.style.display = 'none';
}
// --- Drill-down: clicking a bar filters assets list ---
async function filterAssetsByCategory(cat) {
switchTab('tabAssets');
await new Promise(r => setTimeout(r, 50));
var pills = document.querySelectorAll('#tabAssets .pill');
pills.forEach(p => p.classList.remove('active'));
// Find the matching pill
var matched = false;
pills.forEach(p => {
if (p.textContent.trim() === cat) {
p.classList.add('active');
matched = true;
}
});
if (matched) {
AppState.assetCategoryFilter = cat;
loadAssets();
}
}
async function filterAssetsByStatus(st) {
switchTab('tabAssets');
await new Promise(r => setTimeout(r, 50));
var searchInput = document.querySelector('#tabAssets .search-bar input');
if (searchInput) {
searchInput.value = 'status:' + st;
searchInput.dispatchEvent(new Event('input'));
}
}
async function filterAssetsByMake(make) {
switchTab('tabAssets');
await new Promise(r => setTimeout(r, 50));
var searchInput = document.querySelector('#tabAssets .search-bar input');
if (searchInput) {
searchInput.value = make;
searchInput.dispatchEvent(new Event('input'));
}
}
function renderActivityFeed(activities) {
const el = document.getElementById('activityFeed');
if (!activities.length) {
el.innerHTML = '<div class="empty-state"><div class="es-icon">📝</div>No recent activity.<br><span style="font-size:11px;">Check-ins, asset changes, and updates will appear here.</span></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 };
let reportCacheKey = null;
function setDatePreset(preset) {
const now = new Date();
const df = document.getElementById('reportDateFrom');
const dt = document.getElementById('reportDateTo');
const toISO = d => d.toISOString().split('T')[0];
// Highlight active preset
document.querySelectorAll('.rpt-preset').forEach(b => b.classList.remove('active'));
switch (preset) {
case 'today':
df.value = toISO(now);
dt.value = toISO(now);
document.querySelector('.rpt-preset[onclick*="today"]').classList.add('active');
break;
case 'week': {
const start = new Date(now);
start.setDate(now.getDate() - now.getDay());
df.value = toISO(start);
dt.value = toISO(now);
document.querySelector('.rpt-preset[onclick*="week"]').classList.add('active');
break;
}
case 'month': {
const start = new Date(now.getFullYear(), now.getMonth(), 1);
df.value = toISO(start);
dt.value = toISO(now);
document.querySelector('.rpt-preset[onclick*="month"]').classList.add('active');
break;
}
case 'lastmonth': {
const start = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const end = new Date(now.getFullYear(), now.getMonth(), 0);
df.value = toISO(start);
dt.value = toISO(end);
document.querySelector('.rpt-preset[onclick*="lastmonth"]').classList.add('active');
break;
}
case 'all':
df.value = '';
dt.value = '';
document.querySelector('.rpt-preset[onclick*="all"]').classList.add('active');
break;
}
loadReports();
}
async function loadReports() {
const df = document.getElementById('reportDateFrom').value;
const dt = document.getElementById('reportDateTo').value;
// Build cache key from params
const cacheKey = `from=${df}&to=${dt}`;
if (reportCacheKey === cacheKey && reportVisitsCache.length > 0) {
// Use cached data — just re-render (no fetch)
restoreReportContent();
renderServiceSummary(reportVisitsCache);
renderVisitFrequency(reportVisitsCache);
renderTimeOnSite(reportVisitsCache);
return;
}
// Show skeleton loading
renderReportSkeletons();
// Show loading state
const loadingEl = document.getElementById('rptLoading');
const genBtn = document.getElementById('rptGenerateBtn');
const downloadBtn = document.getElementById('rptDownloadBtn');
if (loadingEl) loadingEl.style.display = 'flex';
if (genBtn) { genBtn.disabled = true; genBtn.textContent = 'Loading...'; }
if (downloadBtn) downloadBtn.disabled = true;
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;
reportCacheKey = cacheKey;
restoreReportContent();
renderServiceSummary(visits);
renderVisitFrequency(visits);
renderTimeOnSite(visits);
// Show/hide download button based on data
if (downloadBtn) {
if (visits.length === 0) {
downloadBtn.style.display = 'none';
} else {
downloadBtn.style.display = '';
downloadBtn.disabled = false;
}
}
} catch (e) {
showToast('Failed to load reports: ' + e.message, true);
} finally {
if (loadingEl) loadingEl.style.display = 'none';
if (genBtn) { genBtn.disabled = false; genBtn.textContent = 'Generate Report'; }
}
}
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 with location tracking
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,
assetId: v.asset_id,
hasLocation: v.latitude != null && v.longitude != null,
lat: v.latitude, lng: v.longitude,
};
}
assetMap[key].visits++;
if (!assetMap[key].last || v.checkin_time > assetMap[key].last) {
assetMap[key].last = v.checkin_time;
}
// Update location from most recent visit
if (v.latitude != null && v.longitude != null) {
assetMap[key].hasLocation = true;
assetMap[key].lat = v.latitude;
assetMap[key].lng = v.longitude;
}
});
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>
<th></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>${r.hasLocation && r.assetId ? `<button class="btn-map-link" onclick="viewOnMap(${r.assetId}, ${r.lat}, ${r.lng})">\u{1f4cd} Map</button>` : ''}</td>
</tr>`).join('')}</tbody>
</table>` : renderEmptyState('📋', 'No visits in this date range',
'Try selecting a different date range or check back after technicians log visits. Visits are automatically tracked when a technician checks in near an asset location.',
'Add Asset', "switchTab('tabAddAsset')");
document.getElementById('reportServiceTable').innerHTML = html;
}
function viewOnMap(assetId, lat, lng) {
switchTab('tabMap');
// Fly to this asset's location on the map
setTimeout(() => {
if (map) {
map.setView([lat, lng], 17);
// Find and open the popup for this asset
const match = assetMarkers.find(m => m.asset && m.asset.id === assetId);
if (match) {
match.marker.openPopup();
} else {
// Asset might not be pinned yet — create a temp marker
const tempIcon = L.divIcon({
className: '',
html: '<div style="background:var(--accent);width:14px;height:14px;border-radius:50%;border:2px solid #fff;box-shadow:0 0 8px var(--accent);"></div>',
iconSize: [14, 14], iconAnchor: [7, 7],
});
const tempMarker = L.marker([lat, lng], { icon: tempIcon }).addTo(map);
setTimeout(() => map.removeLayer(tempMarker), 5000);
}
}
}, 300);
}
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">\u{2195}</span>';
return reportSortState.dir === 1 ? ' <span class="sort-arrow asc">\u{25b2}</span>' : ' <span class="sort-arrow desc">\u{25bc}</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>` : renderEmptyState('📊', 'No visit frequency data',
'Visit frequency shows how often each asset has been checked. Data appears here automatically as technicians log visits.',
'Add Asset', "switchTab('tabAddAsset')");
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>` : renderEmptyState('⏱️', 'No technician time data',
'Time-on-site data is calculated when technicians check in and out at asset locations. This section shows per-technician averages once visits are logged.',
'Add Asset', "switchTab('tabAddAsset')");
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>` : renderEmptyState('📦', 'No asset time data',
'Time-per-asset is calculated from check-in/check-out durations. Make sure assets have locations assigned so technicians can check in on-site.',
'Add Asset', "switchTab('tabAddAsset')");
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 markerCluster = null; // Leaflet.markercluster group
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);
// Marker cluster group for asset pins
markerCluster = L.markerClusterGroup({
maxClusterRadius: 50,
spiderfyOnMaxZoom: true,
showCoverageOnHover: false,
zoomToBoundsOnClick: true,
});
map.addLayer(markerCluster);
// 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();
// 1) Assets that already have their own lat/lng
const withCoords = assets.filter(a => a.latitude != null && a.longitude != null);
withCoords.forEach(asset => addAssetMarker(asset));
// 2) Assets without own coords but with location coords (fallback)
const withLocCoords = assets.filter(a =>
(a.latitude == null || a.longitude == null) &&
a.location_latitude != null && a.location_longitude != null
);
withLocCoords.forEach(asset => {
asset.latitude = asset.location_latitude;
asset.longitude = asset.location_longitude;
addAssetMarker(asset);
});
// 3) Assets with addresses but no coordinates — geocode them
const needsGeocode = assets.filter(a =>
(a.latitude == null || a.longitude == null) &&
(a.address || a.building_name ||
a.location_address || a.location_building_name)
);
if (needsGeocode.length > 0) {
await geocodeAndPin(needsGeocode);
}
// Render legend after pins are on the map
renderMapLegend();
// Update heatmap layer if visible
if (heatVisible) await loadHeatmapData();
} catch (e) {
console.error('Failed to load asset pins:', e);
}
}
// Geocode assets that have address fields but no GPS coordinates
async function geocodeAndPin(assets) {
let geocoded = 0;
for (const asset of assets) {
try {
// Try asset's own address fields first, then fall back to location address
let addr = [asset.address, asset.building_name,
asset.building_number, asset.city, asset.state, asset.zip
].filter(Boolean).join(', ');
if (!addr && (asset.location_address || asset.location_building_name)) {
addr = [asset.location_address, asset.location_building_name,
asset.location_building_number
].filter(Boolean).join(', ');
}
if (!addr) continue;
const result = await api(`/api/geocode-address?address=${encodeURIComponent(addr)}`);
if (result && result.lat && result.lng) {
asset.latitude = result.lat;
asset.longitude = result.lng;
addAssetMarker(asset);
geocoded++;
}
} catch (e) {
// Skip individual geocoding failures — not all addresses resolve
}
}
if (geocoded > 0) {
showToast(`📍 Geocoded ${geocoded} address${geocoded !== 1 ? 'es' : ''}`);
}
}
function clearAssetMarkers() {
if (markerCluster) markerCluster.clearLayers();
assetMarkers = [];
}
function addAssetMarker(asset) {
if (!map || !markerCluster) return;
const icon = makeAssetIcon(asset);
const marker = L.marker([asset.latitude, asset.longitude], { icon });
markerCluster.addLayer(marker);
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);
if (markerCluster) {
if (show) {
map.addLayer(markerCluster);
} else {
map.removeLayer(markerCluster);
}
} else {
assetMarkers.forEach(({ marker }) => {
if (show) marker.addTo(map);
else map.removeLayer(marker);
});
}
}
// Render map legend for asset marker colors
function renderMapLegend() {
// Remove existing legend
const old = document.querySelector('.map-legend');
if (old) old.remove();
const legend = document.createElement('div');
legend.className = 'map-legend';
legend.innerHTML = `
<div class="legend-title">📍 Asset Types</div>
${Object.entries(CAT_COLORS).map(([cat, color]) =>
cat !== 'Utensils &amp; Serveware' ? `
<div class="legend-item">
<div class="legend-dot" style="background:${color};"></div>
<span>${esc(cat)}</span>
</div>` : ''
).join('')}
`;
document.getElementById('mapContainer').appendChild(legend);
}
// ═══════════════════════════════════════════════════════════════════════
// 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 = await showModal('New Geofence', {
type: 'prompt',
inputLabel: 'Geofence name',
inputValue: 'Zone ' + (geofenceLayers.length + 1),
confirmText: 'Save'
});
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;
// Handle both [lat, lng] arrays and {lat, lng} objects
const latlngs = points.map(p =>
Array.isArray(p) ? [p[0], p[1]] : [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 = await showModal('Rename Geofence', {
type: 'prompt',
inputLabel: 'Geofence name',
inputValue: gf.geofence.name,
confirmText: 'Rename'
});
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 real check-in GPS coordinates for heatmap
const data = await api('/api/checkins/heatmap?days=90&limit=5000');
const points = data.points || [];
if (heatLayer) map.removeLayer(heatLayer);
if (points.length === 0) {
showToast('No check-in data for heatmap yet', true);
heatVisible = false;
const chip = document.getElementById('chipHeat');
chip.classList.remove('heat-on');
chip.textContent = '🔥 Heatmap';
return;
}
// Convert to Leaflet.heat format: [lat, lng, intensity]
const heatData = points.map(p => [p.lat, p.lng, p.intensity]);
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 input = await showModal('Reset Database', {
type: 'prompt',
title: '⚠️ Reset Database',
body: 'This will DELETE ALL DATA and re-seed the database. This cannot be undone!',
inputLabel: 'Type RESET to confirm database wipe',
inputPlaceholder: 'RESET',
confirmText: 'Wipe Database',
confirmClass: 'btn-danger',
});
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();
initOnboarding();
initMouseDetection();
initPullToRefresh();
initSwipeToDelete();
// ── FAB initial visibility ─────────────────────────────────────────────
const fab = document.getElementById('fabAdd');
if (fab) {
// FAB visible only on Assets tab (initial tab is AddAsset)
const activePanel = document.querySelector('.tab-panel.active');
fab.style.display = (activePanel && activePanel.id === 'tabAssets') ? '' : 'none';
}
// ── Button ripple / haptic micro-animations ───────────────────────────
document.addEventListener('click', function(e) {
const btn = e.target.closest('.btn, .qa-btn, .pill, .mode-toggle, .esc-cta, .esc-cta-sm');
if (!btn) return;
// Exclude FAB (has its own animation)
if (btn.id === 'fabAdd') return;
// Quick haptic scale — add a temporary class, let CSS handle it
btn.classList.add('btn-pressed');
setTimeout(() => btn.classList.remove('btn-pressed'), 120);
});
</script>
<!-- ═══════════════════════════════════════════════════════════════════════
ONBOARDING WELCOME OVERLAY
═══════════════════════════════════════════════════════════════════════ -->
<div id="onboardOverlay" class="onboard-overlay">
<div class="onboard-card">
<div class="esc-icon">🎯</div>
<div style="font-size:20px;font-weight:700;margin-bottom:8px;color:var(--text);">Welcome to Canteen Asset Tracker</div>
<div style="font-size:13px;color:var(--text2);line-height:1.5;margin-bottom:20px;">
Track equipment, manage check-ins, and locate assets across customer sites.
</div>
<button class="esc-cta" onclick="closeOnboarding()">Get Started</button>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════
FLOATING ACTION BUTTON
═══════════════════════════════════════════════════════════════════════ -->
<button class="fab pulse" id="fabAdd" onclick="fabAction()" title="Quick Add" aria-label="Add asset">
</button>
<!-- Undo toast -->
<div class="undo-toast" id="undoToast">
<span>🗑️ Deleted</span>
<button class="undo-btn" onclick="undoDelete()">Undo</button>
<span style="cursor:pointer;opacity:0.5;font-size:14px;" onclick="dismissUndo()"></span>
</div>
<!-- Success feedback overlay -->
<div class="success-overlay" id="successOverlay" onclick="this.classList.remove('show')">
<div class="success-check"></div>
</div>
</body>
</html>