389f1f11d2
The switchTab function had an early return after closeNavigate() that skipped the actual tab panel switching (slide animation, active class management). closeNavigate() only cleans up navigation state (map, data, UI elements) — it does NOT switch panels. This caused the Navigate panel to remain visible permanently after the first visit, overlapping with any subsequent tab content. Removed the early return so the normal tab switching logic runs after closeNavigate() cleanup.
7820 lines
363 KiB
HTML
7820 lines
363 KiB
HTML
<!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.2s 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);
|
||
}
|
||
@keyframes tabSlideIn {
|
||
from { opacity: 0; transform: translateX(20px); }
|
||
to { opacity: 1; transform: translateX(0); }
|
||
}
|
||
@keyframes tabSlideOutLeft {
|
||
from { opacity: 1; transform: translateX(0); }
|
||
to { opacity: 0; transform: translateX(-20px); }
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
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.15s, transform 0.1s;
|
||
}
|
||
.btn:active { transform: scale(0.97); }
|
||
.btn-primary { background: var(--accent); color: #fff; width: 100%; }
|
||
.btn-primary:hover { background: var(--accent2); }
|
||
.btn-sm { padding: 8px 14px; font-size: 13px; width: auto; }
|
||
.btn-outline { background: transparent; border: 1.5px solid var(--border); color: var(--text); }
|
||
.btn-danger { background: transparent; border: 1.5px solid var(--red); color: var(--red); }
|
||
.btn-green { background: var(--green); color: #000; }
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
INPUTS
|
||
═══════════════════════════════════════════════════════════════════════ */
|
||
.input-field {
|
||
width: 100%; background: #0a0b0f; border: 1.5px solid var(--border);
|
||
border-radius: var(--radius-sm); color: var(--text); padding: 11px 14px;
|
||
font-size: 16px; outline: none; transition: border-color 0.2s; margin-bottom: 8px;
|
||
}
|
||
.input-field:focus { border-color: var(--accent); }
|
||
.input-field::placeholder { color: #3a3d4a; }
|
||
textarea.input-field { resize: vertical; min-height: 60px; font-family: inherit; font-size: 14px; }
|
||
select.input-field { appearance: none; }
|
||
.form-row { display: flex; gap: 8px; }
|
||
.form-row .input-field { flex: 1; }
|
||
|
||
/* checkbox */
|
||
.checkbox-row {
|
||
display: flex; align-items: center; gap: 8px;
|
||
padding: 8px 0; font-size: 14px; cursor: pointer;
|
||
}
|
||
.checkbox-row input[type=checkbox] {
|
||
width: 18px; height: 18px; accent-color: var(--accent); cursor: pointer;
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
STATUS BAR
|
||
═══════════════════════════════════════════════════════════════════════ */
|
||
.status-bar {
|
||
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
|
||
background: #1a1b26; border-radius: var(--radius-sm); font-size: 12px;
|
||
color: var(--text2); margin-bottom: 8px; min-height: 36px;
|
||
}
|
||
.status-bar.success { background: var(--green-bg); color: var(--green); }
|
||
.status-bar.error { background: var(--red-bg); color: var(--red); }
|
||
.status-bar.working { color: var(--accent2); }
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
TOAST
|
||
═══════════════════════════════════════════════════════════════════════ */
|
||
.toast {
|
||
position: fixed; bottom: calc(var(--tab-height) + 12px); left: 50%;
|
||
transform: translateX(-50%); background: var(--green); color: #000;
|
||
font-weight: 700; padding: 10px 22px; border-radius: 20px; font-size: 14px;
|
||
z-index: 400; opacity: 0; transition: opacity 0.3s; pointer-events: none;
|
||
white-space: nowrap; max-width: 90vw; overflow: hidden; text-overflow: ellipsis;
|
||
}
|
||
.toast.show { opacity: 1; }
|
||
.toast.error { background: var(--red); color: #fff; }
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
SPINNER
|
||
═══════════════════════════════════════════════════════════════════════ */
|
||
.spinner {
|
||
width: 20px; height: 20px; border: 2px solid var(--border);
|
||
border-top-color: var(--accent); border-radius: 50%;
|
||
animation: spin 0.7s linear infinite; display: inline-block;
|
||
}
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
|
||
/* 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;
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
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;
|
||
}
|
||
|
||
/* 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);
|
||
}
|
||
</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 & 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">
|
||
<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>
|
||
</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 & 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 & 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="closeNavigate()">← 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>
|
||
<!-- 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;">🧭</div>
|
||
<div style="font-size:14px;color:var(--text);margin-bottom:8px;">Find your way</div>
|
||
<div style="font-size:13px;color:var(--text2);margin-bottom:12px;">
|
||
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 & 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">
|
||
<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';
|
||
|
||
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';
|
||
badge.innerHTML = '📍 ' + err.message + '<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 re‑enable</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) {
|
||
const t = document.getElementById('toast');
|
||
t.textContent = msg;
|
||
t.className = 'toast' + (isError ? ' error' : '');
|
||
t.classList.add('show');
|
||
clearTimeout(toastTimer);
|
||
toastTimer = setTimeout(() => t.classList.remove('show'), 2000);
|
||
}
|
||
|
||
// =========================================================================
|
||
// MODAL — 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;
|
||
document.getElementById('modalOverlay').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) {
|
||
document.getElementById('modalOverlay').classList.remove('open');
|
||
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();
|
||
|
||
// 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', 'anim-enter');
|
||
newPanel.addEventListener('animationend', function handler() {
|
||
newPanel.removeEventListener('animationend', handler);
|
||
newPanel.classList.remove('anim-enter');
|
||
}, { once: true });
|
||
}
|
||
// Animate old panel out while new slides in
|
||
oldPanel.classList.add('anim-exit');
|
||
oldPanel.classList.remove('active');
|
||
oldPanel.addEventListener('animationend', function handler() {
|
||
oldPanel.removeEventListener('animationend', handler);
|
||
oldPanel.classList.remove('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 } }));
|
||
}
|
||
|
||
// =========================================================================
|
||
// 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(/&/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">
|
||
<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 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.closest('.form-collapse-section');
|
||
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)
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// 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 loading state immediately
|
||
isLoading = true;
|
||
showListLoading(true);
|
||
document.getElementById('assetCountBar').innerHTML = '<div class="spinner"></div> Searching...';
|
||
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;
|
||
}
|
||
}
|
||
|
||
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' : '');
|
||
}
|
||
}
|
||
|
||
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>' : '';
|
||
return `
|
||
<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>`;
|
||
}).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();
|
||
}
|
||
|
||
function showDetailView() {
|
||
hideAllAssetViews();
|
||
document.getElementById('assetsDetailView').style.display = 'block';
|
||
}
|
||
|
||
function showEditView() {
|
||
hideAllAssetViews();
|
||
document.getElementById('assetsEditView').style.display = 'block';
|
||
}
|
||
|
||
function showImportView() {
|
||
hideAllAssetViews();
|
||
document.getElementById('assetsImportView').style.display = 'block';
|
||
// Reset import state
|
||
document.getElementById('importFileInput').value = '';
|
||
document.getElementById('importPreview').style.display = 'none';
|
||
document.getElementById('importResults').style.display = 'none';
|
||
document.getElementById('importRunBtn').disabled = true;
|
||
}
|
||
|
||
// ── Detail view ───────────────────────────────────────────────────────
|
||
function nvl(v, fallback) { return (v !== null && v !== undefined && v !== '') ? v : fallback; }
|
||
|
||
async function viewAsset(id) {
|
||
AppState.currentAssetId = id;
|
||
try {
|
||
const a = await api('/api/assets/' + id);
|
||
showDetailView();
|
||
|
||
document.getElementById('detailName').textContent = a.name;
|
||
document.getElementById('detailMeta').innerHTML =
|
||
`${esc(a.machine_id)} · <span class="status-tag ${a.status || 'active'}">${a.status || 'active'}</span>`;
|
||
|
||
// Main details
|
||
document.getElementById('detailFields').innerHTML = [
|
||
df('Machine ID', a.machine_id, true),
|
||
df('Name', a.name),
|
||
df('Serial Number', a.serial_number, true),
|
||
df('Description', a.description),
|
||
df('Category', a.category),
|
||
df('Make', a.make),
|
||
df('Model', a.model),
|
||
df('Status', statusBadge(a.status)),
|
||
].filter(Boolean).join('');
|
||
|
||
// Customer / Location / Technician
|
||
if (a.customer_name) {
|
||
document.getElementById('detailFields').innerHTML += df('Customer',
|
||
`<span class="df-link" onclick="switchTab('tabCustomers')">${esc(a.customer_name)}</span>`);
|
||
}
|
||
if (a.location_name) {
|
||
document.getElementById('detailFields').innerHTML += df('Location',
|
||
`<span class="df-link" onclick="switchTab('tabCustomers')">${esc(a.location_name)}</span>`);
|
||
}
|
||
if (a.assigned_to) {
|
||
var techName = a.assigned_user || ('ID: ' + a.assigned_to);
|
||
document.getElementById('detailFields').innerHTML += df('Assigned Technician', '👤 ' + esc(techName));
|
||
}
|
||
|
||
// Timestamps
|
||
document.getElementById('detailFields').innerHTML +=
|
||
df('Created', formatDate(a.created_at)) +
|
||
df('Updated', formatDate(a.updated_at));
|
||
|
||
// Directions
|
||
const hasDir = a.address || a.building_name || a.building_number || a.floor || a.room || a.walking_directions || a.map_link || a.parking_location;
|
||
const dirCard = document.getElementById('detailDirections');
|
||
const dirBtn = document.getElementById('detailDirectionsBtn');
|
||
if (hasDir) {
|
||
dirCard.style.display = 'block';
|
||
if (a.map_link) dirBtn.style.display = 'inline-flex';
|
||
else dirBtn.style.display = 'none';
|
||
document.getElementById('detailDirFields').innerHTML = [
|
||
df('Address', a.address),
|
||
df('Building', a.building_name),
|
||
df('Building #', a.building_number),
|
||
df('Floor', a.floor),
|
||
df('Room', a.room),
|
||
df('Walking Directions', a.walking_directions),
|
||
df('Map Link', a.map_link ? `<span class="df-link" onclick="openMapLink()">${esc(a.map_link)}</span>` : null),
|
||
df('Parking', a.parking_location),
|
||
].filter(Boolean).join('');
|
||
} else {
|
||
dirCard.style.display = 'none';
|
||
dirBtn.style.display = 'none';
|
||
}
|
||
|
||
// 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. Tap ◎ My Location first.', true);
|
||
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');
|
||
|
||
if (!nav) {
|
||
if (emptyEl) emptyEl.style.display = '';
|
||
if (mapCont) mapCont.style.display = 'none';
|
||
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 = '';
|
||
return;
|
||
}
|
||
|
||
// Hide empty state, show map
|
||
if (emptyEl) emptyEl.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';
|
||
document.getElementById('navDestName').textContent = 'Navigate';
|
||
document.getElementById('navDestAddr').textContent = '';
|
||
// Do NOT call switchTab here — the caller handles the tab transition
|
||
}
|
||
|
||
// ── 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; }
|
||
try {
|
||
const assets = await api('/api/assets?q=' + encodeURIComponent(q) + '&limit=8');
|
||
if (!assets.length) {
|
||
results.innerHTML = '<div class="empty-state" style="padding:12px;">No assets found</div>';
|
||
return;
|
||
}
|
||
results.innerHTML = assets.map(a => {
|
||
const hasLocation = a.latitude != null && a.longitude != null;
|
||
return `<div class="asset-item" style="${hasLocation ? '' : 'opacity:0.5;'}"
|
||
onclick="navToSearchedAsset(${a.id})">
|
||
<div class="ai-icon cat-${cssSafe(a.category)}">📍</div>
|
||
<div class="ai-info">
|
||
<div class="ai-name">${esc(a.name)}</div>
|
||
<div class="ai-meta">${esc(a.machine_id || '')} ${hasLocation ? '📍 ' + esc(a.address || 'GPS tagged') : '⚠ No location'}</div>
|
||
</div>
|
||
<div class="ai-arrow">→</div>
|
||
</div>`;
|
||
}).join('');
|
||
} 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. Tap ◎ My Location first.', true);
|
||
return;
|
||
}
|
||
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' });
|
||
showToast('Asset deleted');
|
||
showAssetList();
|
||
} catch (e) {
|
||
showToast(e.message, true);
|
||
}
|
||
}
|
||
|
||
// ── CSV Import ────────────────────────────────────────────────────────
|
||
let importRows = [];
|
||
|
||
document.getElementById('importFileInput').addEventListener('change', function(e) {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = function(ev) {
|
||
const text = ev.target.result;
|
||
const lines = text.split('\n').filter(l => l.trim());
|
||
if (lines.length < 2) {
|
||
showToast('CSV must have a header row and at least one data row', true);
|
||
return;
|
||
}
|
||
// Parse header
|
||
const header = lines[0].split(',').map(h => h.trim().toLowerCase());
|
||
const idx = {
|
||
machine_id: header.indexOf('machine_id'),
|
||
name: header.indexOf('name'),
|
||
category: header.indexOf('category'),
|
||
make: header.indexOf('make'),
|
||
model: header.indexOf('model'),
|
||
serial_number: header.indexOf('serial_number'),
|
||
customer: header.indexOf('customer'),
|
||
location: header.indexOf('location'),
|
||
};
|
||
if (idx.machine_id < 0 || idx.name < 0) {
|
||
showToast('CSV must have at least machine_id and name columns', true);
|
||
return;
|
||
}
|
||
|
||
importRows = [];
|
||
for (let i = 1; i < lines.length; i++) {
|
||
const cols = lines[i].split(',').map(c => c.trim());
|
||
const row = {
|
||
machine_id: cols[idx.machine_id] || '',
|
||
name: cols[idx.name] || '',
|
||
category: idx.category >= 0 ? cols[idx.category] || '' : '',
|
||
make: idx.make >= 0 ? cols[idx.make] || '' : '',
|
||
model: idx.model >= 0 ? cols[idx.model] || '' : '',
|
||
serial_number: idx.serial_number >= 0 ? cols[idx.serial_number] || '' : '',
|
||
customer_name: idx.customer >= 0 ? cols[idx.customer] || '' : '',
|
||
location_name: idx.location >= 0 ? cols[idx.location] || '' : '',
|
||
};
|
||
if (row.machine_id && row.name) importRows.push(row);
|
||
}
|
||
|
||
if (!importRows.length) {
|
||
showToast('No valid rows found (need machine_id + name)', true);
|
||
return;
|
||
}
|
||
|
||
// Show preview
|
||
document.getElementById('importRowCount').textContent = importRows.length + ' rows ready to import';
|
||
document.getElementById('importPreview').style.display = 'block';
|
||
document.getElementById('importRunBtn').disabled = false;
|
||
|
||
// Show preview table (first 10 rows)
|
||
const displayKeys = ['machine_id','name','category','make','model'];
|
||
const thead = '<tr>' + displayKeys.map(k => '<th>' + k + '</th>').join('') + '</tr>';
|
||
const tbody = importRows.slice(0, 10).map(r =>
|
||
'<tr>' + displayKeys.map(k => '<td>' + esc(r[k] || '') + '</td>').join('') + '</tr>'
|
||
).join('');
|
||
if (importRows.length > 10) {
|
||
document.getElementById('importTable').innerHTML =
|
||
thead + '<tfoot style="font-size:11px;color:var(--text2);"><tr><td colspan="' + displayKeys.length + '">... and ' + (importRows.length - 10) + ' more rows</td></tr></tfoot>' + tbody;
|
||
} else {
|
||
document.getElementById('importTable').innerHTML = thead + tbody;
|
||
}
|
||
};
|
||
reader.readAsText(file);
|
||
});
|
||
|
||
async function runImport() {
|
||
if (!importRows.length) return;
|
||
const btn = document.getElementById('importRunBtn');
|
||
btn.disabled = true;
|
||
btn.textContent = 'Importing...';
|
||
|
||
let created = 0, skipped = 0;
|
||
const errors = [];
|
||
|
||
for (let i = 0; i < importRows.length; i++) {
|
||
const r = importRows[i];
|
||
try {
|
||
await api('/api/assets', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
machine_id: r.machine_id,
|
||
name: r.name,
|
||
category: r.category || 'Other',
|
||
make: r.make || '',
|
||
model: r.model || '',
|
||
serial_number: r.serial_number || '',
|
||
}),
|
||
});
|
||
created++;
|
||
} catch (e) {
|
||
if (e.message && e.message.includes('UNIQUE constraint')) {
|
||
skipped++;
|
||
} else {
|
||
errors.push('Row ' + (i + 2) + ' (' + r.machine_id + '): ' + e.message);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Show results
|
||
const resEl = document.getElementById('importResults');
|
||
resEl.style.display = 'block';
|
||
let html = '<div class="import-results">';
|
||
html += '<span class="ir-ok">✓ Created: ' + created + '</span> ';
|
||
html += '<span class="ir-warn">⊘ Skipped (duplicate): ' + skipped + '</span> ';
|
||
html += '<span class="ir-err">✗ Errors: ' + errors.length + '</span>';
|
||
if (errors.length) {
|
||
html += '<div style="margin-top:6px;font-size:11px;max-height:120px;overflow-y:auto;">' +
|
||
errors.map(e => '<div class="ir-err">' + esc(e) + '</div>').join('') + '</div>';
|
||
}
|
||
html += '</div>';
|
||
resEl.innerHTML = html;
|
||
|
||
btn.textContent = 'Done';
|
||
btn.className = 'btn btn-outline btn-sm';
|
||
btn.onclick = () => showAssetList();
|
||
showToast('Import complete: ' + created + ' created, ' + skipped + ' skipped, ' + errors.length + ' errors');
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// CUSTOMERS & LOCATIONS TAB
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
const CustState = {
|
||
view: 'list', // list | detail | form | locations | loc-detail | loc-form
|
||
custId: null,
|
||
locId: null,
|
||
allCusts: [],
|
||
allLocs: {}, // { custId: [locations] }
|
||
formMode: 'create', // 'create' | 'edit'
|
||
locFormMode: 'create', // 'create' | 'edit'
|
||
formContacts: [], // [{name, phone, email}]
|
||
};
|
||
|
||
// ── View management ───────────────────────────────────────────────────
|
||
function showCustView(name) {
|
||
const views = ['custListView','custDetailView','custFormView','locListView','locDetailView','locFormView'];
|
||
views.forEach(v => { const el = document.getElementById(v); if (el) el.style.display = 'none'; });
|
||
const active = document.getElementById(name);
|
||
if (active) active.style.display = 'block';
|
||
CustState.view = name === 'custListView' ? 'list'
|
||
: name === 'custDetailView' ? 'detail'
|
||
: name === 'custFormView' ? 'form'
|
||
: name === 'locListView' ? 'locations'
|
||
: name === 'locDetailView' ? 'loc-detail'
|
||
: name === 'locFormView' ? 'loc-form' : 'list';
|
||
}
|
||
|
||
// ── Customers List ────────────────────────────────────────────────────
|
||
async function loadCustomers() {
|
||
if (AppState.currentTab !== 'tabCustomers') return;
|
||
const q = (document.getElementById('custSearch').value || '').trim().toLowerCase();
|
||
const clearBtn = document.getElementById('clearCustSearch');
|
||
if (clearBtn) clearBtn.style.display = q ? 'block' : 'none';
|
||
|
||
try {
|
||
const custs = await api('/api/customers');
|
||
CustState.allCusts = custs;
|
||
|
||
// Fetch all locations to get counts
|
||
try {
|
||
const allLocs = await api('/api/locations');
|
||
CustState.allLocs = {};
|
||
allLocs.forEach(l => {
|
||
const cid = l.customer_id;
|
||
if (cid) {
|
||
if (!CustState.allLocs[cid]) CustState.allLocs[cid] = [];
|
||
CustState.allLocs[cid].push(l);
|
||
}
|
||
});
|
||
} catch (e) { CustState.allLocs = {}; }
|
||
|
||
renderCustList(q);
|
||
} catch (e) {
|
||
document.getElementById('custList').innerHTML =
|
||
'<div class="empty-state">Failed to load customers: ' + esc(e.message) + '</div>';
|
||
}
|
||
}
|
||
|
||
function renderCustList(query) {
|
||
let custs = CustState.allCusts;
|
||
if (query) {
|
||
custs = custs.filter(c => c.name.toLowerCase().includes(query));
|
||
}
|
||
const el = document.getElementById('custList');
|
||
if (!custs.length) {
|
||
el.innerHTML = 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) {
|
||
renderDashboard(_dashCache.data.stats, _dashCache.data.activity);
|
||
return;
|
||
}
|
||
|
||
// 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 };
|
||
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)
|
||
renderServiceSummary(reportVisitsCache);
|
||
renderVisitFrequency(reportVisitsCache);
|
||
renderTimeOnSite(reportVisitsCache);
|
||
return;
|
||
}
|
||
|
||
// 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;
|
||
|
||
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 & 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 & 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();
|
||
|
||
// Assets that already have lat/lng
|
||
const withCoords = assets.filter(a => a.latitude != null && a.longitude != null);
|
||
withCoords.forEach(asset => {
|
||
addAssetMarker(asset);
|
||
});
|
||
|
||
// Assets with addresses but no coordinates — geocode them
|
||
const needsGeocode = assets.filter(a =>
|
||
(a.latitude == null || a.longitude == null) &&
|
||
(a.address || a.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 {
|
||
const addr = [asset.address, asset.building_name,
|
||
asset.building_number, asset.city, asset.state, asset.zip
|
||
].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 & 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;
|
||
const latlngs = points.map(p => [p.lat, p.lng]);
|
||
const polygon = L.polygon(latlngs, {
|
||
color: gf.color || '#3388ff',
|
||
fillColor: gf.color || '#3388ff',
|
||
fillOpacity: 0.15,
|
||
weight: 2,
|
||
}).addTo(map);
|
||
|
||
const assignedUsers = gf.assigned_users || [];
|
||
const popupBody = `<div style="min-width:150px;">
|
||
<div style="font-weight:700;margin-bottom:4px;">${esc(gf.name)}</div>
|
||
${assignedUsers.length ? `
|
||
<div style="font-size:11px;color:var(--text2);margin:4px 0;border-top:1px solid var(--border);padding-top:4px;">
|
||
👤 ${assignedUsers.map(u => `<span style="color:var(--accent2);">${esc(u.username)}</span>`).join(', ')}
|
||
</div>
|
||
` : ''}
|
||
<div style="display:flex;gap:4px;margin-top:4px;">
|
||
<button onclick="editGeofence(${gf.id})" style="font-size:11px;padding:3px 8px;border-radius:4px;background:var(--card2);color:var(--text);border:1px solid var(--border);cursor:pointer;">✏️ Edit</button>
|
||
<button onclick="assignGeofenceUsers(${gf.id})" style="font-size:11px;padding:3px 8px;border-radius:4px;background:var(--accent-bg);color:var(--accent);border:1px solid var(--accent);cursor:pointer;">👤 Assign</button>
|
||
<button onclick="deleteGeofence(${gf.id})" style="font-size:11px;padding:3px 8px;border-radius:4px;background:var(--red-bg);color:var(--red);border:1px solid var(--red);cursor:pointer;">🗑️ Delete</button>
|
||
</div>
|
||
</div>`;
|
||
polygon.bindPopup(popupBody);
|
||
|
||
geofenceLayers.push({ geofence: gf, polygon });
|
||
});
|
||
|
||
renderGeofenceList(geofences);
|
||
} catch (e) {
|
||
console.error('Failed to load geofences:', e);
|
||
}
|
||
}
|
||
|
||
function renderGeofenceList(geofences) {
|
||
document.getElementById('gfCount').textContent = geofences.length + ' zone' + (geofences.length !== 1 ? 's' : '');
|
||
const el = document.getElementById('geofenceList');
|
||
if (!geofences.length) {
|
||
el.innerHTML = '<div style="font-size:12px;color:var(--text3);padding:4px 0;">No geofences yet — tap ✏️ to draw one</div>';
|
||
return;
|
||
}
|
||
el.innerHTML = geofences.map(gf => `
|
||
<div class="geofence-item">
|
||
<div class="gf-color" style="background:${esc(gf.color || '#3388ff')};"></div>
|
||
<span class="gf-name">${esc(gf.name)}</span>
|
||
${gf.assigned_users && gf.assigned_users.length ? `
|
||
<span style="font-size:10px;color:var(--text3);margin-left:4px;flex-shrink:0;">
|
||
👤${gf.assigned_users.length}
|
||
</span>
|
||
` : ''}
|
||
<div class="gf-actions">
|
||
<button class="gf-btn" onclick="assignGeofenceUsers(${gf.id})" title="Assign Users">👤</button>
|
||
<button class="gf-btn" onclick="editGeofence(${gf.id})" title="Edit">✏️</button>
|
||
<button class="gf-btn danger" onclick="deleteGeofence(${gf.id})" title="Delete">🗑️</button>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
async function editGeofence(id) {
|
||
const gf = geofenceLayers.find(g => g.geofence.id === id);
|
||
if (!gf) return;
|
||
const newName = 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();
|
||
</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>
|
||
|
||
</body>
|
||
</html>
|