e66ed51e7f
- Fixed switchTab recursion bug when leaving tabNavigate (set currentTab early + return) - Fixed closeNavigate to not call switchTab (caller handles tab transition) - Added renderNavPage call in switchTab lifecycle for tabNavigate - Added empty state with asset search on Navigate page - Added route-finding: search assets, navigate from search results - Added driving/walking mode toggle with OSRM route display - Added ETA display for driving mode - Fixed route polyline to use OSRM road geometry when available - Added switchNavMode function for mode switching - Improved directions label (Walking vs Driving)
7574 lines
351 KiB
HTML
7574 lines
351 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;
|
||
animation: tabSlideIn 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||
}
|
||
.tab-panel.slide-left {
|
||
display: block !important;
|
||
animation: tabSlideOutLeft 0.2s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
||
}
|
||
@keyframes tabSlideIn {
|
||
from { opacity: 0; transform: translateX(16px); }
|
||
to { opacity: 1; transform: translateX(0); }
|
||
}
|
||
@keyframes tabSlideOutLeft {
|
||
from { opacity: 1; transform: translateX(0); }
|
||
to { opacity: 0; transform: translateX(-16px); }
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
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) */
|
||
.tab-btn .tab-label { display: none; }
|
||
.tab-btn.active .tab-label { display: inline; }
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
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); } }
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
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-arrow { color: var(--text2); font-size: 18px; }
|
||
|
||
/* status tag */
|
||
.status-tag {
|
||
display: inline-block; font-size: 10px; padding: 2px 8px; border-radius: 4px;
|
||
font-weight: 600; text-transform: uppercase;
|
||
}
|
||
.status-tag.active { background: var(--green-bg); color: var(--green); }
|
||
.status-tag.maintenance { background: var(--amber-bg); color: var(--amber); }
|
||
.status-tag.retired { background: var(--red-bg); color: var(--red); }
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
SEARCH BAR
|
||
═══════════════════════════════════════════════════════════════════════ */
|
||
.search-bar { position: relative; margin-bottom: 8px; }
|
||
.search-bar input { padding-right: 36px; margin-bottom: 0; }
|
||
.search-bar .clear-btn {
|
||
position: absolute; right: 10px; top: 50%; transform: translateY(-50%);
|
||
background: none; border: none; color: var(--text2); font-size: 18px;
|
||
cursor: pointer; display: none; padding: 4px;
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
FILTER PILLS
|
||
═══════════════════════════════════════════════════════════════════════ */
|
||
.filter-pills { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
|
||
.pill {
|
||
font-size: 12px; padding: 5px 12px; border-radius: 20px; border: 1px solid var(--border);
|
||
background: transparent; color: var(--text2); cursor: pointer; font-weight: 500;
|
||
transition: all 0.15s;
|
||
}
|
||
.pill.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
CAMERA / SCANNER (Add Asset tab)
|
||
═══════════════════════════════════════════════════════════════════════ */
|
||
.camera-area {
|
||
position: relative; background: #0a0b0f; border-radius: var(--radius-sm);
|
||
aspect-ratio: 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;
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
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; }
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
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>
|
||
<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" onclick="showImportView()">📥 Import</button>
|
||
</div>
|
||
<div id="assetList"></div>
|
||
</div>
|
||
|
||
<!-- ── Detail View ──────────────────────────────────────────────────── -->
|
||
<div id="assetsDetailView" style="display:none;">
|
||
<div class="detail-header">
|
||
<button class="back-btn" onclick="showAssetList()">←</button>
|
||
<div class="dh-info">
|
||
<div id="detailName" style="font-size:18px;font-weight:700;"></div>
|
||
<div id="detailMeta" style="font-size:12px;color:var(--text2);margin-top:2px;"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Info card -->
|
||
<div class="card">
|
||
<div class="card-title">Asset Details</div>
|
||
<div id="detailFields"></div>
|
||
</div>
|
||
|
||
<!-- Directions card -->
|
||
<div class="card" id="detailDirections" style="display:none;">
|
||
<div class="card-title">📍 Directions & 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()" disabled>⬇ 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() {
|
||
const badge = document.getElementById('gpsBadge');
|
||
if (!navigator.geolocation) {
|
||
badge.className = 'gps-badge err';
|
||
badge.textContent = '📍 Unavailable';
|
||
return;
|
||
}
|
||
navigator.geolocation.getCurrentPosition(
|
||
pos => {
|
||
AppState.gpsLat = pos.coords.latitude;
|
||
AppState.gpsLng = pos.coords.longitude;
|
||
AppState.gpsAcc = pos.coords.accuracy;
|
||
badge.className = 'gps-badge ok';
|
||
badge.textContent = `📍 ${AppState.gpsLat.toFixed(4)}, ${AppState.gpsLng.toFixed(4)}`;
|
||
},
|
||
err => {
|
||
badge.className = 'gps-badge err';
|
||
badge.textContent = '📍 ' + err.message;
|
||
},
|
||
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 300000 }
|
||
);
|
||
}
|
||
|
||
// =========================================================================
|
||
// TOAST
|
||
// =========================================================================
|
||
let toastTimer;
|
||
function showToast(msg, isError = false) {
|
||
const t = document.getElementById('toast');
|
||
t.textContent = msg;
|
||
t.className = 'toast' + (isError ? ' error' : '');
|
||
t.classList.add('show');
|
||
clearTimeout(toastTimer);
|
||
toastTimer = setTimeout(() => t.classList.remove('show'), 2000);
|
||
}
|
||
|
||
// =========================================================================
|
||
// MODAL — 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
|
||
if (AppState.currentTab === 'tabNavigate' && tabId !== 'tabNavigate') {
|
||
AppState.currentTab = tabId; // set early to prevent closeNavigate→switchTab recursion
|
||
closeNavigate();
|
||
return; // closeNavigate handles the full tab switch
|
||
}
|
||
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) {
|
||
// Play exit animation on old panel, keeping it visible
|
||
oldPanel.classList.add('slide-left');
|
||
oldPanel.addEventListener('animationend', function handler() {
|
||
oldPanel.removeEventListener('animationend', handler);
|
||
oldPanel.classList.remove('active', 'slide-left');
|
||
}, { once: true });
|
||
// Small delay then show new panel with slide-in
|
||
setTimeout(() => {
|
||
if (newPanel) newPanel.classList.add('active');
|
||
}, 50);
|
||
} 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) {
|
||
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>
|
||
<button class="esc-cta" onclick="${btnAction}">${esc(btnLabel)}</button>
|
||
</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);
|
||
}
|
||
|
||
// 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 spinner in count bar immediately
|
||
isLoading = true;
|
||
document.getElementById('assetCountBar').innerHTML = '<div class="spinner"></div> Loading...';
|
||
clearTimeout(assetDebounce);
|
||
assetDebounce = setTimeout(_loadAssets, 200);
|
||
}
|
||
|
||
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.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) {
|
||
document.getElementById('assetList').innerHTML =
|
||
'<div class="empty-state">Failed to load assets</div>';
|
||
document.getElementById('assetCountBar').innerHTML = '';
|
||
isLoading = false;
|
||
}
|
||
}
|
||
|
||
function updateCountBar(filteredCount) {
|
||
isLoading = false;
|
||
const bar = document.getElementById('assetCountBar');
|
||
if (totalAssetCount > 0) {
|
||
const hasFilters = assetFilters.category || assetFilters.status || 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();
|
||
|
||
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('${esc(c)}',null,null)">📂 ${esc(c)}</span>`;
|
||
});
|
||
|
||
// Status pills
|
||
[...stats].sort().forEach(s => {
|
||
html += `<span class="pill${assetFilters.status === s ? ' active' : ''}" onclick="setFilter(null,'${esc(s)}',null)">${s === 'active' ? '🟢' : s === 'maintenance' ? '🟡' : '🔴'} ${s}</span>`;
|
||
});
|
||
|
||
// Make pills
|
||
[...makes].sort().forEach(m => {
|
||
html += `<span class="pill${assetFilters.make === m ? ' active' : ''}" onclick="setFilter(null,null,'${esc(m)}')">🏭 ${esc(m)}</span>`;
|
||
});
|
||
|
||
document.getElementById('filterPills').innerHTML = html;
|
||
}
|
||
|
||
function setFilter(category, status, make) {
|
||
assetFilters.category = category;
|
||
assetFilters.status = status;
|
||
assetFilters.make = make;
|
||
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 = '<div class="empty-state"><div class="es-icon">📍</div>No locations for this customer<br><button class="esc-cta-sm" onclick="event.stopPropagation();showLocFormForCust(' + id + ')" style="margin-top:10px;">+ Add Location</button></div>';
|
||
} else {
|
||
locsEl.innerHTML = locs.map(l => `
|
||
<div class="loc-card" onclick="event.stopPropagation();viewLocation(${l.id})">
|
||
<div class="lc-icon">📍</div>
|
||
<div class="lc-info">
|
||
<div class="lc-name">${esc(l.name)}</div>
|
||
<div class="lc-meta">${esc(l.address || '')}${l.building_name ? ' · ' + esc(l.building_name) : ''}</div>
|
||
</div>
|
||
<div class="lc-arrow">›</div>
|
||
</div>
|
||
`).join('') +
|
||
`<button class="btn btn-outline btn-sm" onclick="event.stopPropagation();showLocFormForCust(${id})" style="width:100%;margin-top:8px;">+ Add Location</button>`;
|
||
}
|
||
} catch (e) {
|
||
showToast(e.message, true);
|
||
showCustList();
|
||
}
|
||
}
|
||
|
||
// ── Customer Form (Create/Edit) ───────────────────────────────────────
|
||
function showCustForm() {
|
||
CustState.formMode = 'create';
|
||
CustState.custId = null;
|
||
CustState.formContacts = [];
|
||
showCustView('custFormView');
|
||
document.getElementById('custFormTitle').textContent = 'New Customer';
|
||
document.getElementById('custFormName').value = '';
|
||
document.getElementById('custFormSaveBtn').textContent = 'Save Customer';
|
||
renderContactForm();
|
||
}
|
||
|
||
async function editCustomer() {
|
||
try {
|
||
const cust = await api(`/api/customers/${CustState.custId}`);
|
||
CustState.formMode = 'edit';
|
||
CustState.formContacts = (cust.contacts || []).map(c => ({ name: c.name || '', phone: c.phone || '', email: c.email || '' }));
|
||
showCustView('custFormView');
|
||
document.getElementById('custFormTitle').textContent = 'Edit Customer';
|
||
document.getElementById('custFormName').value = cust.name;
|
||
document.getElementById('custFormSaveBtn').textContent = 'Update Customer';
|
||
renderContactForm();
|
||
} catch (e) {
|
||
showToast(e.message, true);
|
||
}
|
||
}
|
||
|
||
function cancelCustForm() {
|
||
showCustView(CustState.formMode === 'edit' ? 'custDetailView' : 'custListView');
|
||
}
|
||
|
||
function renderContactForm() {
|
||
const el = document.getElementById('custFormContacts');
|
||
if (!CustState.formContacts.length) {
|
||
CustState.formContacts.push({ name: '', phone: '', email: '' });
|
||
}
|
||
el.innerHTML = CustState.formContacts.map((c, i) => `
|
||
<div class="contact-row">
|
||
${CustState.formContacts.length > 1 ? `<button class="cr-remove" onclick="removeContact(${i})">✕</button>` : ''}
|
||
<input type="text" class="input-field" placeholder="Contact name" value="${esc(c.name)}" oninput="updateContact(${i},'name',this.value)" style="margin-bottom:0;">
|
||
<div class="form-row" style="margin-bottom:0;">
|
||
<input type="tel" class="input-field" placeholder="Phone" value="${esc(c.phone)}" oninput="updateContact(${i},'phone',this.value)" style="margin-bottom:0;">
|
||
<input type="email" class="input-field" placeholder="Email" value="${esc(c.email)}" oninput="updateContact(${i},'email',this.value)" style="margin-bottom:0;">
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function addContactRow() {
|
||
CustState.formContacts.push({ name: '', phone: '', email: '' });
|
||
renderContactForm();
|
||
}
|
||
|
||
function removeContact(i) {
|
||
if (CustState.formContacts.length <= 1) return;
|
||
CustState.formContacts.splice(i, 1);
|
||
renderContactForm();
|
||
}
|
||
|
||
function updateContact(i, field, value) {
|
||
CustState.formContacts[i][field] = value;
|
||
}
|
||
|
||
async function saveCustomer() {
|
||
const name = document.getElementById('custFormName').value.trim();
|
||
if (!name) { showToast('Customer name is required', true); return; }
|
||
|
||
const contacts = CustState.formContacts
|
||
.filter(c => c.name || c.phone || c.email)
|
||
.map(c => ({ name: c.name.trim(), phone: c.phone.trim(), email: c.email.trim() }));
|
||
|
||
const payload = { name, contacts };
|
||
|
||
try {
|
||
if (CustState.formMode === 'create') {
|
||
const cust = await api('/api/customers', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
showToast('Customer created!');
|
||
CustState.custId = cust.id;
|
||
await loadCustomers();
|
||
viewCustomer(cust.id);
|
||
} else {
|
||
await api(`/api/customers/${CustState.custId}`, {
|
||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
showToast('Customer updated!');
|
||
await loadCustomers();
|
||
viewCustomer(CustState.custId);
|
||
}
|
||
} catch (e) {
|
||
showToast(e.message, true);
|
||
}
|
||
}
|
||
|
||
async function deleteCustomer() {
|
||
const cust = await api(`/api/customers/${CustState.custId}`);
|
||
const confirmed = await showModal(
|
||
'Delete Customer',
|
||
`Delete "${cust.name}" and all its locations? This cannot be undone.`,
|
||
'Delete'
|
||
);
|
||
if (!confirmed) return;
|
||
try {
|
||
await api(`/api/customers/${CustState.custId}`, { method: 'DELETE' });
|
||
showToast('Customer deleted');
|
||
showCustList();
|
||
} catch (e) {
|
||
showToast(e.message, true);
|
||
}
|
||
}
|
||
|
||
// ── Locations: List for a Customer ────────────────────────────────────
|
||
async function showLocationsForCust(custId) {
|
||
CustState.custId = custId;
|
||
try {
|
||
const cust = await api(`/api/customers/${custId}`);
|
||
const locs = CustState.allLocs[custId] || [];
|
||
showCustView('locListView');
|
||
document.getElementById('locListCustName').textContent = cust.name;
|
||
renderLocList(locs);
|
||
} catch (e) {
|
||
showToast(e.message, true);
|
||
}
|
||
}
|
||
|
||
function renderLocList(locs) {
|
||
const el = document.getElementById('locList');
|
||
if (!locs.length) {
|
||
el.innerHTML = 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">No data</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">No data</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('')
|
||
: `<div class="empty-state" style="padding:20px;">
|
||
<div class="es-icon">📍</div>
|
||
<div style="font-size:14px;margin-bottom:8px;">No visit data yet</div>
|
||
<button class="btn btn-primary btn-sm" onclick="switchTab('tabAddAsset')" style="margin-top:4px;">
|
||
➕ Add a Check-in
|
||
</button>
|
||
</div>`;
|
||
|
||
// --- 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.</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);
|
||
|
||
// Enable/disable download button based on data
|
||
if (downloadBtn) {
|
||
downloadBtn.disabled = visits.length === 0;
|
||
if (visits.length === 0) {
|
||
downloadBtn.title = 'No data to download';
|
||
} else {
|
||
downloadBtn.title = '';
|
||
}
|
||
}
|
||
} 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>` : `<div class="guidance-card">
|
||
<div class="gc-icon">\u{1f4cb}</div>
|
||
<div class="gc-title">No visits in this date range</div>
|
||
<div class="gc-hint">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.</div>
|
||
</div>`;
|
||
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>` : `<div class="guidance-card">
|
||
<div class="gc-icon">\u{1f4ca}</div>
|
||
<div class="gc-title">No visit frequency data</div>
|
||
<div class="gc-hint">Visit frequency shows how often each asset has been checked. Data appears here automatically as technicians log visits.</div>
|
||
</div>`;
|
||
|
||
document.getElementById('reportVisitTable').innerHTML = tableHtml;
|
||
}
|
||
|
||
function sortReport(key) {
|
||
if (reportSortState.key === key) {
|
||
reportSortState.dir *= -1;
|
||
} else {
|
||
reportSortState.key = key;
|
||
reportSortState.dir = 1;
|
||
}
|
||
renderVisitFrequency(reportVisitsCache);
|
||
}
|
||
|
||
function renderTimeOnSite(visits) {
|
||
// Per technician
|
||
const techMap = {};
|
||
visits.forEach(v => {
|
||
if (!v.user_name) return;
|
||
const key = v.user_name;
|
||
if (!techMap[key]) techMap[key] = { visits: 0, totalSecs: 0 };
|
||
techMap[key].visits++;
|
||
if (v.checkout_time && v.checkin_time) {
|
||
techMap[key].totalSecs += (new Date(v.checkout_time) - new Date(v.checkin_time)) / 1000;
|
||
}
|
||
});
|
||
let techRows = Object.entries(techMap).map(([name, d]) => ({
|
||
name, visits: d.visits, totalMin: Math.round(d.totalSecs / 60), avgMin: d.visits ? Math.round(d.totalSecs / d.visits / 60) : 0,
|
||
}));
|
||
const maxAvgTech = Math.max(1, ...techRows.map(r => r.avgMin));
|
||
techRows.sort((a, b) => b.totalMin - a.totalMin);
|
||
|
||
const techHtml = techRows.length ? `
|
||
<table class="rpt-table">
|
||
<thead><tr>
|
||
<th>Technician</th>
|
||
<th class="rpt-number">Visits</th>
|
||
<th class="rpt-number">Total Time</th>
|
||
<th style="width:50%;">Avg Time</th>
|
||
</tr></thead>
|
||
<tbody>${techRows.map(r => {
|
||
const pct = (r.avgMin / maxAvgTech * 100).toFixed(0);
|
||
return `<tr>
|
||
<td>${esc(r.name)}</td>
|
||
<td class="rpt-number">${r.visits}</td>
|
||
<td class="rpt-number">${r.totalMin} min</td>
|
||
<td><div class="rpt-bar-wrap">
|
||
<div class="rpt-bar-track"><div class="rpt-bar-fill" style="width:${pct}%;"></div></div>
|
||
<span class="rpt-bar-val">${r.avgMin} min</span>
|
||
</div></td>
|
||
</tr>`;
|
||
}).join('')}</tbody>
|
||
</table>` : `<div class="guidance-card">
|
||
<div class="gc-icon">\u{23f1}\u{fe0f}</div>
|
||
<div class="gc-title">No technician time data</div>
|
||
<div class="gc-hint">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.</div>
|
||
</div>`;
|
||
document.getElementById('reportTimeTech').innerHTML = techHtml;
|
||
|
||
// Per asset
|
||
const assetMap = {};
|
||
visits.forEach(v => {
|
||
const key = v.asset_id || '?';
|
||
if (!assetMap[key]) {
|
||
assetMap[key] = { name: v.asset_name || v.machine_id || 'Unknown', visits: 0, totalSecs: 0 };
|
||
}
|
||
assetMap[key].visits++;
|
||
if (v.checkout_time && v.checkin_time) {
|
||
assetMap[key].totalSecs += (new Date(v.checkout_time) - new Date(v.checkin_time)) / 1000;
|
||
}
|
||
});
|
||
let assetRows = Object.entries(assetMap).map(([id, d]) => ({
|
||
name: d.name, visits: d.visits, totalMin: Math.round(d.totalSecs / 60), avgMin: d.visits ? Math.round(d.totalSecs / d.visits / 60) : 0,
|
||
}));
|
||
const maxAvgAsset = Math.max(1, ...assetRows.map(r => r.avgMin));
|
||
assetRows.sort((a, b) => b.totalMin - a.totalMin);
|
||
|
||
const assetHtml = assetRows.length ? `
|
||
<table class="rpt-table">
|
||
<thead><tr>
|
||
<th>Asset</th>
|
||
<th class="rpt-number">Visits</th>
|
||
<th class="rpt-number">Total Time</th>
|
||
<th style="width:50%;">Avg Time</th>
|
||
</tr></thead>
|
||
<tbody>${assetRows.map(r => {
|
||
const pct = (r.avgMin / maxAvgAsset * 100).toFixed(0);
|
||
return `<tr>
|
||
<td>${esc(r.name)}</td>
|
||
<td class="rpt-number">${r.visits}</td>
|
||
<td class="rpt-number">${r.totalMin} min</td>
|
||
<td><div class="rpt-bar-wrap">
|
||
<div class="rpt-bar-track"><div class="rpt-bar-fill" style="width:${pct}%;"></div></div>
|
||
<span class="rpt-bar-val">${r.avgMin} min</span>
|
||
</div></td>
|
||
</tr>`;
|
||
}).join('')}</tbody>
|
||
</table>` : `<div class="guidance-card">
|
||
<div class="gc-icon">\u{1f4e6}</div>
|
||
<div class="gc-title">No asset time data</div>
|
||
<div class="gc-hint">Time-per-asset is calculated from check-in/check-out durations. Make sure assets have locations assigned so technicians can check in on-site.</div>
|
||
</div>`;
|
||
document.getElementById('reportTimeAsset').innerHTML = assetHtml;
|
||
}
|
||
|
||
function exportServiceSummary() {
|
||
if (!reportVisitsCache.length) {
|
||
showToast('No data to export. Generate a report first.', true);
|
||
return;
|
||
}
|
||
// Build CSV client-side from the filtered data
|
||
const assetMap = {};
|
||
reportVisitsCache.forEach(v => {
|
||
const key = v.asset_id || '?';
|
||
if (!assetMap[key]) {
|
||
assetMap[key] = {
|
||
asset: v.asset_name || v.machine_id || 'Unknown',
|
||
visits: 0, last: null, techs: new Set(), totalSecs: 0,
|
||
};
|
||
}
|
||
assetMap[key].visits++;
|
||
if (!assetMap[key].last || v.checkin_time > assetMap[key].last) assetMap[key].last = v.checkin_time;
|
||
if (v.user_name) assetMap[key].techs.add(v.user_name);
|
||
if (v.checkout_time && v.checkin_time) {
|
||
assetMap[key].totalSecs += (new Date(v.checkout_time) - new Date(v.checkin_time)) / 1000;
|
||
}
|
||
});
|
||
const csv = 'Asset,Visits,Last Visit,Technicians,Avg Time (min)\n'
|
||
+ Object.values(assetMap).map(r => [
|
||
'"' + r.asset.replace(/"/g, '""') + '"',
|
||
r.visits,
|
||
'"' + (r.last ? formatDate(r.last) : '') + '"',
|
||
r.techs.size,
|
||
r.visits ? Math.round(r.totalSecs / r.visits / 60) : 0,
|
||
].join(',')).join('\n');
|
||
|
||
const blob = new Blob([csv], { type: 'text/csv' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = 'service_summary.csv';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
showToast('CSV downloaded');
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// MAP TAB — Leaflet map with pins, geofences, heatmap, auto-visit
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
let map = null;
|
||
let assetMarkers = []; // { asset, marker }
|
||
let geofenceLayers = []; // { geofence, polygon }
|
||
let heatLayer = null;
|
||
let heatVisible = false;
|
||
let drawnItems = null; // Leaflet FeatureGroup for drawn polygons
|
||
let drawControl = null;
|
||
let drawingGeofence = false;
|
||
let geoWatchId = null;
|
||
let visitTimers = {}; // { asset_id: { startTime, lastSeen } }
|
||
const VISIT_THRESHOLD_M = 50;
|
||
const VISIT_DURATION_MIN = 10;
|
||
|
||
// Category-to-color mapping for asset pins
|
||
const CAT_COLORS = {
|
||
'Furniture': '#4ade80',
|
||
'Appliances': '#60a5fa',
|
||
'Utensils & 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);
|
||
|
||
// FeatureGroup for drawn geofences
|
||
drawnItems = new L.FeatureGroup();
|
||
map.addLayer(drawnItems);
|
||
|
||
// Draw control (hidden by default, shown when user taps "Add Geofence")
|
||
drawControl = new L.Control.Draw({
|
||
draw: {
|
||
polygon: {
|
||
allowIntersection: false,
|
||
showArea: true,
|
||
shapeOptions: { color: '#3388ff', weight: 2 },
|
||
},
|
||
polyline: false,
|
||
rectangle: false,
|
||
circle: false,
|
||
circlemarker: false,
|
||
marker: false,
|
||
},
|
||
edit: { featureGroup: drawnItems },
|
||
});
|
||
// Don't add to map yet — only when drawing mode active
|
||
|
||
// Handle drawn polygon
|
||
map.on(L.Draw.Event.CREATED, function (e) {
|
||
drawnItems.addLayer(e.layer);
|
||
// Show the save/cancel row
|
||
document.getElementById('geofenceColorRow').style.display = 'flex';
|
||
});
|
||
|
||
// Handle edits
|
||
map.on(L.Draw.Event.EDITED, function () {
|
||
syncEditedGeofences();
|
||
});
|
||
|
||
map.on(L.Draw.Event.DELETED, function () {
|
||
syncDeletedGeofences();
|
||
});
|
||
|
||
// Invalidate size when tab becomes visible
|
||
setTimeout(() => map.invalidateSize(), 100);
|
||
|
||
loadAssetPins();
|
||
loadGeofences();
|
||
}
|
||
|
||
// Load all assets with coordinates and place pins
|
||
async function loadAssetPins() {
|
||
try {
|
||
const assets = await api('/api/assets?limit=1000');
|
||
clearAssetMarkers();
|
||
const withCoords = assets.filter(a => a.latitude != null && a.longitude != null);
|
||
withCoords.forEach(asset => {
|
||
addAssetMarker(asset);
|
||
});
|
||
// Update heatmap layer if visible
|
||
if (heatVisible) await loadHeatmapData();
|
||
} catch (e) {
|
||
console.error('Failed to load asset pins:', e);
|
||
}
|
||
}
|
||
|
||
function clearAssetMarkers() {
|
||
assetMarkers.forEach(({ marker }) => {
|
||
if (map) map.removeLayer(marker);
|
||
});
|
||
assetMarkers = [];
|
||
}
|
||
|
||
function addAssetMarker(asset) {
|
||
if (!map) return;
|
||
const icon = makeAssetIcon(asset);
|
||
const marker = L.marker([asset.latitude, asset.longitude], { icon }).addTo(map);
|
||
|
||
const addrParts = [
|
||
asset.address, asset.building_name,
|
||
asset.building_number ? `#${asset.building_number}` : '',
|
||
asset.floor ? `Floor ${asset.floor}` : ''
|
||
].filter(Boolean);
|
||
const addr = addrParts.length ? addrParts.join(', ') : 'No address';
|
||
|
||
const dirLink = `https://www.google.com/maps/dir/?api=1&destination=${asset.latitude},${asset.longitude}`;
|
||
|
||
marker.bindPopup(`
|
||
<div style="min-width:180px;">
|
||
<div style="font-size:15px;font-weight:700;margin-bottom:4px;">${esc(asset.name)}</div>
|
||
<div style="font-size:11px;color:var(--text2);margin-bottom:6px;">
|
||
${asset.machine_id ? esc(asset.machine_id) + ' · ' : ''}${esc(asset.category || 'Other')}
|
||
${asset.status ? ` · <span style="color:${asset.status==='active'?'var(--green)':asset.status==='maintenance'?'var(--amber)':'var(--red)'};">${esc(asset.status)}</span>` : ''}
|
||
</div>
|
||
<div style="font-size:11px;color:var(--text2);margin-bottom:8px;">📍 ${esc(addr)}</div>
|
||
<div style="display:flex;gap:6px;flex-wrap:wrap;">
|
||
<a href="${dirLink}" target="_blank" rel="noopener" style="display:inline-flex;align-items:center;gap:4px;font-size:12px;font-weight:600;padding:6px 12px;border-radius:8px;background:var(--accent);color:#fff;text-decoration:none;">🧭 Directions</a>
|
||
<button onclick="switchTab('tabAssets');viewAsset(${asset.id});" style="font-size:12px;font-weight:600;padding:6px 12px;border-radius:8px;background:var(--card2);color:var(--text);border:1px solid var(--border);cursor:pointer;">📋 Details</button>
|
||
</div>
|
||
</div>
|
||
`);
|
||
|
||
assetMarkers.push({ asset, marker });
|
||
}
|
||
|
||
// Center map on user GPS
|
||
function centerOnGPS() {
|
||
if (!map) return;
|
||
if (AppState.gpsLat) {
|
||
map.setView([AppState.gpsLat, AppState.gpsLng], 16);
|
||
// Add a pulsing blue dot for current location
|
||
if (!map._userMarker) {
|
||
map._userMarker = L.circleMarker([AppState.gpsLat, AppState.gpsLng], {
|
||
radius: 8, fillColor: '#5b6ef7', color: '#fff', weight: 2,
|
||
fillOpacity: 0.9,
|
||
}).addTo(map);
|
||
} else {
|
||
map._userMarker.setLatLng([AppState.gpsLat, AppState.gpsLng]);
|
||
}
|
||
} else {
|
||
showToast('GPS location not available yet', true);
|
||
}
|
||
}
|
||
|
||
// Toggle asset pins visibility
|
||
function togglePins() {
|
||
const chip = document.getElementById('chipPins');
|
||
const show = !chip.classList.contains('active');
|
||
chip.classList.toggle('active', show);
|
||
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>
|