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

5379 lines
252 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Canteen Asset Tracker</title>
<script src="https://cdn.jsdelivr.net/npm/@zxing/library@0.20.0/umd/index.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js"></script>
<!-- Leaflet Map -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
<style>
/* ═══════════════════════════════════════════════════════════════════════
DESIGN TOKENS
═══════════════════════════════════════════════════════════════════════ */
:root {
--bg: #0d0e12;
--card: #161820;
--card2: #1c1e28;
--border: #252830;
--border2: #2e3140;
--text: #e4e4e7;
--text2: #8b8fa3;
--text3: #5b5f73;
--accent: #5b6ef7;
--accent2: #7c8cf8;
--accent-bg: #141a3a;
--green: #4ade80;
--green-bg: #1a2e1a;
--red: #f87171;
--red-bg: #2e1a1a;
--amber: #fbbf24;
--amber-bg: #2a2510;
--radius: 14px;
--radius-sm: 10px;
--radius-xs: 6px;
--tab-height: 64px;
--header-height: 52px;
--drawer-width: 280px;
}
/* ═══════════════════════════════════════════════════════════════════════
RESET & BASE
═══════════════════════════════════════════════════════════════════════ */
* { box-sizing: border-box; margin: 0; padding: 0; }
html { overflow-x: hidden; -webkit-text-size-adjust: 100%; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
max-width: 480px;
margin: 0 auto;
min-height: 100dvh;
overflow-x: hidden;
padding-top: calc(var(--header-height) + 8px);
padding-bottom: calc(var(--tab-height) + 20px);
}
/* ═══════════════════════════════════════════════════════════════════════
HEADER
═══════════════════════════════════════════════════════════════════════ */
.header {
position: fixed; top: 0; left: 50%; transform: translateX(-50%);
width: 100%; max-width: 480px; height: var(--header-height);
display: flex; align-items: center; gap: 10px;
padding: 0 12px;
background: var(--card);
border-bottom: 1px solid var(--border);
z-index: 200;
}
.header .hamburger {
width: 36px; height: 36px; border: none; background: transparent;
color: var(--text); font-size: 22px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
border-radius: var(--radius-sm);
-webkit-tap-highlight-color: transparent;
flex-shrink: 0;
}
.header .hamburger:active { background: var(--border); }
.header h1 {
font-size: 16px; font-weight: 700; flex: 1;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.header .header-badges {
display: flex; align-items: center; gap: 6px; flex-shrink: 0;
}
.gps-badge {
display: inline-flex; align-items: center; gap: 3px;
font-size: 10px; font-weight: 600; padding: 4px 8px; border-radius: 20px;
white-space: nowrap; transition: 0.2s;
}
.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.waiting:hover,
.gps-badge.err:hover { filter: brightness(1.15); }
.gps-badge.waiting { animation: pulse-gps 2s ease-in-out infinite; }
@keyframes pulse-gps {
0%, 100% { opacity: 1; }
50% { opacity: 0.65; }
}
.user-badge {
width: 30px; height: 30px; border-radius: 50%;
background: var(--accent-bg); color: var(--accent2);
display: flex; align-items: center; justify-content: center;
font-size: 13px; font-weight: 700; cursor: pointer;
border: 1.5px solid var(--border);
}
/* ═══════════════════════════════════════════════════════════════════════
DRAWER OVERLAY & PANEL
═══════════════════════════════════════════════════════════════════════ */
.drawer-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.55);
z-index: 2000; opacity: 0; pointer-events: none;
transition: opacity 0.25s;
}
.drawer-overlay.open { opacity: 1; pointer-events: auto; }
.drawer {
position: fixed; top: 0; left: 0; bottom: 0;
width: var(--drawer-width); max-width: 85vw;
background: var(--card); z-index: 2001;
transform: translateX(-100%);
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
display: flex; flex-direction: column;
border-right: 1px solid var(--border);
box-shadow: 4px 0 20px rgba(0,0,0,0.4);
}
.drawer.open { transform: translateX(0); }
.drawer-header {
display: flex; align-items: center; gap: 10px;
padding: 14px 16px; border-bottom: 1px solid var(--border);
min-height: var(--header-height);
}
.drawer-header .dh-title {
font-size: 17px; font-weight: 700; flex: 1;
}
.drawer-header .close-drawer {
width: 32px; height: 32px; border: none; background: transparent;
color: var(--text2); font-size: 20px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
border-radius: var(--radius-xs);
}
.drawer-header .close-drawer:active { background: var(--border); }
.drawer-user {
display: flex; align-items: center; gap: 12px;
padding: 14px 16px; border-bottom: 1px solid var(--border);
}
.drawer-user .du-avatar {
width: 40px; height: 40px; border-radius: 50%;
background: var(--accent-bg); color: var(--accent2);
display: flex; align-items: center; justify-content: center;
font-size: 18px; font-weight: 700; flex-shrink: 0;
}
.drawer-user .du-info { flex: 1; min-width: 0; }
.drawer-user .du-name { font-weight: 600; font-size: 14px; }
.drawer-user .du-role {
font-size: 11px; color: var(--text2); text-transform: capitalize;
}
.drawer-nav { flex: 1; overflow-y: auto; padding: 8px 0; }
.drawer-nav .dn-item {
display: flex; align-items: center; gap: 12px;
padding: 13px 16px; color: var(--text); font-size: 15px;
cursor: pointer; transition: background 0.1s;
border: none; background: transparent; width: 100%;
text-align: left; font-weight: 500;
-webkit-tap-highlight-color: transparent;
}
.drawer-nav .dn-item:active, .drawer-nav .dn-item.active {
background: rgba(255,255,255,0.04);
}
.drawer-nav .dn-item .dn-icon { font-size: 20px; width: 28px; text-align: center; flex-shrink: 0; }
.drawer-nav .dn-item .dn-badge {
margin-left: auto; font-size: 10px; font-weight: 700;
padding: 2px 8px; border-radius: 10px;
background: var(--accent-bg); color: var(--accent2);
}
.drawer-footer {
padding: 12px 16px; border-top: 1px solid var(--border);
font-size: 12px; color: var(--text3);
}
/* ═══════════════════════════════════════════════════════════════════════
TAB PANELS
═══════════════════════════════════════════════════════════════════════ */
.tab-panel { display: none; padding: 0 12px; }
.tab-panel.active { display: block; }
/* ═══════════════════════════════════════════════════════════════════════
BOTTOM TABS
═══════════════════════════════════════════════════════════════════════ */
.tabs {
position: fixed; bottom: 0; left: 50%; transform: translateX(-50%);
width: 100%; max-width: 480px;
display: flex; background: var(--card); border-top: 1px solid var(--border);
z-index: 100;
}
.tab-btn {
flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 2px; padding: 8px 6px 6px; border: none; background: transparent;
color: var(--text2); font-size: 10px; font-weight: 600; cursor: pointer;
transition: color 0.15s; -webkit-tap-highlight-color: transparent;
min-width: 0;
}
.tab-btn.active { color: var(--accent2); }
.tab-btn .tab-icon { font-size: 22px; }
/* ═══════════════════════════════════════════════════════════════════════
CARDS
═══════════════════════════════════════════════════════════════════════ */
.card {
background: var(--card); border: 1px solid var(--border);
border-radius: var(--radius); padding: 16px; margin-bottom: 10px;
}
.card-title {
font-size: 11px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.05em; color: var(--text2); margin-bottom: 10px;
}
.dropdown-item {
padding: 8px 12px; border-radius: 6px; cursor: pointer; font-size: 13px;
transition: background .15s;
}
.dropdown-item:hover { background: var(--hover); }
/* ═══════════════════════════════════════════════════════════════════════
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; }
.btn-xs { padding: 6px 12px; font-size: 12px; width: auto; line-height: 1; }
.pagination-bar { display: flex; align-items: center; gap: 6px; }
/* ═══════════════════════════════════════════════════════════════════════
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); }
/* ═══════════════════════════════════════════════════════════════════════
OFFLINE BANNER
═══════════════════════════════════════════════════════════════════════ */
.offline-banner {
position: fixed; top: var(--header-height); left: 50%; transform: translateX(-50%);
width: 100%; max-width: 480px; z-index: 199;
background: var(--amber-bg); color: var(--amber);
text-align: center; padding: 6px 12px; font-size: 13px; font-weight: 600;
display: none; align-items: center; justify-content: center; gap: 6px;
}
.offline-banner.show { display: flex; }
.offline-banner .queue-count {
background: var(--amber); color: #000; font-size: 10px; font-weight: 700;
padding: 1px 7px; border-radius: 10px; margin-left: 4px;
}
.queue-indicator {
display: none; align-items: center; gap: 3px;
background: var(--amber-bg); color: var(--amber);
font-size: 10px; font-weight: 700; padding: 3px 8px; border-radius: 20px;
cursor: pointer; white-space: nowrap;
}
.queue-indicator.show { display: inline-flex; }
.queue-indicator.syncing { background: var(--accent-bg); color: var(--accent2); }
/* ═══════════════════════════════════════════════════════════════════════
TOAST
═══════════════════════════════════════════════════════════════════════ */
.toast {
position: fixed; bottom: calc(var(--tab-height) + 12px); left: 50%;
transform: translateX(-50%); background: var(--green); color: #000;
font-weight: 700; padding: 10px 22px; border-radius: 20px; font-size: 14px;
z-index: 400; opacity: 0; transition: opacity 0.3s; pointer-events: none;
white-space: nowrap; max-width: 90vw; overflow: hidden; text-overflow: ellipsis;
}
.toast.show { opacity: 1; }
.toast.error { background: var(--red); color: #fff; }
/* ═══════════════════════════════════════════════════════════════════════
SPINNER
═══════════════════════════════════════════════════════════════════════ */
.spinner {
width: 20px; height: 20px; border: 2px solid var(--border);
border-top-color: var(--accent); border-radius: 50%;
animation: spin 0.7s linear infinite; display: inline-block;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading-spinner {
display: flex; align-items: center; justify-content: center; gap: 8px;
padding: 32px 16px; color: var(--text2); font-size: 13px;
}
.search-hint {
font-size: 10px; color: var(--text3); text-align: right;
margin-bottom: 4px; margin-top: -4px;
}
.help-btn {
width: 30px; height: 30px; border: none; background: transparent;
color: var(--text2); font-size: 18px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
border-radius: 50%; flex-shrink: 0;
-webkit-tap-highlight-color: transparent;
}
.help-btn:active { background: var(--border); }
/* ═══════════════════════════════════════════════════════════════════════
EMPTY STATE
═══════════════════════════════════════════════════════════════════════ */
.empty-state { text-align: center; padding: 32px 16px; color: var(--text2); font-size: 13px; }
.empty-state .es-icon { font-size: 40px; margin-bottom: 8px; opacity: 0.5; }
/* ═══════════════════════════════════════════════════════════════════════
MODAL / DIALOG
═══════════════════════════════════════════════════════════════════════ */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
z-index: 500; display: flex; align-items: center; justify-content: center;
opacity: 0; pointer-events: none; transition: opacity 0.2s;
padding: 20px;
}
.modal-overlay.open { opacity: 1; pointer-events: auto; }
.modal {
background: var(--card); border: 1px solid var(--border);
border-radius: var(--radius); padding: 20px; width: 100%; max-width: 400px;
text-align: center;
}
.modal .modal-title { font-size: 17px; font-weight: 700; margin-bottom: 8px; }
.modal .modal-body { font-size: 14px; color: var(--text2); margin-bottom: 16px; }
.modal .modal-actions { display: flex; gap: 8px; }
.modal .modal-actions .btn { flex: 1; }
/* ═══════════════════════════════════════════════════════════════════════
LOGIN OVERLAY (Phase M)
═══════════════════════════════════════════════════════════════════════ */
.login-overlay {
position: fixed; inset: 0; background: var(--bg);
z-index: 1000; display: flex; align-items: center; justify-content: center;
padding: 20px;
}
.login-overlay.hidden { display: none; }
.login-card {
width: 100%; max-width: 360px; background: var(--card);
border: 1px solid var(--border); border-radius: var(--radius);
padding: 28px 24px;
}
.login-card .login-icon { font-size: 48px; text-align: center; margin-bottom: 8px; }
.login-card h2 { text-align: center; font-size: 20px; margin-bottom: 20px; }
.login-card .login-error {
background: var(--red-bg); color: var(--red); font-size: 13px;
padding: 8px 12px; border-radius: var(--radius-xs); margin-bottom: 10px;
display: none;
}
.login-card .login-error.show { display: block; }
.login-card .login-footer { text-align: center; font-size: 12px; color: var(--text3); margin-top: 12px; }
/* ═══════════════════════════════════════════════════════════════════════
ROLE BADGE (Phase M)
═══════════════════════════════════════════════════════════════════════ */
.role-badge {
font-size: 10px; font-weight: 700; padding: 3px 8px; border-radius: 10px;
text-transform: uppercase; letter-spacing: 0.03em; margin-right: 4px;
}
.role-badge.admin { background: var(--accent-bg); color: var(--accent2); }
.role-badge.technician { background: var(--green-bg); color: var(--green); }
.role-badge.readonly { background: var(--border); color: var(--text2); }
.role-badge.guest { background: var(--border); color: var(--text3); }
/* ═══════════════════════════════════════════════════════════════════════
/* ═══════════════════════════════════════════════════════════════════════
ASSET LIST ITEMS (shared)
═══════════════════════════════════════════════════════════════════════ */
.asset-item {
display: flex; align-items: flex-start; gap: 12px; padding: 18px 14px;
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: 48px; height: 48px; border-radius: var(--radius-sm); display: flex;
align-items: center; justify-content: center; font-size: 22px; flex-shrink: 0;
}
.asset-item .ai-icon.ai-icon-wide {
width: 64px; padding: 0 4px;
}
.asset-item .ai-icon .cat-icon-img { width: 32px; height: 32px; object-fit: contain; }
.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; line-height: 1.35; white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.asset-item .ai-company { font-weight: 600; font-size: 14px; line-height: 1.3; }
.asset-item .ai-location-line { font-size: 12px; color: var(--text2); margin-top: 1px; display: flex; gap: 4px; flex-wrap: wrap; }
.asset-item .ai-location-line .tag { background: rgba(255,255,255,0.06); padding: 1px 6px; border-radius: 4px; }
.asset-item .ai-building { font-size: 12px; color: var(--text1); margin-top: 2px; }
.asset-item .ai-address { font-size: 12px; color: var(--text2); margin-top: 2px; }
.asset-item .ai-location { font-size: 13px; color: var(--text1); font-weight: 500; margin-top: 3px; }
.asset-item .ai-meta { font-size: 12px; color: var(--text2); margin-top: 3px; }
.asset-item .ai-arrow { color: var(--text2); font-size: 20px; padding-top: 2px; }
/* 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); }
.disney-tag {
display: inline-block; font-size: 10px; padding: 2px 8px; border-radius: 4px;
font-weight: 600; background: #8b5cf622; color: #8b5cf6;
}
/* ═══════════════════════════════════════════════════════════════════════
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; }
/* ═══════════════════════════════════════════════════════════════════════
PHOTO PICKER / GALLERY PREVIEW
═══════════════════════════════════════════════════════════════════════ */
.photo-picker-btn {
width: 100%; padding: 10px; border: 1px dashed var(--border2);
background: var(--card); color: var(--text); font-size: 14px;
font-weight: 600; cursor: pointer; border-radius: var(--radius-sm);
display: flex; align-items: center; justify-content: center; gap: 6px;
margin-bottom: 10px; transition: all 0.15s;
-webkit-tap-highlight-color: transparent;
}
.photo-picker-btn:active { background: var(--border); }
.photo-preview-card {
background: var(--card); border-radius: var(--radius);
box-shadow: 0 4px 24px rgba(0,0,0,0.3); padding: 12px;
margin-bottom: 10px; animation: fadeIn 0.2s;
}
.photo-preview-card img {
width: 100%; border-radius: var(--radius-sm); margin-bottom: 10px;
}
.photo-preview-toolbar {
display: flex; gap: 8px; margin-bottom: 10px; flex-wrap: wrap;
}
.photo-preview-toolbar button {
flex: 1; min-width: 80px; padding: 8px 12px; border: 1px solid var(--border2);
background: var(--card2); color: var(--text); font-size: 12px;
font-weight: 600; border-radius: var(--radius-xs); cursor: pointer;
transition: all 0.15s; -webkit-tap-highlight-color: transparent;
}
.photo-preview-toolbar button:active { background: var(--accent-bg); border-color: var(--accent); }
.exif-data {
background: var(--card2); border: 1px solid var(--border);
border-radius: var(--radius-xs); padding: 10px; font-size: 12px;
color: var(--text2);
}
.exif-data .exif-row {
display: flex; justify-content: space-between; padding: 3px 0;
border-bottom: 1px solid var(--border);
}
.exif-data .exif-row:last-child { border-bottom: none; }
.exif-data .exif-label { color: var(--text3); }
.exif-data .exif-value { color: var(--text); font-weight: 500; }
/* ═══════════════════════════════════════════════════════════════════════
MODE TOGGLES (Add Asset tab)
═══════════════════════════════════════════════════════════════════════ */
.mode-toggles {
display: flex; flex-wrap: wrap; 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; }
/* OCR progress bar */
.ocr-progress-wrap {
margin-top: 6px; height: 4px; background: var(--border);
border-radius: 2px; overflow: hidden; display: none;
}
.ocr-progress-wrap.active { display: block; }
.ocr-progress-bar {
height: 100%; width: 0%; background: var(--accent);
border-radius: 2px; transition: width 0.3s;
}
/* Offline OCR badge */
.offline-badge {
display: inline-flex; align-items: center; gap: 4px;
font-size: 10px; font-weight: 700; padding: 2px 8px;
border-radius: 10px; background: var(--amber-bg); color: var(--amber);
margin-left: 8px; vertical-align: middle;
}
/* ═══════════════════════════════════════════════════════════════════════
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; }
/* ═══════════════════════════════════════════════════════════════════════
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; }
/* ═══════════════════════════════════════════════════════════════════════
FILTER PANEL (collapsible)
═══════════════════════════════════════════════════════════════════════ */
.filter-toggle {
display: flex; align-items: center; gap: 6px;
padding: 7px 14px; border-radius: 20px; border: 1px solid var(--border);
background: transparent; color: var(--text2); cursor: pointer;
font-size: 12px; font-weight: 500; white-space: nowrap;
transition: all 0.15s;
}
.filter-toggle:hover { border-color: var(--accent); color: var(--text); }
.filter-toggle.active { border-color: var(--accent); color: var(--accent2); background: var(--accent-bg); }
.filter-toggle .ft-count {
background: var(--accent); color: #fff; font-size: 10px; font-weight: 700;
padding: 1px 6px; border-radius: 8px; margin-left: 2px;
}
.filter-toggle.warn { border-color: var(--amber); color: var(--amber); }
.filter-toggle.warn.active { background: rgba(255, 168, 0, 0.15); }
.filter-panel {
display: none; margin-bottom: 8px;
background: var(--card2); border: 1px solid var(--border);
border-radius: var(--radius-sm); padding: 12px;
}
.filter-panel.open { display: block; animation: fadeIn 0.15s; }
.filter-panel .fp-row { display: flex; gap: 8px; margin-bottom: 8px; }
.filter-panel .fp-row:last-child { margin-bottom: 0; }
.filter-panel .fp-row select { flex: 1; margin-bottom: 0; font-size: 12px; padding: 7px 10px; }
.filter-panel .fp-label {
font-size: 10px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.04em; color: var(--text3); margin-bottom: 3px;
}
.filter-panel .fp-actions { display: flex; gap: 6px; margin-top: 4px; }
.filter-panel .fp-actions button { font-size: 11px; padding: 5px 12px; }
/* Active filter tags */
.active-filters {
display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 6px;
}
.active-filters:empty { display: none; }
.filter-tag {
display: inline-flex; align-items: center; gap: 4px;
font-size: 11px; padding: 3px 10px; border-radius: 14px;
background: var(--accent-bg); color: var(--accent2);
border: 1px solid var(--accent);
font-weight: 500;
}
.filter-tag .ft-remove {
cursor: pointer; font-size: 13px; line-height: 1; margin-left: 2px;
opacity: 0.7;
}
.filter-tag .ft-remove:hover { opacity: 1; }
.result-count {
font-size: 12px; color: var(--text2); margin-bottom: 4px;
}
.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); }
/* ═══════════════════════════════════════════════════════════════════════
MAP STYLES
═══════════════════════════════════════════════════════════════════════ */
#mapContainer {
width: 100%;
aspect-ratio: 1 / 1;
max-height: 70vh;
min-height: 350px;
border-radius: var(--radius);
overflow: hidden;
border: 1px solid var(--border);
}
#mapContainer .leaflet-container {
background: var(--bg);
}
/* 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; }
.map-chip.park-on { background: var(--accent); border-color: var(--accent); color: #fff; }
/* Map search bar */
.map-search-row {
display: flex; gap: 6px; margin-bottom: 6px; align-items: center;
}
.map-search-row input {
flex: 1; min-width: 0;
}
/* Asset list below map */
.map-asset-list {
margin-top: 8px;
max-height: 280px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 4px;
}
.map-asset-item {
display: flex; align-items: center; gap: 8px;
padding: 8px 10px; border-radius: var(--radius-xs);
background: var(--card); border: 1px solid var(--border);
cursor: pointer; transition: background 0.12s;
font-size: 13px;
}
.map-asset-item:hover { background: var(--card2); }
.map-asset-item .mai-icon { font-size: 16px; width: 24px; text-align: center; }
.map-asset-item .mai-name { flex: 1; font-weight: 600; color: var(--text); }
.map-asset-item .mai-meta { font-size: 11px; color: var(--text3); }
.map-asset-item .mai-dist { font-size: 11px; color: var(--accent2); white-space: nowrap; }
.map-asset-empty {
text-align: center; padding: 24px; color: var(--text3); font-size: 13px;
}
.cat-pin-icon { background: transparent !important; border: none !important; box-shadow: none !important; }
.park-label-icon { pointer-events: none !important; background: transparent !important; border: none !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; }
/* Leaflet.draw toolbar — removed */
/* ── Native Route Planner ── */
#tabRoute {
flex-direction: column;
padding: 12px !important;
gap: 10px;
overflow-y: auto;
}
#tabRoute.active {
display: flex !important;
}
#routeSection, #routeResultSection { width: 100%; min-width: 0; }
.route-card {
background: var(--card, #1e1e2e);
border: 1px solid var(--border, #333);
border-radius: 8px;
padding: 14px;
margin-bottom: 10px;
}
.route-card h3 { font-size: 14px; color: var(--heading, #e0e0e0); margin-bottom: 8px; }
.route-card p { font-size: 13px; color: var(--text2, #999); }
.route-card label {
display: block; font-size: 12px; color: var(--text2, #999);
margin-bottom: 4px; font-weight: 600;
}
.route-card .btn-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.route-card .btn-row button { flex: 1; min-width: 120px; }
/* Tech checkboxes */
.tech-grid {
display: flex; gap: 6px; flex-wrap: wrap; margin: 6px 0;
}
.tech-grid label {
display: flex; align-items: center; gap: 4px; cursor: pointer;
font-size: 13px; background: var(--bg, #111); padding: 4px 10px;
border-radius: 6px; border: 1px solid var(--border, #333);
}
/* Input tabs */
.rp-tabs {
display: flex; border-bottom: 1px solid var(--border, #333);
margin-bottom: 10px;
}
.rp-tab {
padding: 8px 16px; font-size: 13px; cursor: pointer;
color: var(--text2, #999); border-bottom: 2px solid transparent;
transition: all 0.15s;
}
.rp-tab:hover { color: var(--text, #ccc); }
.rp-tab.active { color: var(--accent, #58a6ff); border-bottom-color: var(--accent, #58a6ff); }
/* Origin presets */
.origin-presets { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 6px; }
.origin-presets button { font-size: 11px; padding: 4px 8px; }
/* Search results */
.search-results { max-height: 250px; overflow-y: auto; }
.search-item {
padding: 8px 10px; border-bottom: 1px solid var(--border, #333);
cursor: pointer; font-size: 13px; transition: background 0.1s;
}
.search-item:hover { background: var(--bg, #111); }
.search-item .si-name { font-weight: 600; color: var(--heading, #e0e0e0); }
.search-item .si-addr { color: var(--text2, #999); font-size: 11px; }
.search-item.selected { background: rgba(88,166,255,0.1); border-left: 3px solid var(--accent, #58a6ff); }
/* Chips (selected WOs) */
.chip-list { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 6px; }
.chip {
display: inline-flex; align-items: center; gap: 4px;
background: var(--bg, #111); border: 1px solid var(--border, #333);
border-radius: 4px; padding: 2px 6px; font-size: 11px;
}
.chip .remove { color: var(--red, #f85149); cursor: pointer; font-weight: 700; margin-left: 2px; }
/* Stats */
.rp-stats { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
.rp-stat {
background: var(--card, #1e1e2e); border: 1px solid var(--border, #333);
border-radius: 6px; padding: 10px 14px; flex: 1; min-width: 80px;
}
.rp-stat-val { font-size: 20px; font-weight: 700; color: var(--heading, #e0e0e0); }
.rp-stat-lbl { font-size: 11px; color: var(--text2, #999); margin-top: 2px; }
/* Route map */
#routeMap {
height: 350px; border-radius: 8px;
border: 1px solid var(--border, #333); margin-bottom: 10px;
}
@media (max-width: 600px) { #routeMap { height: 250px; } }
/* Stop list */
.stop-list { margin-top: 6px; }
.stop-item {
background: var(--card, #1e1e2e); border: 1px solid var(--border, #333);
border-radius: 6px; padding: 10px 12px; margin-bottom: 6px;
display: flex; align-items: flex-start; gap: 10px;
}
.stop-num {
background: var(--accent, #58a6ff); color: #fff; width: 26px; height: 26px;
border-radius: 50%; display: flex; align-items: center; justify-content: center;
font-size: 13px; font-weight: 700; flex-shrink: 0; margin-top: 1px;
}
.stop-body { flex: 1; min-width: 0; }
.stop-name { font-weight: 600; font-size: 13px; color: var(--heading, #e0e0e0); }
.stop-addr { font-size: 12px; color: var(--text, #ccc); }
.stop-meta { font-size: 11px; color: var(--text2, #999); margin-top: 2px; }
.stop-drive { font-size: 11px; color: var(--orange, #d29922); margin-top: 2px; }
.stop-cumulative { font-size: 11px; color: var(--green, #3fb950); margin-top: 2px; }
.stop-actions { display: flex; gap: 4px; flex-shrink: 0; align-items: center; }
/* Loading */
.route-loading { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 200px; }
.route-loading .spinner { width: 32px; height: 32px; border: 3px solid var(--border, #333); border-top-color: var(--accent, #58a6ff); border-radius: 50%; animation: rp-spin 0.7s linear infinite; margin: 0 auto; }
@keyframes rp-spin { to { transform: rotate(360deg); } }
/* Asset modal (reuse existing modal pattern) */
.asset-overlay {
display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.65); z-index: 2000;
}
.asset-modal {
display: none; position: fixed; top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: 90%; max-width: 500px; max-height: 85vh; z-index: 2001;
background: var(--card, #1e1e2e); border: 1px solid var(--border, #333);
border-radius: 12px; overflow: hidden;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
}
.asset-modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 16px; border-bottom: 1px solid var(--border, #333);
background: var(--bg, #111); position: sticky; top: 0; z-index: 1;
}
.asset-modal-content { padding: 16px; overflow-y: auto; max-height: calc(85vh - 60px); }
.asset-section-title {
font-size: 12px; font-weight: 600; color: var(--accent, #58a6ff);
text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px;
}
.asset-row {
display: flex; justify-content: space-between; align-items: baseline;
padding: 4px 0; font-size: 13px; border-bottom: 1px solid var(--border, #333);
}
.asset-row:last-child { border-bottom: none; }
.asset-label { color: var(--text2, #999); white-space: nowrap; margin-right: 8px; }
.asset-val { color: var(--heading, #e0e0e0); text-align: right; word-break: break-word; max-width: 60%; }
/* Print */
@media print {
body * { visibility: hidden; }
#tabRoute, #tabRoute * { visibility: visible; }
#tabRoute { position: absolute; left: 0; top: 0; width: 100%; }
.no-print { display: none !important; }
.stop-num { background: #333 !important; }
}
</style>
</head>
<body>
<!-- ═══════════════════════════════════════════════════════════════════════
HEADER
═══════════════════════════════════════════════════════════════════════ -->
<div class="header">
<button class="hamburger" onclick="openDrawer()" aria-label="Menu" title="Menu (Escape to close)"></button>
<h1>📦 Canteen Assets</h1>
<div class="header-badges">
<button class="help-btn" onclick="showHelpModal()" title="Help &amp; shortcuts" aria-label="Help"></button>
<span id="roleBadge" class="role-badge guest" style="display:none;">guest</span>
<span id="gpsBadge" class="gps-badge waiting" onclick="void(0)" style="cursor:pointer;" title="GPS status">📍 Tap GPS</span>
<span id="queueIndicator" class="queue-indicator" onclick="processOfflineQueue()" title="Offline queue — click to sync">⬇️ <span id="queueIndicatorCount">0</span></span>
<div class="user-badge" id="userBadge" title="User menu — click to open drawer">?</div>
</div>
</div>
<!-- Offline Banner -->
<div class="offline-banner" id="offlineBanner">
<span>📡</span> Offline mode — changes will sync when connected
<span class="queue-count" id="queueCountBadge" style="display:none;">0</span>
</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> Find 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>
<div style="border-top:1px solid var(--border);margin:6px 16px;"></div>
<button class="dn-item" data-tab="tabNavigate" onclick="navFromDrawer('tabNavigate')">
<span class="dn-icon">🧭</span> Nav
</button>
<button class="dn-item" data-tab="tabRoute" onclick="navFromDrawer('tabRoute')">
<span class="dn-icon">🗺️</span> Route
</button>
<button class="dn-item" id="logoutBtn" onclick="doLogout()" style="display:none;">
<span class="dn-icon">🚪</span> Logout
</button>
</nav>
<div class="drawer-footer">
<div style="margin-bottom:6px;">Canteen Asset Tracker v3.3</div>
<div style="font-size:11px;line-height:1.5;margin-bottom:6px;">
<kbd>Esc</kbd> to close · <kbd>Ctrl</kbd>+<kbd>/</kbd> to search · ❓ for help
</div>
<a href="https://admin.canteen.ourpad.casa" target="_blank" rel="noopener" style="display:block;font-size:11px;color:var(--accent2);text-decoration:none;">⚙️ Admin Panel</a>
</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>
</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>
</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 id="ocrProgressWrap" class="ocr-progress-wrap">
<div id="ocrProgressBar" class="ocr-progress-bar"></div>
</div>
</div>
<!-- OCR result -->
<div id="ocrResult" class="ocr-result" style="display:none;"></div>
<!-- OCR GPS badge -->
<div id="ocrGpsBadge" class="gps-badge ok" style="display:none; margin-bottom:8px;"></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 name, machine ID, serial, telemetry ID..." oninput="loadAssets()">
<button class="clear-btn" id="clearSearch" onclick="clearAssetSearch()" title="Clear search"></button>
</div>
<div class="search-hint"><kbd>Ctrl</kbd>+<kbd>/</kbd> to focus search</div>
<div style="display:flex;gap:6px;align-items:flex-start;margin-bottom:8px;">
<button class="filter-toggle" id="filterToggle" onclick="toggleFilterPanel()" title="Toggle filter panel">
🎯 Filters <span id="filterCount" class="ft-count" style="display:none;">0</span>
</button>
</div>
<!-- Filter panel (collapsible) -->
<div class="filter-panel" id="filterPanel">
<div class="fp-row">
<div style="flex:1">
<div class="fp-label">Category</div>
<select id="filterCategory" class="input-field" onchange="onFilterChange()">
<option value="">All categories</option>
</select>
</div>
<div style="flex:1">
<div class="fp-label">Make</div>
<select id="filterMake" class="input-field" onchange="onFilterChange()">
<option value="">All makes</option>
</select>
</div>
</div>
<div class="fp-row">
<div style="flex:1">
<div class="fp-label">Location Type</div>
<select id="filterDisneyFilter" class="input-field" onchange="onFilterChange()">
<option value="">All locations</option>
<option value="disney">🏰 Disney</option>
<option value="non_disney">🏢 Non-Disney</option>
</select>
</div>
<div style="flex:1"></div>
</div>
<div class="fp-row">
<div style="flex:1">
<div class="fp-label">Disney Park</div>
<select id="filterDisneyPark" class="input-field" onchange="onFilterChange()">
<option value="">All parks</option>
<option disabled>──────────</option>
<option value="magic-kingdom">🏰 Magic Kingdom</option>
<option value="epcot">🌍 Epcot</option>
<option value="hollywood-studios">🎬 Hollywood Studios</option>
<option value="animal-kingdom">🌿 Animal Kingdom</option>
<option value="disney-springs">🛍️ Disney Springs</option>
<option value="resort">🏨 Resort</option>
<option value="office">🏢 Office</option>
<option value="other">📍 Other</option>
</select>
</div>
</div>
<div class="fp-row">
<div style="flex:1">
<div class="fp-label">Customer</div>
<select id="filterCustomer" class="input-field" onchange="onFilterChange()">
<option value="">All customers</option>
</select>
</div>
<div style="flex:1">
<div class="fp-label">Location</div>
<select id="filterLocation" class="input-field" onchange="onFilterChange()">
<option value="">All locations</option>
</select>
</div>
</div>
<div class="fp-row">
<div style="flex:1">
<div class="fp-label">Branch</div>
<select id="filterBranch" class="input-field" onchange="onFilterChange()">
<option value="">All branches</option>
</select>
</div>
</div>
<div class="fp-row">
<div style="flex:1">
<div class="fp-label">Data Health</div>
<button class="filter-toggle warn" id="filterNoDex" onclick="toggleNoDexFilter()" style="width:100%;">
⚠️ No DEX Report (5d)
</button>
</div>
<div style="flex:1"></div>
</div>
<div class="fp-row">
<div style="flex:1">
<div class="fp-label">Assigned To</div>
<select id="filterAssignedTo" class="input-field" onchange="onFilterChange()">
<option value="">Anyone</option>
</select>
</div>
<div style="flex:1"></div>
</div>
<div class="fp-actions">
<button class="btn btn-outline btn-xs" onclick="clearAllFilters()" style="flex:1;">✕ Clear all filters</button>
<button class="btn btn-outline btn-xs" onclick="toggleFilterPanel()" style="flex:1;">✓ Done</button>
</div>
</div>
<div class="active-filters" id="activeFilters"></div>
<div class="result-count" id="resultCount"></div>
<div id="assetList"></div>
<div id="pagination" class="pagination-bar" style="display:none;margin-top:8px;"></div>
</div>
<!-- ── Detail View ──────────────────────────────────────────────────── -->
<div id="assetsDetailView" style="display:none;">
<div class="detail-header">
<button class="back-btn" onclick="showAssetList()" title="Go back to asset list"></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>
<!-- Photo card -->
<div class="card" id="detailPhotoCard" style="display:none;">
<div class="card-title">📷 Photo</div>
<div style="margin-top:8px;text-align:center;">
<img id="detailPhotoImg" style="max-width:100%;max-height:60vh;border-radius:8px;cursor:pointer;"
onclick="openPhotoModal()" alt="Asset photo">
</div>
</div>
<!-- Photo modal (fullscreen) -->
<div id="photoModal" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);z-index:9999;cursor:pointer;justify-content:center;align-items:center;"
onclick="closePhotoModal(event)">
<img id="photoModalImg" style="max-width:95%;max-height:95%;border-radius:4px;object-fit:contain;">
<span style="position:absolute;top:16px;right:24px;color:#fff;font-size:32px;font-weight:bold;cursor:pointer;" onclick="closePhotoModal()"></span>
</div>
<!-- Directions card -->
<div class="card" id="detailDirections" style="display:none;">
<div class="card-title">📍 Directions &amp; Access</div>
<div id="detailDirFields"></div>
</div>
<!-- Keys & Badges -->
<div class="card" id="detailAccess" style="display:none;">
<div class="card-title">🔑 Access</div>
<div id="detailAccessFields"></div>
</div>
<!-- Service Entrances -->
<div class="card" id="detailServiceEntrances" style="display:none;">
<div class="card-title">🚚 Service Entrances</div>
<div id="detailSEFields"></div>
</div>
<!-- MSFS Data (Dynamics 365 Field Service enrichment) -->
<div class="card" id="detailMsfsCard" style="display:none;">
<div class="card-title">📊 Field Service Data</div>
<div id="detailMsfsFields"></div>
</div>
<!-- Seed Data (mycantaloupe.com enrichment) -->
<div class="card" id="detailSeedCard" style="display:none;">
<div class="card-title">🌱 Cantaloupe/Seed Data</div>
<div id="detailSeedFields"></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;">
<!-- Edit/Delete removed — main app is lookup-only; use admin server -->
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════
TAB: MAP
═══════════════════════════════════════════════════════════════════════ -->
<div id="tabMap" class="tab-panel">
<div class="map-controls">
<span class="map-chip" id="chipHeat" onclick="toggleHeatmap()">🔥 Heatmap</span>
<span class="map-chip park-on" id="chipParkOutlines" onclick="toggleParkOutlines()">🏰 Parks ON</span>
<span class="map-chip" id="chipGeoGPS" onclick="if(navigator.geolocation)navigator.geolocation.getCurrentPosition(p=>{map.setView([p.coords.latitude,p.coords.longitude],15)})">◎ My GPS</span>
</div>
<!-- Search bar -->
<div class="map-search-row">
<input type="text" id="mapSearchInput" class="search-input" placeholder="Search by name, machine ID, category..."
oninput="mapDebounceFilter()">
<button class="btn btn-secondary btn-sm" onclick="document.getElementById('mapSearchInput').value='';mapDebounceFilter()" style="white-space:nowrap;"></button>
</div>
<!-- Filter toggle + panel (same pattern as assets tab) -->
<div style="display:flex;gap:6px;align-items:flex-start;margin-bottom:8px;">
<button class="filter-toggle" id="mapFilterToggle" onclick="toggleMapFilterPanel()" title="Toggle filter panel">
🎯 Filters <span id="mapFilterCount" class="ft-count" style="display:none;">0</span>
</button>
</div>
<div class="filter-panel" id="mapFilterPanel">
<div class="fp-row">
<div style="flex:1">
<div class="fp-label">Category</div>
<select id="mapFilterCategory" class="input-field" onchange="onMapFilterChange()">
<option value="">All categories</option>
</select>
</div>
<div style="flex:1">
<div class="fp-label">Make</div>
<select id="mapFilterMake" class="input-field" onchange="onMapFilterChange()">
<option value="">All makes</option>
</select>
</div>
</div>
<div class="fp-row">
<div style="flex:1">
<div class="fp-label">Location Type</div>
<select id="mapFilterDisneyFilter" class="input-field" onchange="onMapFilterChange()">
<option value="">All locations</option>
<option value="disney">🏰 Disney</option>
<option value="non_disney">🏢 Non-Disney</option>
</select>
</div>
<div style="flex:1">
<div class="fp-label">Disney Park</div>
<select id="mapFilterDisneyPark" class="input-field" onchange="onMapFilterChange()">
<option value="">All parks</option>
<option disabled>──────────</option>
<option value="magic-kingdom">🏰 Magic Kingdom</option>
<option value="epcot">🌍 Epcot</option>
<option value="hollywood-studios">🎬 Hollywood Studios</option>
<option value="animal-kingdom">🌿 Animal Kingdom</option>
<option value="disney-springs">🛍️ Disney Springs</option>
<option value="resort">🏨 Resort</option>
<option value="office">🏢 Office</option>
<option value="other">📍 Other</option>
</select>
</div>
</div>
<div class="fp-row">
<div style="flex:1">
<div class="fp-label">Customer</div>
<select id="mapFilterCustomer" class="input-field" onchange="onMapFilterChange()">
<option value="">All customers</option>
</select>
</div>
<div style="flex:1">
<div class="fp-label">Location</div>
<select id="mapFilterLocation" class="input-field" onchange="onMapFilterChange()" disabled>
<option value="">All locations</option>
</select>
</div>
</div>
<div class="fp-row">
<div style="flex:1">
<div class="fp-label">Branch</div>
<select id="mapFilterBranch" class="input-field" onchange="onMapFilterChange()">
<option value="">All branches</option>
</select>
</div>
<div style="flex:1">
<div class="fp-label">Assigned To</div>
<select id="mapFilterAssignedTo" class="input-field" onchange="onMapFilterChange()">
<option value="">Anyone</option>
</select>
</div>
</div>
<div class="fp-actions">
<button class="btn btn-outline btn-xs" onclick="clearMapFilters()" style="flex:1;">✕ Clear all filters</button>
<button class="btn btn-outline btn-xs" onclick="toggleMapFilterPanel()" style="flex:1;">✓ Done</button>
</div>
</div>
<div class="active-filters" id="mapActiveFilters"></div>
<!-- Map -->
<div id="mapContainer"></div>
<!-- Asset list -->
<div id="mapAssetList" class="map-asset-list"></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>
<!-- Setup prompt: shown when missing GPS or asset -->
<div id="navSetup" class="card" style="margin-bottom:10px;">
<div id="navSetupGps" style="display:none;margin-bottom:8px;">
<div style="font-size:13px;color:var(--text2);margin-bottom:6px;">📍 Your location is needed for navigation</div>
<button class="btn btn-sm btn-primary" onclick="captureNavGps()" style="width:100%;">📍 Get My Location</button>
</div>
<div id="navSetupAsset" style="display:none;margin-bottom:8px;">
<div style="font-size:13px;color:var(--text2);margin-bottom:6px;">🔍 Select an asset to navigate to</div>
<input id="navAssetSearch" type="text" class="input-field" placeholder="Search asset by name or machine ID..." oninput="searchNavAssets()" style="margin-bottom:6px;">
<div id="navAssetResults" style="font-size:13px;"></div>
</div>
</div>
<div id="navMapContainer" style="height:280px;border-radius:12px;overflow:hidden;margin-bottom:12px;"></div>
<div style="display:flex;gap:6px;margin-bottom:8px;flex-wrap:wrap;">
<button class="btn btn-sm btn-outline" onclick="navigateToAsset('driving')" style="flex:1;">🚗 Driving</button>
<button class="btn btn-sm btn-outline" onclick="navigateToAsset('walking')" style="flex:1;">🚶 Walking</button>
</div>
<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;">
<div style="font-size:11px;color:var(--text3);">Est. Time</div>
<div id="navDuration" style="font-size:20px;font-weight:700;"></div>
</div>
</div>
</div>
<div id="navDirectionsCard" class="card" style="display:none;">
<div class="card-title">🚶 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>
<!-- ── ROUTE PLANNER ── -->
<div id="tabRoute" class="tab-panel">
<!-- Input Section -->
<div id="routeSection">
<!-- Load Today's Route -->
<div class="route-card" style="text-align:center;">
<div style="text-align:left;margin-bottom:10px;">
<label>👤 Technicians</label>
<div id="rpTechCheckboxes" class="tech-grid">
<label style="display:flex;align-items:center;gap:4px;cursor:pointer;font-size:13px;background:var(--bg,#111);padding:4px 10px;border-radius:6px;border:1px solid var(--border,#333);">
<input type="checkbox" id="rpTechAll" checked onchange="rpToggleAllTechs()"> All
</label>
</div>
</div>
<div class="btn-row">
<button class="btn btn-primary" style="font-size:15px;padding:12px 24px;flex:1;" onclick="rpLoadToday()">
🚀 Load Today's Route
</button>
</div>
<p style="font-size:11px;color:var(--text2,#999);margin-top:6px;">Finds today's scheduled/active work orders and plans the optimal route</p>
</div>
<!-- Starting Point -->
<div class="route-card">
<h3>📍 Starting Point</h3>
<div style="display:flex;gap:8px;">
<input type="text" id="rpOriginInput" placeholder="28.5383, -81.3792" class="search-input" style="flex:1;">
<button class="btn btn-secondary btn-sm" onclick="rpUseMyLocation()">📍 My Location</button>
</div>
<div class="origin-presets">
<button class="btn btn-secondary btn-sm" onclick="rpSetOrigin(28.5383,-81.3792)">🏢 Orlando</button>
<button class="btn btn-secondary btn-sm" onclick="rpSetOrigin(28.6272,-81.2343)">🔧 Oviedo Shop</button>
<button class="btn btn-secondary btn-sm" onclick="rpSetOrigin(28.4945,-81.5774)">🏭 Ocoee</button>
</div>
<label style="margin-top:6px;display:flex;align-items:center;gap:6px;font-size:12px;">
<input type="checkbox" id="rpUseOrigin" checked> Start from this location
</label>
</div>
<!-- Work Order Input -->
<div class="route-card">
<div class="rp-tabs" id="rpInputTabs">
<div class="rp-tab active" onclick="rpSwitchInputTab('paste')">📋 Paste WOs</div>
<div class="rp-tab" onclick="rpSwitchInputTab('search')">🔍 Search</div>
</div>
<!-- Paste tab -->
<div id="rpTabPaste">
<label>Paste work order numbers (one per line, or comma-separated)</label>
<textarea id="rpWoInput" class="search-input" style="width:100%;min-height:80px;resize:vertical;font-family:monospace;font-size:13px;padding:8px 10px;background:var(--bg,#111);border:1px solid var(--border,#333);border-radius:6px;color:var(--text,#ccc);" placeholder="WO-00958990&#10;WO-00959256"></textarea>
<button class="btn btn-primary mt-8" onclick="rpPlanFromPaste()" style="margin-top:8px;">🚀 Plan Route</button>
<span style="font-size:11px;color:var(--text2,#999);margin-left:8px;">Looks up WO numbers from the extraction DB</span>
</div>
<!-- Search tab -->
<div id="rpTabSearch" style="display:none;">
<label>Search work orders</label>
<input type="text" id="rpSearchInput" class="search-input" style="width:100%;" placeholder="Search by WO#, account name, or city..." oninput="rpDebounceSearch()">
<div id="rpSearchResults" class="search-results" style="margin-top:6px;"></div>
<div id="rpSelectedWOs" style="margin-top:8px;">
<label>Selected WOs (<span id="rpSelectedCount">0</span>)</label>
<div id="rpSelectedWOList" class="chip-list"></div>
</div>
<button class="btn btn-primary mt-8" onclick="rpPlanFromSelected()" id="rpPlanSelectedBtn" disabled style="margin-top:8px;">🚀 Plan Route (<span id="rpPlanSelectedCount">0</span>)</button>
</div>
</div>
</div>
<!-- Loading -->
<div id="rpLoadingSection" style="display:none;">
<div class="route-loading">
<div class="spinner"></div>
<div style="margin-top:12px;color:var(--text2,#999);font-size:14px;" id="rpLoadingMsg">Planning route...</div>
</div>
</div>
<!-- Result Section -->
<div id="rpResultSection" style="display:none;width:100%;">
<!-- Action bar -->
<div class="btn-row no-print" style="margin-bottom:10px;">
<button class="btn btn-secondary btn-sm" onclick="rpResetAll()">← New Route</button>
<button class="btn btn-secondary btn-sm" onclick="window.print()">🖨️ Print</button>
<button class="btn btn-secondary btn-sm" onclick="rpCopyText()">📋 Copy</button>
</div>
<!-- Stats -->
<div id="rpRouteStats" class="rp-stats"></div>
<!-- Map -->
<div id="routeMap"></div>
<!-- Stop List -->
<div class="route-card no-print">
<div style="display:flex;justify-content:space-between;align-items:center;">
<h3>📍 Stops</h3>
<button class="btn btn-secondary btn-sm" onclick="rpToggleReorder()" id="rpReorderToggle">↕️ Reorder</button>
</div>
<div id="rpStopList" class="stop-list"></div>
</div>
<!-- No GPS warning -->
<div id="rpNoGpsWarning" style="display:none;" class="route-card">
<h3 style="color:var(--orange,#d29922);">⚠️ Stops without GPS</h3>
<div id="rpNoGpsList"></div>
</div>
</div>
</div>
<!-- Asset Detail Modal -->
<div id="rpAssetOverlay" class="asset-overlay" onclick="rpCloseAssetModal()"></div>
<div id="rpAssetModal" class="asset-modal">
<div class="asset-modal-header">
<span style="font-size:18px;font-weight:700;color:var(--heading,#e0e0e0);">🔧 Machine Details</span>
<button onclick="rpCloseAssetModal()" style="background:none;border:none;color:var(--text2,#999);font-size:20px;cursor:pointer;padding:4px 8px;"></button>
</div>
<div id="rpAssetModalContent" class="asset-modal-content"></div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════
BOTTOM TABS
═══════════════════════════════════════════════════════════════════════ -->
<div class="tabs">
<button class="tab-btn active" data-tab="tabAddAsset" onclick="switchTab('tabAddAsset')" title="Scan barcode or OCR to find an asset">
<span class="tab-icon">📷</span> Find Asset
</button>
<button class="tab-btn" data-tab="tabAssets" onclick="switchTab('tabAssets')" title="Browse and search your assets">
<span class="tab-icon">📦</span> Assets
</button>
<button class="tab-btn" data-tab="tabMap" onclick="switchTab('tabMap')" title="View all assets on a map">
<span class="tab-icon">🗺️</span> Map
</button>
<button class="tab-btn" data-tab="tabNavigate" onclick="switchTab('tabNavigate')" title="Navigate to an asset">
<span class="tab-icon">🧭</span> Nav
</button>
<button class="tab-btn" data-tab="tabRoute" onclick="switchTab('tabRoute')" title="Plan a route">
<span class="tab-icon">🗺️</span> Route
</button>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════
HELP MODAL
═══════════════════════════════════════════════════════════════════════ -->
<div class="modal-overlay" id="helpModalOverlay" onclick="closeHelpModal(event)">
<div class="modal" onclick="event.stopPropagation()" style="text-align:left;max-width:420px;">
<div class="modal-title" style="text-align:center;">❓ Quick Tips</div>
<div class="modal-body" style="text-align:left;line-height:1.6;">
<p style="margin-bottom:10px;"><strong>Keyboard Shortcuts</strong></p>
<ul style="margin:0 0 12px 20px;padding:0;list-style:disc;">
<li><kbd>Ctrl</kbd>+<kbd>/</kbd> — Focus search bar</li>
<li><kbd>Escape</kbd> — Close drawers, modals, or go back</li>
</ul>
<p style="margin-bottom:10px;"><strong>Navigation</strong></p>
<ul style="margin:0 0 12px 20px;padding:0;list-style:disc;">
<li>Bottom tabs switch between Find Asset, Assets, Map, Nav, and Route views</li>
<li>Use the ☰ menu to access settings, profile, and admin panel</li>
</ul>
<p style="margin-bottom:10px;"><strong>Scanning</strong></p>
<ul style="margin:0 0 12px 20px;padding:0;list-style:disc;">
<li>Use the Find Asset tab to scan barcodes or read ConnectID stickers via camera</li>
<li>Stickers in <code>XXXXX-XXXXXX</code> format are read automatically via OCR</li>
</ul>
</div>
<div class="modal-actions">
<button class="btn btn-primary" onclick="closeHelpModal()">Got it</button>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════
TOAST
═══════════════════════════════════════════════════════════════════════ -->
<div class="toast" id="toast"></div>
<!-- ═══════════════════════════════════════════════════════════════════════
MODAL
═══════════════════════════════════════════════════════════════════════ -->
<div class="modal-overlay" id="modalOverlay" onclick="closeModal()">
<div class="modal" onclick="event.stopPropagation()">
<div class="modal-title" id="modalTitle">Confirm</div>
<div class="modal-body" id="modalBody">Are you sure?</div>
<div class="modal-actions">
<button class="btn btn-outline btn-sm" onclick="closeModal()">Cancel</button>
<button class="btn btn-danger btn-sm" id="modalConfirmBtn">Confirm</button>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════
LOGIN OVERLAY (Phase M)
═══════════════════════════════════════════════════════════════════════ -->
<div class="login-overlay hidden" id="loginOverlay">
<div class="login-card">
<div class="login-icon">🔐</div>
<h2>Sign In</h2>
<div class="login-error" id="loginError"></div>
<input id="loginUsername" type="text" class="input-field" placeholder="Username" autocomplete="username">
<input id="loginPassword" type="password" class="input-field" placeholder="Password" autocomplete="current-password">
<label class="checkbox-row">
<input type="checkbox" id="loginRemember"> Remember me
</label>
<button class="btn btn-primary" id="loginBtn" 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 = {}) {
const returnMeta = opts._returnMeta;
delete opts._returnMeta;
// 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);
}
if (returnMeta) return { data, headers: res.headers };
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,
ocrGpsSaved: false, // true when OCR photo GPS was auto-saved to asset
};
function isLoggedIn() { return !!AppState.authToken; }
function isTechnician() { return AppState.currentUser && AppState.currentUser.role === 'technician'; }
// ── Dropdown helper ────────────────────────────────────────────────────
let _activeDropdown = null;
function showDropdown(anchorEl, itemsHtml) {
closeDropdown();
const rect = anchorEl.getBoundingClientRect();
const el = document.createElement('div');
el.id = '__dropdown';
el.style.cssText = 'position:fixed;z-index:9999;background:var(--card-bg);border:1px solid var(--border);border-radius:8px;padding:6px;box-shadow:0 8px 24px rgba(0,0,0,.4);min-width:200px';
el.style.top = (rect.bottom + 6) + 'px';
el.style.left = Math.min(rect.left, window.innerWidth - 220) + 'px';
el.innerHTML = itemsHtml;
document.body.appendChild(el);
_activeDropdown = el;
// Click outside to close
setTimeout(() => document.addEventListener('click', _closeDropdownHandler), 10);
}
function _closeDropdownHandler() { closeDropdown(); document.removeEventListener('click', _closeDropdownHandler); }
function closeDropdown() {
if (_activeDropdown) {
_activeDropdown.remove();
_activeDropdown = null;
}
}
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';
// 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() {
// Field tool mode — server accepts anonymous requests (falls back to admin).
// We skip auth entirely. If a stored session exists, use it for display
// but never gate the UI.
const stored = localStorage.getItem('canteen_session');
if (stored) {
try {
const session = JSON.parse(stored);
AppState.authToken = session.token;
AppState.currentUser = await api('/api/auth/me', {
headers: { 'Authorization': 'Bearer ' + session.token }
}).catch(() => null);
} catch (e) {
localStorage.removeItem('canteen_session');
}
}
if (!AppState.currentUser) {
// Guest mode — no token, server accepts anonymous
AppState.authToken = null;
AppState.currentUser = { username: 'Guest', role: 'guest' };
}
updateUserUI();
hideLogin();
}
// =========================================================================
// 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');
const loginBtn = document.getElementById('loginBtn');
if (!username || !password) {
errEl.textContent = 'Please enter username and password';
errEl.classList.add('show');
return;
}
loginBtn.disabled = true;
loginBtn.textContent = 'Signing in...';
try {
const remember = document.getElementById('loginRemember').checked;
const result = await api('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, remember_me: remember }),
});
AppState.authToken = result.token;
AppState.currentUser = { id: result.id, username: result.username, role: result.role };
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(() => {
populateMakeSelect();
populateCustomerSelect();
loadBadgeChecklist();
});
} catch (e) {
errEl.textContent = e.message || 'Login failed';
errEl.classList.add('show');
} finally {
loginBtn.disabled = false;
loginBtn.textContent = 'Sign In';
}
}
async function doLogout() {
try {
await api('/api/auth/logout', { method: 'POST' });
} catch (e) { /* ignore — still clean up locally */ }
localStorage.removeItem('canteen_session');
AppState.authToken = null;
AppState.currentUser = { username: 'Guest', role: 'guest' };
updateUserUI();
hideLogin();
showToast('Logged out — running as guest');
}
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();
}
}
// Global keyboard shortcuts
document.addEventListener('keydown', function(e) {
// Ctrl+/ or Ctrl+K to focus search
if ((e.ctrlKey || e.metaKey) && (e.key === '/' || e.key === 'k')) {
e.preventDefault();
var searchEl = document.getElementById('assetSearch');
if (searchEl) { searchEl.focus(); searchEl.select(); }
return;
}
// Escape — close modals, drawers, go back
if (e.key === 'Escape') {
var helpOverlay = document.getElementById('helpModalOverlay');
if (helpOverlay && helpOverlay.classList.contains('open')) {
closeHelpModal(); return;
}
var modalOverlay = document.getElementById('modalOverlay');
if (modalOverlay && modalOverlay.classList.contains('open')) {
closeModal(); return;
}
var drawer = document.querySelector('.drawer');
if (drawer && drawer.classList.contains('open')) {
closeDrawer(); return;
}
}
// Enter on login
if (e.key === 'Enter' && !document.getElementById('loginOverlay').classList.contains('hidden')) {
var active = document.activeElement;
if (active && (active.id === 'loginUsername' || active.id === 'loginPassword')) {
doLogin();
}
}
});
// =========================================================================
// GPS
// =========================================================================
function requestLocation() {
return new Promise((resolve, reject) => {
navigator.geolocation.getCurrentPosition(
pos => {
AppState.gpsLat = pos.coords.latitude;
AppState.gpsLng = pos.coords.longitude;
AppState.gpsAcc = pos.coords.accuracy;
const badge = document.getElementById('gpsBadge');
badge.className = 'gps-badge ok';
badge.textContent = `📍 ${AppState.gpsLat.toFixed(4)}, ${AppState.gpsLng.toFixed(4)}`;
resolve(pos);
},
err => {
const badge = document.getElementById('gpsBadge');
badge.className = 'gps-badge err';
if (err.code === 1) badge.textContent = '📍 Tap for GPS';
else badge.textContent = '📍 ' + err.message;
reject(err);
},
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 300000 }
);
});
}
function initGPS() {
const badge = document.getElementById('gpsBadge');
if (!navigator.geolocation) {
badge.className = 'gps-badge err';
badge.textContent = '📍 Unavailable';
badge.style.cursor = 'default';
return;
}
// Always tappable — never one-shot, user can re-capture anytime
updateGpsBadge();
badge.style.cursor = 'pointer';
badge.onclick = function() {
badge.textContent = '📍 Locating...';
requestLocation().then(() => {
updateGpsBadge();
// Re-enable for next re-capture
badge.onclick = arguments.callee;
}).catch(() => {
badge.textContent = '📍 Tap to retry';
badge.onclick = arguments.callee;
});
};
// Also try silently once in case browser allows it without gesture
requestLocation().catch(() => {
// Silent fail is expected on mobile — badge click handler covers it
});
}
function updateGpsBadge() {
const badge = document.getElementById('gpsBadge');
if (AppState.gpsLat != null) {
badge.className = 'gps-badge ok';
badge.textContent = `📍 ${AppState.gpsLat.toFixed(4)}, ${AppState.gpsLng.toFixed(4)}`;
} else {
badge.className = 'gps-badge waiting';
badge.textContent = '📍 Tap for GPS';
}
}
// =========================================================================
// 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'), 4000);
}
// =========================================================================
// MODAL
// =========================================================================
let modalResolve = null;
function showModal(title, body, confirmText = 'Confirm', confirmClass = 'btn-danger') {
document.getElementById('modalTitle').textContent = title;
const bodyEl = document.getElementById('modalBody');
bodyEl.innerHTML = '';
if (typeof body === 'string') {
bodyEl.textContent = body;
} else {
bodyEl.appendChild(body);
}
const btn = document.getElementById('modalConfirmBtn');
btn.textContent = confirmText;
btn.className = 'btn btn-sm ' + confirmClass;
document.getElementById('modalOverlay').classList.add('open');
return new Promise(resolve => {
modalResolve = resolve;
});
}
function closeModal(confirmed = false) {
document.getElementById('modalOverlay').classList.remove('open');
if (modalResolve) {
modalResolve(confirmed);
modalResolve = null;
}
}
function showHelpModal() {
document.getElementById('helpModalOverlay').classList.add('open');
}
function closeHelpModal(e) {
if (e && e.target !== e.currentTarget) return;
document.getElementById('helpModalOverlay').classList.remove('open');
}
document.getElementById('modalConfirmBtn').addEventListener('click', () => closeModal(true));
// =========================================================================
// DRAWER
// =========================================================================
function openDrawer() {
document.getElementById('drawer').classList.add('open');
document.getElementById('drawerOverlay').classList.add('open');
// Highlight current tab in drawer
document.querySelectorAll('.drawer-nav .dn-item').forEach(el => {
el.classList.toggle('active', el.dataset.tab === AppState.currentTab);
});
}
function closeDrawer() {
document.getElementById('drawer').classList.remove('open');
document.getElementById('drawerOverlay').classList.remove('open');
}
function navFromDrawer(tabId) {
closeDrawer();
switchTab(tabId);
}
// =========================================================================
// TAB SWITCHING
// =========================================================================
function switchTab(tabId) {
const wasNavigating = AppState.currentTab === 'tabNavigate';
// Track previous tab for back navigation
if (AppState.currentTab !== tabId && !wasNavigating) {
AppState._prevTab = AppState.currentTab;
}
// Update current tab BEFORE cleanup to prevent recursion
// when closeNavigate() calls switchTab()
AppState.currentTab = tabId;
// Cleanup nav page when leaving (closeNavigate only resets nav state,
// it does NOT switch panels — we must NOT return early here)
if (wasNavigating && tabId !== 'tabNavigate') {
closeNavigate();
}
// Update tab panels
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
const panel = document.getElementById(tabId);
if (panel) panel.classList.add('active');
// Update bottom tabs — highlight the matching tab, or "More" for drawer-only tabs
const tabMap = {
tabAddAsset: 'tabAddAsset',
tabAssets: 'tabAssets',
tabMap: 'tabMap',
tabNavigate: 'tabNavigate',
tabRoute: 'tabRoute',
};
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(); }
// Map tab lifecycle
if (tabId === 'tabMap') {
if (!map) {
const tileUrl = document.querySelector('meta[name="map-tile-url"]')?.content
|| 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
const tileAttr = document.querySelector('meta[name="map-tile-attribution"]')?.content
|| '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>';
map = L.map('mapContainer', {
center: [39.8283, -98.5795],
zoom: 4,
zoomControl: true,
});
L.tileLayer(tileUrl, { attribution: tileAttr, maxZoom: 19 }).addTo(map);
// Draw Disney park outlines on first map init
if (parkOutlinesVisible) drawParkOutlines();
// Restore heatmap layer if it was active
if (heatVisible) setTimeout(() => loadHeatmapData(), 300);
}
setTimeout(() => map.invalidateSize(), 150);
loadAssetPins(); // Refresh pins on every tab visit
}
// Route tab — dispatch event so route planner can initialize
if (tabId === 'tabRoute') {
document.dispatchEvent(new CustomEvent('rpTabActivated'));
}
// Navigate tab — check GPS and asset setup
if (tabId === 'tabNavigate') {
updateNavSetup();
}
// Dispatch event so child tab implementations can hook in
document.dispatchEvent(new CustomEvent('tabChange', { detail: { tabId } }));
}
// =========================================================================
// EVENT DELEGATION
// =========================================================================
// Global click handler for dynamic content
document.addEventListener('click', function(e) {
const el = e.target.closest('[data-action]');
if (!el) return;
const action = el.dataset.action;
// Child tabs register handlers via AppState.actions or direct listeners
const handler = AppState._actions && AppState._actions[action];
if (handler) {
e.preventDefault();
handler(el, e);
}
});
// Register an action handler for event delegation
function registerAction(name, handler) {
if (!AppState._actions) AppState._actions = {};
AppState._actions[name] = handler;
}
// =========================================================================
// Helpers
// =========================================================================
function esc(s) {
if (!s) return '';
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
function cssSafe(s) {
if (!s) return 'Other';
return s.replace(/&amp;/g, '&').replace(/[^a-zA-Z0-9]/g, '-');
}
function formatDate(iso) {
if (!iso) return '—';
try {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
+ ' ' + d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
} catch (e) { return iso; }
}
function timeAgo(iso) {
if (!iso) return '';
try {
const d = new Date(iso);
const now = new Date();
const diff = Math.floor((now - d) / 1000);
if (diff < 60) return 'just now';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
return Math.floor(diff / 86400) + 'd ago';
} catch (e) { return iso; }
}
// ═══════════════════════════════════════════════════════════════════════
// ADD ASSET TAB — 3 Modes: Barcode, OCR, Manual
// ═══════════════════════════════════════════════════════════════════════
let addAssetMode = 'barcode';
let stream = null;
let ocrStream = null;
let codeReader = null;
let scanningActive = false;
let currentScannedMachineId = null;
let barcodeDebounce = null;
let manualPhotoBlob = null;
let manKeysCounter = 0;
let settingsCache = {};
let map = null;
let heatLayer = null;
let heatVisible = false;
// ── 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();
if (typeof stopManualPhoto === 'function') stopManualPhoto();
if (mode === 'barcode') {
startScanning();
}
}
// ── Shared: Load settings dropdowns (hardcoded defaults — managed via admin app) ──
async function loadSettingsCache() {
if (settingsCache._loaded) return;
// Hardcoded seed data matches the admin app's defaults
settingsCache.categories = [
{name:'Furniture',icon:'🪑'}, {name:'Appliances',icon:'🔌'},
{name:'Utensils & Serveware',icon:'🍽️'}, {name:'Equipment',icon:'⚙️'},
{name:'Other',icon:'📦'},
];
settingsCache.makes = [
{id:1,name:'Canteen'}, {id:2,name:'Hobart'}, {id:3,name:'Vollrath'},
{id:4,name:'Metro'}, {id:5,name:'Rubbermaid'}, {id:6,name:'Cambro'},
{id:7,name:'Other'},
];
settingsCache.models = [];
settingsCache.keyNames = [
{name:'MK500'}, {name:'Green Dot'}, {name:'Red Key'}, {name:'Blue Key'},
{name:'Master Key'}, {name:'Padlock Key'},
];
settingsCache.keyTypes = [
{name:'Round Short'}, {name:'Barrel'}, {name:'Standard'},
{name:'Flat'}, {name:'Tubular'},
];
settingsCache.badgeTypes = [
{name:'Disney Contractor Base'}, {name:'Visitor Badge'}, {name:'Employee Badge'},
{name:'Contractor Badge'}, {name:'Temporary Pass'},
];
settingsCache.customers = [];
settingsCache._loaded = true;
}
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;
// Locations managed via admin app
try {
sel.innerHTML += '<option value="">No locations (manage in admin app)</option>';
} 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() {
// All formats — 1D (Code 128, EAN, UPC, etc.) + 2D (QR, DataMatrix,
// PDF417, Aztec) for serial number stickers with 2D barcodes.
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,
ZXing.BarcodeFormat.QR_CODE,
ZXing.BarcodeFormat.DATA_MATRIX,
ZXing.BarcodeFormat.PDF_417,
ZXing.BarcodeFormat.AZTEC,
];
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('checkinCard').style.display = 'none';
try {
const asset = await api(`/api/assets/search?machine_id=${encodeURIComponent(machineId)}`);
// Asset exists — go directly to its detail view
showToast(`${esc(asset.name)} — showing details`, false);
// Auto-checkin if GPS available (background, non-blocking)
if (AppState.gpsLat != null) {
doAutoCheckin(asset.id);
}
switchTab('tabAssets');
viewAsset(asset.id);
} 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 hasGps = AppState.gpsLat != null;
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(catLabel(asset.category))} ·
</div>
<div class="sr-actions">
<span class="sr-checkin-done" style="color:${hasGps ? '#22c55e' : 'var(--amber)'}">
${hasGps ? '✓ GPS captured' : '📍 GPS needed'}
</span>
<button class="btn btn-outline btn-sm" onclick="switchTab('tabAssets');viewAsset(${asset.id});" style="flex:1;">View Details</button>
</div>
`;
// If we have GPS, auto-checkin silently
if (hasGps) {
doAutoCheckin(asset.id);
}
}
async function doAutoCheckin(assetId) {
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 scan',
}),
});
} catch (e) {
console.warn('Auto check-in failed:', e);
}
}
function openCheckinForAsset(assetId) {
AppState.currentAssetId = assetId;
showCheckinForm();
}
function showNewAssetForm(machineId) {
currentScannedMachineId = machineId;
setScanStatus(`Machine ID ${machineId} not found in database`, 'error');
const el = document.getElementById('scanResult');
el.style.display = 'block';
el.innerHTML = `
<div style="font-size:14px;font-weight:600;color:var(--amber);">❌ Asset not found</div>
<div style="font-size:12px;color:var(--text2);margin-top:4px;">
Machine ID <strong>${esc(machineId)}</strong> is not registered.
</div>
<div style="font-size:12px;color:var(--text3);margin-top:8px;">
Try OCR scanning the sticker, or search in the Assets tab.
</div>`;
}
function showCheckinForm() {
document.getElementById('checkinNotes').value = '';
document.getElementById('checkinCard').style.display = 'block';
document.getElementById('checkinNotes').focus();
updateCheckinGpsDisplay();
}
function updateCheckinGpsDisplay() {
const status = document.getElementById('checkinGpsStatus');
const coords = document.getElementById('checkinGpsCoords');
if (AppState.gpsLat != null) {
status.className = 'gps-badge ok';
status.textContent = '📍 GPS locked';
coords.textContent = `${AppState.gpsLat.toFixed(4)}, ${AppState.gpsLng.toFixed(4)}${Math.round(AppState.gpsAcc || 0)}m)`;
coords.style.display = 'inline';
} else {
status.className = 'gps-badge waiting';
status.textContent = '📍 Tap for GPS';
coords.style.display = 'none';
}
}
function captureCheckinGps() {
const status = document.getElementById('checkinGpsStatus');
status.textContent = '📍 Locating...';
status.className = 'gps-badge waiting';
if (!navigator.geolocation) {
status.className = 'gps-badge err';
status.textContent = '📍 Unavailable';
showToast('GPS not available on this device', true);
return;
}
navigator.geolocation.getCurrentPosition(
pos => {
AppState.gpsLat = pos.coords.latitude;
AppState.gpsLng = pos.coords.longitude;
AppState.gpsAcc = pos.coords.accuracy;
updateCheckinGpsDisplay();
updateGpsBadge();
showToast('📍 GPS location captured');
},
err => {
status.className = 'gps-badge err';
if (err.code === 1) {
status.textContent = '📍 Permission denied — tap to retry';
showToast('Please allow GPS access in your browser settings', true);
} else {
status.textContent = '📍 ' + err.message;
showToast('GPS error: ' + err.message, true);
}
},
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 60000 }
);
}
async function submitCheckin() {
if (!AppState.currentAssetId) {
showToast('No asset selected', true);
return;
}
// If GPS is missing, warn but still let them check in
if (AppState.gpsLat == null) {
if (!(await showModal('No GPS', 'No GPS location captured. Check in without location data?', 'Check In', 'btn-primary'))) {
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);
}
}
// ── 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; }
}
function updateOcrProgress(m) {
const wrap = document.getElementById('ocrProgressWrap');
const bar = document.getElementById('ocrProgressBar');
if (!wrap || !bar) return;
if (m.status === 'recognizing text') {
wrap.classList.add('active');
const pct = Math.round((m.progress || 0) * 100);
bar.style.width = pct + '%';
}
if (m.status === 'done' || m.status === 'error') {
wrap.classList.remove('active');
bar.style.width = '0%';
}
}
function parseMachineId(text) {
// Same logic as server-side: XXXXX-XXXXXX pattern → last 5 digits
const match = text.match(/(\d{5})[-\s]*(\d{6,})/);
if (match) {
const digits = match[0].replace(/\D/g, '');
return { machine_id: digits.slice(-5), confidence: 'high' };
}
// Looser: any 5+ digit number → last 5 digits
const loose = text.match(/(\d{5,})/);
if (loose) {
const digits = loose[1];
const mid = digits.length > 5 ? digits.slice(-5) : digits;
return { machine_id: mid, confidence: 'low' };
}
return { machine_id: null, confidence: 'none' };
}
// ── Shared: Display OCR result and GPS ─────────────────────────────────────
function displayOcrResult(data) {
const el = document.getElementById('ocrResult');
el.style.display = 'block';
if (data.machine_id) {
const methodBadge = data.method === 'client'
? '<span class="offline-badge">📡 Offline OCR</span>'
: '';
const confLabel = data.confidence === 'high' ? '✅ High confidence' :
data.confidence === 'low' ? '⚠️ Low confidence' : '';
el.innerHTML = `
<div class="or-id">${esc(data.machine_id)}${methodBadge}</div>
<div class="or-meta">${confLabel}</div>
${data.raw_text ? `<div class="or-raw">OCR text: ${esc(data.raw_text)}</div>` : ''}
<div class="or-searching" style="margin-top:8px;font-size:13px;color:var(--text2);">🔍 Searching for asset...</div>`;
setOcrStatus('Machine ID found! Searching...', 'working');
// Auto-search the asset
lookupOcrAsset(data.machine_id);
} 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');
}
// If GPS was extracted from the photo EXIF, fill it into AppState
if (data.exif_gps && data.exif_gps.lat && data.exif_gps.lng) {
AppState.gpsLat = data.exif_gps.lat;
AppState.gpsLng = data.exif_gps.lng;
const badge = document.getElementById('ocrGpsBadge');
if (badge) {
badge.style.display = 'block';
badge.textContent = '📍 GPS: ' + data.exif_gps.lat.toFixed(6) + ', ' + data.exif_gps.lng.toFixed(6);
}
showToast('📍 GPS extracted from photo: ' + data.exif_gps.lat.toFixed(6) + ', ' + data.exif_gps.lng.toFixed(6));
}
if (data.gps_saved) {
AppState.ocrGpsSaved = true;
}
}
// ── OCR: Auto-search asset by machine_id ─────────────────────────────
async function lookupOcrAsset(machineId) {
try {
const asset = await api('/api/assets/search?machine_id=' + encodeURIComponent(machineId));
// Asset found — update OCR result with asset info
const el = document.getElementById('ocrResult');
el.innerHTML = `
<div class="or-id">${esc(asset.machine_id)} ✅</div>
<div class="sr-name" style="font-size:16px;font-weight:700;margin:4px 0;">${esc(asset.name)}</div>
<div class="or-meta">${esc(catLabel(asset.category))} · ${esc(asset.status)}</div>`;
setOcrStatus('Asset found!', 'success');
// Try to capture GPS if not available
AppState.currentAssetId = asset.id;
if (AppState.gpsLat == null) {
try {
const pos = await new Promise((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject,
{ enableHighAccuracy: true, timeout: 8000 });
});
AppState.gpsLat = pos.coords.latitude;
AppState.gpsLng = pos.coords.longitude;
AppState.gpsAcc = pos.coords.accuracy;
} catch (_) { /* GPS not available */ }
}
// Auto-checkin if GPS available
if (AppState.gpsLat != null) {
doAutoCheckin(asset.id);
el.innerHTML += `<div style="margin-top:8px;font-size:12px;color:var(--green);">✓ Auto check-in recorded</div>`;
// Save GPS to asset if it's missing GPS coords
if (!asset.latitude || !asset.longitude) {
try {
await api('/api/assets/' + asset.id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
latitude: AppState.gpsLat,
longitude: AppState.gpsLng,
}),
});
el.innerHTML += `<div style="margin-top:8px;font-size:12px;color:var(--green);">📍 GPS location saved to asset</div>`;
} catch (_) {}
}
} else {
el.innerHTML += `<div style="margin-top:8px;font-size:12px;color:var(--amber);">📍 GPS not available — check-in skipped</div>`;
}
if (AppState.ocrGpsSaved) {
el.innerHTML += `<div style="margin-top:8px;font-size:12px;color:var(--green);">📍 GPS saved to asset</div>`;
AppState.ocrGpsSaved = false;
}
el.innerHTML += `<div style="margin-top:8px;">
<button class="btn btn-outline btn-sm" onclick="switchTab('tabAssets');viewAsset(${asset.id});" style="width:100%;">View Details</button>
</div>`;
} catch (e) {
if (e.message === 'Asset not found' || e.message.includes('404')) {
const el = document.getElementById('ocrResult');
el.innerHTML = `
<div class="or-id">${esc(machineId)} ❌</div>
<div class="or-meta" style="color:var(--amber);">Machine ID not found in database</div>`;
setOcrStatus('Asset not found — try scanning the barcode instead', 'error');
} else {
setOcrStatus('Lookup error: ' + e.message, 'error');
}
}
}
async function ocrImageClient(imageBlob) {
// Timeout race — 30s max for client-side Tesseract.js
const ocrPromise = Tesseract.recognize(imageBlob, 'eng', {
logger: m => updateOcrProgress(m),
});
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Client OCR timed out after 30s')), 30000)
);
let result;
try {
result = await Promise.race([ocrPromise, timeoutPromise]);
} catch (e) {
updateOcrProgress({ status: 'error' });
throw new Error('Client OCR failed: ' + (e.message || 'unknown error'));
}
updateOcrProgress({ status: 'done' });
const text = (result.data && result.data.text) || '';
const parsed = parseMachineId(text.trim());
return {
machine_id: parsed.machine_id,
raw_text: text.trim().slice(0, 500),
confidence: parsed.confidence,
method: 'client',
tesseractConfidence: result.data ? result.data.confidence : null,
};
}
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));
// Stop camera after capturing photo
stopOcrCamera();
setOcrStatus('Processing OCR...', 'working');
document.getElementById('ocrResult').style.display = 'none';
let data = null;
let method = 'server';
// Try server-side OCR first with 8s timeout
try {
const controller = new AbortController();
const serverTimeout = setTimeout(() => controller.abort(), 15000);
const formData = new FormData();
formData.append('file', blob, 'sticker.jpg');
const res = await fetch('/api/ocr', {
method: 'POST',
body: formData,
signal: controller.signal,
headers: AppState.authToken
? { 'Authorization': 'Bearer ' + AppState.authToken }
: {},
});
clearTimeout(serverTimeout);
if (res.ok) {
data = await res.json();
data.method = 'server';
} else {
throw new Error('Server returned ' + res.status);
}
} catch (serverErr) {
// Server failed or timed out — fall back to client-side Tesseract.js
setOcrStatus('Server unavailable — using offline OCR...', 'working');
// Show offline badge in status
const statusEl = document.getElementById('ocrStatus');
if (statusEl) {
statusEl.innerHTML = 'Server unavailable — <span class="offline-badge">📡 Offline OCR</span>';
statusEl.className = 'status-bar working';
}
try {
data = await ocrImageClient(blob);
method = 'client';
} catch (clientErr) {
setOcrStatus('OCR failed: ' + clientErr.message, 'error');
return;
}
}
// Reset status bar innerHTML from offline badge if server succeeded
if (method === 'server') {
setOcrStatus('Processing OCR...', 'working');
}
// Capture device GPS for this photo (fires async — saved to AppState)
if (AppState.gpsLat == null) {
navigator.geolocation.getCurrentPosition(
(pos) => {
AppState.gpsLat = pos.coords.latitude;
AppState.gpsLng = pos.coords.longitude;
AppState.gpsAcc = pos.coords.accuracy;
},
() => {},
{ enableHighAccuracy: true, timeout: 10000 }
);
}
displayOcrResult(data);
}
function openManualCamera() {
// Camera shortcut — EXIF won't survive but quick for field use
const inp = document.createElement('input');
inp.type = 'file';
inp.accept = 'image/*';
inp.capture = 'environment';
inp.style.display = 'none';
inp.onchange = handleManualPhotoFile;
// Must be in DOM for mobile Chrome to allow programmatic click
document.body.appendChild(inp);
inp.click();
// Clean up after file is picked (or cancelled)
inp.addEventListener('cancel', () => inp.remove());
// Fallback: remove after a timeout in case 'cancel' doesn't fire
setTimeout(() => { if (inp.parentNode) inp.remove(); }, 60000);
}
async function handleManualPhotoFile(event) {
const file = event.target.files?.[0];
if (!file) return;
// Clean up any dynamically created camera input
const inp = event.target;
if (inp.id !== 'manPhotoInput' && inp.parentNode) inp.remove();
manualPhotoFile = file;
manualPhotoBlob = file; // Use original file (preserves EXIF)
// Show preview
const url = URL.createObjectURL(file);
document.getElementById('manPhotoPreview').src = url;
document.getElementById('manPhotoPreview').style.display = 'block';
document.getElementById('manPhotoRetake').style.display = 'block';
document.getElementById('manPhotoPlaceholder').style.display = 'none';
// Try EXIF GPS from the original file
manualPhotoGps = await extractGpsFromPhoto(file);
if (manualPhotoGps) {
const coords = manualPhotoGps.lat.toFixed(6) + ', ' + manualPhotoGps.lng.toFixed(6);
const parkingEl = document.getElementById('manParkingLocation');
if (parkingEl && !parkingEl.value.trim()) {
parkingEl.value = coords;
}
const mapLinkEl = document.getElementById('manMapLink');
if (mapLinkEl && !mapLinkEl.value.trim()) {
mapLinkEl.value = `https://www.openstreetmap.org/?mlat=${manualPhotoGps.lat}&mlon=${manualPhotoGps.lng}&zoom=17`;
}
showToast('📍 GPS extracted from photo!');
}
}
function retakeManualPhoto() {
manualPhotoFile = null;
manualPhotoBlob = null;
manualPhotoGps = null;
document.getElementById('manPhotoPreview').style.display = 'none';
document.getElementById('manPhotoRetake').style.display = 'none';
document.getElementById('manPhotoPlaceholder').style.display = '';
document.getElementById('manPhotoInput').value = '';
}
// ═══════════════════════════════════════════════════════════════════════
// MANUAL MODE — Submit
// ═══════════════════════════════════════════════════════════════════════
async function quickCheckinManual() {
if (!AppState.currentAssetId) {
showToast('No asset selected', true);
return;
}
try {
await api('/api/checkins', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
asset_id: AppState.currentAssetId,
latitude: AppState.gpsLat,
longitude: AppState.gpsLng,
accuracy: AppState.gpsAcc,
notes: document.getElementById('manCheckinNotes').value.trim(),
}),
});
showToast('Checked in!');
document.getElementById('manCheckinCard').style.display = 'none';
} catch (e) {
showToast(e.message, true);
}
}
function openMapPin() {
const link = document.getElementById('manMapLink').value.trim();
if (link) window.open(link, '_blank');
else showToast('Enter a map link first', true);
}
function fillGpsParking(inputId) {
if (!AppState.gpsLat) {
showToast('📍 Tap the GPS badge in the header to enable location', true);
// Also trigger the GPS badge click if it exists
const badge = document.getElementById('gpsBadge');
if (badge && badge.onclick) badge.onclick();
return;
}
document.getElementById(inputId).value = AppState.gpsLat.toFixed(6) + ', ' + AppState.gpsLng.toFixed(6);
showToast('📍 GPS coords filled!');
}
// Settings are loaded after auth (in doLogin / initAuth)
// ═══════════════════════════════════════════════════════════════════════
// ASSETS TAB — List, Filter, Detail, Edit, Import
// ═══════════════════════════════════════════════════════════════════════
// ── Category config ───────────────────────────────────────────────────
const CATEGORY_MAP = {
'Bev': { icon: '🥤', color: '#0a7ab5' },
'Snack': { icon: '🍿', color: '#c97d0e' },
'Food': { icon: '🍔', color: '#b15a0a' },
'Other': { icon: '📦', color: '#6b7280' },
'Snack/bev': { icon: '🥤🍿', color: '#9b59b6', label: 'Snack & Bev', wide: true },
'Snack/food': { icon: '🍔🍿', color: '#9b59b6', label: 'Snack & Food', wide: true },
'Food': { icon: '🍔', color: '#d35400' },
'Equipment': { icon: '🔧', color: '#2c3e50' },
'Furniture': { icon: '🪑', color: '#8e44ad' },
'Appliances': { icon: '🔌', color: '#3b82f6' },
'Unknown': { icon: '❓', color: '#6b7280' },
'GF Bev': { icon: '🥤', color: '#0a7ab5' },
};
const PARK_ICONS = {'magic-kingdom':'🏰','epcot':'🌍','hollywood-studios':'🎬','animal-kingdom':'🌿','disney-springs':'🛍️','resort':'🏨','office':'🏢','other':'📍'};
const PARK_NAMES = {'magic-kingdom':'Magic Kingdom','epcot':'Epcot','hollywood-studios':'Hollywood Studios','animal-kingdom':'Animal Kingdom','disney-springs':'Disney Springs','resort':'Resort','office':'Office','other':'Other'};
function catLabel(cat) {
const e = CATEGORY_MAP[cat];
return e && e.label ? e.label : (cat || 'Other');
}
function parkIcon(key) { return PARK_ICONS[key] || '📍'; }
function parkName(key) { return PARK_NAMES[key] || key; }
// ── Disney Park Outline Polygons ───────────────────────────────────────
const DISNEY_PARK_OUTLINES = {
'magic-kingdom': {
name: 'Magic Kingdom',
color: '#c2410c',
icon: '🏰',
// Accurate boundary from OSM (way 297428432, simplified)
coords: [
[28.42160, -81.57667], [28.42219, -81.57734], [28.42246, -81.57747],
[28.42274, -81.57777], [28.42302, -81.57803], [28.42297, -81.57930],
[28.42256, -81.57929], [28.42266, -81.57989], [28.42270, -81.58054],
[28.42269, -81.58085], [28.42267, -81.58273], [28.42272, -81.58321],
[28.42298, -81.58365], [28.42325, -81.58403], [28.42328, -81.58422],
[28.42326, -81.58452], [28.42308, -81.58478], [28.42257, -81.58527],
[28.42153, -81.58584], [28.42098, -81.58609], [28.42072, -81.58628],
[28.42011, -81.58657], [28.41987, -81.58682], [28.41928, -81.58700],
[28.41771, -81.58699], [28.41562, -81.58696], [28.41490, -81.58696],
[28.41522, -81.58656], [28.41582, -81.58561], [28.41617, -81.58490],
[28.41630, -81.58405], [28.41625, -81.58321], [28.41621, -81.58284],
[28.41632, -81.58231], [28.41623, -81.58177], [28.41614, -81.58123],
[28.41614, -81.58104], [28.41610, -81.58093], [28.41615, -81.58077],
[28.41615, -81.58056], [28.41618, -81.58037], [28.41631, -81.58010],
[28.41646, -81.57957], [28.41631, -81.57936], [28.41633, -81.57914],
[28.41666, -81.57804], [28.41701, -81.57736], [28.41756, -81.57715],
[28.41802, -81.57693], [28.41866, -81.57663], [28.41917, -81.57639],
[28.41947, -81.57625], [28.41982, -81.57599], [28.42038, -81.57586],
[28.42086, -81.57600], [28.42105, -81.57615], [28.42160, -81.57667]
]
},
'epcot': {
name: 'Epcot',
color: '#0284c7',
icon: '🌍',
coords: [
[28.3793, -81.5530], [28.3800, -81.5475], [28.3770, -81.5455],
[28.3735, -81.5448], [28.3700, -81.5460], [28.3680, -81.5490],
[28.3690, -81.5530], [28.3720, -81.5550], [28.3755, -81.5548],
[28.3793, -81.5530]
]
},
'hollywood-studios': {
name: 'Hollywood Studios',
color: '#ca8a04',
icon: '🎬',
coords: [
[28.3615, -81.5625], [28.3625, -81.5560], [28.3590, -81.5545],
[28.3550, -81.5548], [28.3526, -81.5578], [28.3530, -81.5620],
[28.3560, -81.5640], [28.3595, -81.5638], [28.3615, -81.5625]
]
},
'animal-kingdom': {
name: 'Animal Kingdom',
color: '#15803d',
icon: '🌿',
coords: [
[28.3635, -81.5955], [28.3640, -81.5870], [28.3600, -81.5850],
[28.3550, -81.5855], [28.3515, -81.5880], [28.3510, -81.5920],
[28.3530, -81.5965], [28.3580, -81.5985], [28.3635, -81.5955]
]
},
'disney-springs': {
name: 'Disney Springs',
color: '#be185d',
icon: '🛍️',
coords: [
[28.3740, -81.5255], [28.3745, -81.5175], [28.3710, -81.5155],
[28.3665, -81.5170], [28.3655, -81.5215], [28.3670, -81.5255],
[28.3705, -81.5270], [28.3740, -81.5255]
]
},
'grand-floridian-resort': {
name: 'Grand Floridian Resort',
color: '#1e40af',
icon: '🏨',
coords: [[28.4139, -81.5903], [28.4139, -81.5858], [28.4124, -81.5843], [28.4104, -81.5843], [28.4089, -81.5858], [28.4089, -81.5903], [28.4104, -81.5908], [28.4124, -81.5908], [28.4139, -81.5903]]
},
'polynesian-village-resort': {
name: 'Polynesian Village Resort',
color: '#2563eb',
icon: '🏨',
coords: [[28.4072, -81.5868], [28.4072, -81.5823], [28.4057, -81.5808], [28.4037, -81.5808], [28.4022, -81.5823], [28.4022, -81.5868], [28.4037, -81.5873], [28.4057, -81.5873], [28.4072, -81.5868]]
},
'contemporary-resort': {
name: 'Contemporary Resort',
color: '#3b82f6',
icon: '🏨',
coords: [[28.4175, -81.5775], [28.4175, -81.5730], [28.4160, -81.5715], [28.4140, -81.5715], [28.4125, -81.5730], [28.4125, -81.5775], [28.4140, -81.5780], [28.4160, -81.5780], [28.4175, -81.5775]]
},
'wilderness-lodge': {
name: 'Wilderness Lodge',
color: '#4f46e5',
icon: '🏨',
coords: [[28.4110, -81.5810], [28.4110, -81.5765], [28.4095, -81.5750], [28.4075, -81.5750], [28.4060, -81.5765], [28.4060, -81.5810], [28.4075, -81.5815], [28.4095, -81.5815], [28.4110, -81.5810]]
},
'fort-wilderness': {
name: 'Fort Wilderness',
color: '#6366f1',
icon: '🏨',
coords: [[28.4045, -81.5740], [28.4045, -81.5695], [28.4030, -81.5680], [28.4010, -81.5680], [28.3995, -81.5695], [28.3995, -81.5740], [28.4010, -81.5745], [28.4030, -81.5745], [28.4045, -81.5740]]
},
'yacht-and-beach-club': {
name: 'Yacht & Beach Club',
color: '#1d4ed8',
icon: '🏨',
coords: [[28.3739, -81.5612], [28.3739, -81.5567], [28.3724, -81.5552], [28.3704, -81.5552], [28.3689, -81.5567], [28.3689, -81.5612], [28.3704, -81.5617], [28.3724, -81.5617], [28.3739, -81.5612]]
},
'boardwalk-inn': {
name: 'BoardWalk Inn',
color: '#3730a3',
icon: '🏨',
coords: [[28.3690, -81.5580], [28.3690, -81.5535], [28.3675, -81.5520], [28.3655, -81.5520], [28.3640, -81.5535], [28.3640, -81.5580], [28.3655, -81.5585], [28.3675, -81.5585], [28.3690, -81.5580]]
},
'animal-kingdom-lodge': {
name: 'Animal Kingdom Lodge',
color: '#2563eb',
icon: '🏨',
coords: [[28.3570, -81.5938], [28.3570, -81.5893], [28.3555, -81.5878], [28.3535, -81.5878], [28.3520, -81.5893], [28.3520, -81.5938], [28.3535, -81.5943], [28.3555, -81.5943], [28.3570, -81.5938]]
},
'saratoga-springs': {
name: 'Saratoga Springs',
color: '#3b82f6',
icon: '🏨',
coords: [[28.3725, -81.5230], [28.3725, -81.5185], [28.3710, -81.5170], [28.3690, -81.5170], [28.3675, -81.5185], [28.3675, -81.5230], [28.3690, -81.5235], [28.3710, -81.5235], [28.3725, -81.5230]]
},
'port-orleans-resort': {
name: 'Port Orleans Resort',
color: '#4f46e5',
icon: '🏨',
coords: [[28.3805, -81.5180], [28.3805, -81.5135], [28.3790, -81.5120], [28.3770, -81.5120], [28.3755, -81.5135], [28.3755, -81.5180], [28.3770, -81.5185], [28.3790, -81.5185], [28.3805, -81.5180]]
},
'old-key-west': {
name: 'Old Key West',
color: '#1e40af',
icon: '🏨',
coords: [[28.3675, -81.5160], [28.3675, -81.5115], [28.3660, -81.5100], [28.3640, -81.5100], [28.3625, -81.5115], [28.3625, -81.5160], [28.3640, -81.5165], [28.3660, -81.5165], [28.3675, -81.5160]]
},
'coronado-springs': {
name: 'Coronado Springs',
color: '#2563eb',
icon: '🏨',
coords: [[28.3635, -81.5775], [28.3635, -81.5730], [28.3620, -81.5715], [28.3600, -81.5715], [28.3585, -81.5730], [28.3585, -81.5775], [28.3600, -81.5780], [28.3620, -81.5780], [28.3635, -81.5775]]
},
'caribbean-beach': {
name: 'Caribbean Beach',
color: '#3b82f6',
icon: '🏨',
coords: [[28.3585, -81.5540], [28.3585, -81.5495], [28.3570, -81.5480], [28.3550, -81.5480], [28.3535, -81.5495], [28.3535, -81.5540], [28.3550, -81.5545], [28.3570, -81.5545], [28.3585, -81.5540]]
},
'pop-century---art-of-animation': {
name: 'Pop Century / Art of Animation',
color: '#6366f1',
icon: '🏨',
coords: [[28.3505, -81.5535], [28.3505, -81.5490], [28.3490, -81.5475], [28.3470, -81.5475], [28.3455, -81.5490], [28.3455, -81.5535], [28.3470, -81.5540], [28.3490, -81.5540], [28.3505, -81.5535]]
},
'all-star-resorts': {
name: 'All-Star Resorts',
color: '#1d4ed8',
icon: '🏨',
coords: [[28.3485, -81.5735], [28.3485, -81.5690], [28.3470, -81.5675], [28.3450, -81.5675], [28.3435, -81.5690], [28.3435, -81.5735], [28.3450, -81.5740], [28.3470, -81.5740], [28.3485, -81.5735]]
},
};
// Park outline state
let parkOutlinesVisible = true;
let parkOutlineGroup = null;
function drawParkOutlines() {
if (!map) return;
if (parkOutlineGroup) map.removeLayer(parkOutlineGroup);
parkOutlineGroup = L.featureGroup();
Object.entries(DISNEY_PARK_OUTLINES).forEach(([key, park]) => {
const polygon = L.polygon(park.coords, {
color: park.color,
weight: 5,
opacity: 1,
fillColor: park.color,
fillOpacity: 0.25,
dashArray: ['magic-kingdom','epcot','hollywood-studios','animal-kingdom','disney-springs'].includes(key) ? null : '6, 3',
}).addTo(parkOutlineGroup);
// Add a label at the polygon center
const bounds = polygon.getBounds();
const center = bounds.getCenter();
const label = L.marker(center, {
icon: L.divIcon({
className: 'park-label-icon',
html: '<div style="background:' + park.color + '22; color:' + park.color +
'; border:1px solid ' + park.color + '44; border-radius:6px; padding:3px 10px;' +
' font-size:13px; font-weight:700; white-space:normal; max-width:140px; text-align:center;' +
' text-shadow:0 1px 2px rgba(0,0,0,0.8);">' +
park.icon + ' ' + park.name + '</div>',
iconSize: [0, 0],
iconAnchor: [0, 0],
}),
interactive: false,
}).addTo(parkOutlineGroup);
});
parkOutlineGroup.addTo(map);
}
function toggleParkOutlines() {
parkOutlinesVisible = !parkOutlinesVisible;
const chip = document.getElementById('chipParkOutlines');
if (parkOutlinesVisible) {
chip.classList.add('park-on');
chip.textContent = '🏰 Parks ON';
drawParkOutlines();
} else {
chip.classList.remove('park-on');
chip.textContent = '🏰 Parks';
if (parkOutlineGroup) map.removeLayer(parkOutlineGroup);
parkOutlineGroup = null;
}
}
// ── Filter state ──────────────────────────────────────────────────────
let assetFilters = { category: null, status: null, make: null, disney_park: null, disney_filter: null, q: '',
customer_id: null, location_id: null, assigned_to: null, branch: null };
let noDexFilterActive = false;
let assetOffset = 0;
const ASSET_PAGE_SIZE = 2000;
let assetTotal = 0;
let assetDebounce = null;
let cachedAssets = [];
let filterDropdownsLoaded = false;
function toggleFilterPanel() {
const panel = document.getElementById('filterPanel');
const toggle = document.getElementById('filterToggle');
panel.classList.toggle('open');
toggle.classList.toggle('active', panel.classList.contains('open'));
if (panel.classList.contains('open') && !filterDropdownsLoaded) {
loadFilterDropdowns();
}
}
async function loadFilterDropdowns() {
if (filterDropdownsLoaded) return;
filterDropdownsLoaded = true;
var filterPanel = document.getElementById('filterPanel');
var loadingMsg = document.createElement('div');
loadingMsg.className = 'loading-spinner';
loadingMsg.innerHTML = '<span class="spinner"></span> Loading filters...';
filterPanel.appendChild(loadingMsg);
try {
// Load categories
var cats = await api('/api/categories');
var catSel = document.getElementById('filterCategory');
catSel.innerHTML = '<option value="">All categories</option>' +
cats.map(function(c) { return '<option value="' + esc(c) + '">' + esc(c) + '</option>'; }).join('');
// Load makes
var makes = await api('/api/makes');
var makeSel = document.getElementById('filterMake');
makeSel.innerHTML = '<option value="">All makes</option>' +
makes.map(function(m) { return '<option value="' + esc(m) + '">' + esc(m) + '</option>'; }).join('');
// Load customers
var customers = await api('/api/customers');
var custSel = document.getElementById('filterCustomer');
custSel.innerHTML = '<option value="">All customers</option>' +
customers.map(function(c) { return '<option value="' + c.id + '">' + esc(c.name) + '</option>'; }).join('');
// Load users for assigned_to filter
var users = await api('/api/users');
var assnSel = document.getElementById('filterAssignedTo');
assnSel.innerHTML = '<option value="">Anyone</option>' +
users.filter(function(u) { return u.role === 'technician' || u.role === 'admin'; })
.map(function(u) { return '<option value="' + esc(u.username) + '">' + esc(u.username) + '</option>'; }).join('');
// Load branches
var branches = await api('/api/branches');
var branchSel = document.getElementById('filterBranch');
branchSel.innerHTML = '<option value="">All branches</option>' +
branches.branches.map(function(b) { return '<option value="' + esc(b.name) + '">' + esc(b.name) + ' (' + b.machine_count + ')</option>'; }).join('');
} catch (e) {
showToast('Failed to load filter options', true);
} finally {
if (loadingMsg && loadingMsg.parentNode) loadingMsg.parentNode.removeChild(loadingMsg);
}
}
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';
renderActiveFilterTags();
clearTimeout(assetDebounce);
assetDebounce = setTimeout(_loadAssets, 200);
}
function clearAssetSearch() {
document.getElementById('assetSearch').value = '';
assetFilters.q = '';
document.getElementById('clearSearch').style.display = 'none';
assetOffset = 0;
loadAssets();
}
function onFilterChange() {
var catEl = document.getElementById('filterCategory');
var makeEl = document.getElementById('filterMake');
var disneyFilterEl = document.getElementById('filterDisneyFilter');
var parkEl = document.getElementById('filterDisneyPark');
var custEl = document.getElementById('filterCustomer');
var locEl = document.getElementById('filterLocation');
var assnEl = document.getElementById('filterAssignedTo');
var branchEl = document.getElementById('filterBranch');
assetFilters.category = catEl.value || null;
assetFilters.make = makeEl.value || null;
// Location Type (Disney/Non-Disney) and specific park are mutually exclusive
var disneyFilterVal = disneyFilterEl.value;
if (disneyFilterVal) {
assetFilters.disney_filter = disneyFilterVal;
assetFilters.disney_park = null;
} else {
var parkVal = parkEl.value;
assetFilters.disney_park = parkVal || null;
assetFilters.disney_filter = null;
}
assetFilters.customer_id = custEl.value || null;
assetFilters.assigned_to = assnEl.value || null;
assetFilters.branch = branchEl.value || null;
var prevCust = locEl.dataset.customerId;
if (custEl.value !== prevCust) {
locEl.innerHTML = '<option value="">All locations</option>';
locEl.disabled = true;
assetFilters.location_id = null;
if (custEl.value) {
loadLocationsForCustomer(custEl.value);
}
} else {
assetFilters.location_id = locEl.value || null;
}
locEl.dataset.customerId = custEl.value;
assetOffset = 0;
renderActiveFilterTags();
_loadAssets();
}
async function loadLocationsForCustomer(customerId) {
try {
var locations = await api('/api/locations?customer_id=' + customerId);
var locEl = document.getElementById('filterLocation');
locEl.innerHTML = '<option value="">All locations</option>' +
locations.map(function(l) { return '<option value="' + l.id + '">' + esc(l.name) + '</option>'; }).join('');
locEl.disabled = false;
} catch (e) { showToast('Failed to load locations', true); }
}
function getActiveFilterCount() {
var count = 0;
if (assetFilters.category) count++;
if (assetFilters.make) count++;
if (assetFilters.disney_park) count++;
if (assetFilters.disney_filter) count++;
if (assetFilters.customer_id) count++;
if (assetFilters.location_id) count++;
if (assetFilters.assigned_to) count++;
if (assetFilters.branch) count++;
if (noDexFilterActive) count++;
if (assetFilters.q) count++;
return count;
}
function renderActiveFilterTags() {
var el = document.getElementById('activeFilters');
var countEl = document.getElementById('filterCount');
var toggle = document.getElementById('filterToggle');
var count = getActiveFilterCount();
countEl.textContent = count;
countEl.style.display = count > 0 ? 'inline' : 'none';
if (toggle) toggle.classList.toggle('active', count > 0);
var tags = [];
if (assetFilters.q) tags.push({key:'q', label:'\uD83D\uDD0D "' + assetFilters.q + '"'});
if (assetFilters.category) tags.push({key:'category', label:'\uD83D\uDCC2 ' + assetFilters.category});
if (assetFilters.make) tags.push({key:'make', label:'\uD83C\uDFED ' + assetFilters.make});
if (assetFilters.disney_park) tags.push({key:'disney_park', label: parkIcon(assetFilters.disney_park) + ' ' + parkName(assetFilters.disney_park)});
if (assetFilters.disney_filter === 'disney') tags.push({key:'disney_filter', label:'🏰 Disney only'});
if (assetFilters.disney_filter === 'non_disney') tags.push({key:'disney_filter', label:'🏢 Non-Disney'});
if (assetFilters.customer_id) {
var custSel = document.getElementById('filterCustomer');
var txt = custSel.options[custSel.selectedIndex] ? custSel.options[custSel.selectedIndex].text : 'Customer';
tags.push({key:'customer_id', label:'\uD83C\uDFE2 ' + txt});
}
if (assetFilters.location_id) {
var locSel = document.getElementById('filterLocation');
var txt = locSel.options[locSel.selectedIndex] ? locSel.options[locSel.selectedIndex].text : 'Location';
tags.push({key:'location_id', label:'📍 ' + txt});
}
if (assetFilters.assigned_to) tags.push({key:'assigned_to', label:'👤 ' + assetFilters.assigned_to});
if (assetFilters.branch) tags.push({key:'branch', label:'🏢 ' + assetFilters.branch});
if (noDexFilterActive) tags.push({key:'no_dex_days', label:'⚠️ No DEX (5d)'});
el.innerHTML = tags.map(function(t) {
return '<span class="filter-tag">' + esc(t.label) + ' <span class="ft-remove" onclick="removeFilter(\'' + t.key + '\')">\u2715</span></span>';
}).join('');
}
function statusIcon(s) {
if (s === 'active') return '\uD83D\uDFE2';
if (s === 'maintenance') return '\uD83D\uDFE1';
if (s === 'retired') return '\uD83D\uDD34';
return '';
}
function removeFilter(key) {
if (key === 'q') {
document.getElementById('assetSearch').value = '';
assetFilters.q = '';
document.getElementById('clearSearch').style.display = 'none';
} else if (key === 'category') {
document.getElementById('filterCategory').value = '';
assetFilters.category = null;
} else if (key === 'make') {
document.getElementById('filterMake').value = '';
assetFilters.make = null;
} else if (key === 'disney_park') {
document.getElementById('filterDisneyPark').value = '';
assetFilters.disney_park = null;
} else if (key === 'disney_filter') {
document.getElementById('filterDisneyFilter').value = '';
assetFilters.disney_filter = null;
} else if (key === 'customer_id') {
document.getElementById('filterCustomer').value = '';
document.getElementById('filterLocation').innerHTML = '<option value="">All locations</option>';
document.getElementById('filterLocation').disabled = true;
assetFilters.customer_id = null;
assetFilters.location_id = null;
} else if (key === 'location_id') {
document.getElementById('filterLocation').value = '';
assetFilters.location_id = null;
} else if (key === 'assigned_to') {
document.getElementById('filterAssignedTo').value = '';
assetFilters.assigned_to = null;
} else if (key === 'branch') {
document.getElementById('filterBranch').value = '';
assetFilters.branch = null;
} else if (key === 'no_dex_days') {
noDexFilterActive = false;
var btn = document.getElementById('filterNoDex');
if (btn) btn.classList.remove('active');
}
renderActiveFilterTags();
assetOffset = 0;
_loadAssets();
}
function clearAllFilters() {
assetFilters = { category: null, status: null, make: null, disney_park: null, disney_filter: null, q: '',
customer_id: null, location_id: null, assigned_to: null, branch: null };
document.getElementById('assetSearch').value = '';
document.getElementById('clearSearch').style.display = 'none';
document.getElementById('filterCategory').value = '';
document.getElementById('filterMake').value = '';
document.getElementById('filterDisneyPark').value = '';
document.getElementById('filterDisneyFilter').value = '';
document.getElementById('filterCustomer').value = '';
document.getElementById('filterLocation').innerHTML = '<option value="">All locations</option>';
document.getElementById('filterLocation').disabled = true;
document.getElementById('filterAssignedTo').value = '';
document.getElementById('filterBranch').value = '';
noDexFilterActive = false;
var noDexBtn = document.getElementById('filterNoDex');
if (noDexBtn) noDexBtn.classList.remove('active');
renderActiveFilterTags();
assetOffset = 0;
_loadAssets();
}
function toggleNoDexFilter() {
noDexFilterActive = !noDexFilterActive;
var btn = document.getElementById('filterNoDex');
if (btn) btn.classList.toggle('active', noDexFilterActive);
renderActiveFilterTags();
assetOffset = 0;
_loadAssets();
}
// _loadAssets with all filters
var __loadAssets_orig = null;
_loadAssets = async function() {
var assetListEl = document.getElementById('assetList');
var countEl = document.getElementById('resultCount');
assetListEl.innerHTML = '<div class="loading-spinner"><span class="spinner"></span> Loading assets...</div>';
if (countEl) countEl.textContent = '';
var params = new URLSearchParams();
if (assetFilters.category) params.set('category', assetFilters.category);
if (assetFilters.make) params.set('make', assetFilters.make);
if (assetFilters.disney_park) params.set('disney_park', assetFilters.disney_park);
if (assetFilters.disney_filter) params.set('disney_filter', assetFilters.disney_filter);
if (assetFilters.q) params.set('q', assetFilters.q);
if (assetFilters.customer_id) params.set('customer_id', assetFilters.customer_id);
if (assetFilters.location_id) params.set('location_id', assetFilters.location_id);
if (assetFilters.assigned_to) params.set('assigned_to', assetFilters.assigned_to);
if (assetFilters.branch) params.set('branch', assetFilters.branch);
if (noDexFilterActive) params.set('no_dex_days', '5');
params.set('limit', String(ASSET_PAGE_SIZE));
params.set('offset', String(assetOffset));
try {
var result = await api('/api/assets?' + params.toString(), {_returnMeta: true});
var assets = result.data;
assetTotal = parseInt(result.headers.get('X-Total-Count')) || assets.length;
renderAssetList(assets);
renderPagination();
if (countEl) countEl.textContent = assetTotal + ' asset' + (assetTotal !== 1 ? 's' : '') + ' found';
} catch (e) {
document.getElementById('assetList').innerHTML =
'<div class="empty-state"><div class="es-icon">⚠️</div><strong>Failed to load assets</strong><br>Check your connection and try again</div>';
showToast('Failed to load assets — ' + (e.message || 'connection error'), true);
}
};
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 — try clearing filters or scan a new asset</div>';
return;
}
el.innerHTML = assets.filter(a => a.status !== 'retired').map(a => {
const catEntry = CATEGORY_MAP[a.category];
const icon = catEntry ? catEntry.icon : (a.category ? a.category.charAt(0).toUpperCase() : '📦');
const catColor = catEntry ? catEntry.color : '#1a1b26';
const iconWide = catEntry && catEntry.wide ? ' ai-icon-wide' : '';
return `
<div class="asset-item" onclick="viewAsset(${a.id})">
<div class="ai-icon${iconWide}" style="background:${catColor}22;color:${catColor}">${icon}</div>
<div class="ai-info">
${renderAssetCardContent(a)}
<div class="ai-meta">
${esc(a.machine_id)}${a.serial_number ? ' · S/N: ' + esc(a.serial_number) : ''}
${a.make ? ' · ' + esc(a.make) : ''}
${a.disney_park ? ' · <span class="disney-tag">' + parkIcon(a.disney_park) + ' ' + parkName(a.disney_park) + '</span>' : ''}
· ${esc(catLabel(a.category))}
</div>
</div>
<div class="ai-arrow"></div>
</div>`;
}).join('');
}
function renderAssetCardContent(a) {
const parts = [];
const company = a.company || a.name;
parts.push(`<div class="ai-company">${esc(company)}</div>`);
const locParts = [];
if (a.location_area) locParts.push(`<span class="tag">${esc(a.location_area)}</span>`);
if (a.place) locParts.push(esc(a.place));
if (locParts.length) parts.push(`<div class="ai-location-line">${locParts.join(' · ')}</div>`);
const bldParts = [];
if (a.building_name) bldParts.push(esc(a.building_name));
if (a.building_number && !a.building_name?.includes(a.building_number)) bldParts.push(`Bldg ${esc(a.building_number)}`);
if (a.trailer_number) bldParts.push(`Trailer ${esc(a.trailer_number)}`);
if (a.floor) bldParts.push(`Floor ${esc(a.floor)}`);
if (a.room) bldParts.push(`Room ${esc(a.room)}`);
if (bldParts.length) parts.push(`<div class="ai-building">${bldParts.join(' · ')}</div>`);
if (a.address) parts.push(`<div class="ai-address">📍 ${esc(a.address)}</div>`);
return parts.join('');
}
function setFilter(category, status, make, disney_park) {
assetFilters.category = category;
assetFilters.status = status;
assetFilters.make = make;
assetFilters.disney_park = disney_park;
assetFilters.disney_filter = null;
// Sync dropdowns
if (document.getElementById('filterCategory')) document.getElementById('filterCategory').value = category || '';
if (document.getElementById('filterMake')) document.getElementById('filterMake').value = make || '';
if (document.getElementById('filterDisneyPark')) document.getElementById('filterDisneyPark').value = disney_park || '';
renderActiveFilterTags();
assetOffset = 0;
_loadAssets();
}
// ── Pagination ──────────────────────────────────────────────────────────
function renderPagination() {
const el = document.getElementById('pagination');
if (assetTotal <= ASSET_PAGE_SIZE) {
el.style.display = 'none';
return;
}
el.style.display = 'flex';
const current = assetOffset + 1;
const end = Math.min(assetOffset + ASSET_PAGE_SIZE, assetTotal);
el.innerHTML =
'<span style="font-size:12px;color:var(--text2);margin-right:auto;">' +
current + '' + end + ' of ' + assetTotal + '</span>' +
'<button class="btn btn-outline btn-xs" onclick="prevPage()" ' + (assetOffset === 0 ? 'disabled' : '') + '> Prev</button>' +
'<button class="btn btn-outline btn-xs" onclick="nextPage()" ' + (end >= assetTotal ? 'disabled' : '') + '>Next </button>';
}
function prevPage() {
if (assetOffset > 0) {
assetOffset = Math.max(0, assetOffset - ASSET_PAGE_SIZE);
_loadAssets();
}
}
function nextPage() {
if (assetOffset + ASSET_PAGE_SIZE < assetTotal) {
assetOffset += ASSET_PAGE_SIZE;
_loadAssets();
}
}
// ── View switching ────────────────────────────────────────────────────
function hideAllAssetViews() {
['assetsListView','assetsDetailView'].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';
}
// ── Detail view ───────────────────────────────────────────────────────
function nvl(v, fallback) { return (v !== null && v !== undefined && v !== '') ? v : fallback; }
async function viewAsset(id) {
AppState.currentAssetId = id;
// Show loading state in detail view
showDetailView();
document.getElementById('detailName').textContent = 'Loading...';
document.getElementById('detailMeta').innerHTML = '';
document.getElementById('detailFields').innerHTML = '<div class="loading-spinner"><span class="spinner"></span> Loading asset details...</div>';
try {
const a = await api('/api/assets/' + id);
document.getElementById('detailName').textContent = a.name;
document.getElementById('detailMeta').innerHTML =
`${esc(a.machine_id)}${a.disney_park ? ' · <span class="disney-tag">' + parkIcon(a.disney_park) + ' ' + parkName(a.disney_park) + '</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('Telemetry ID', a.description),
df('Category', catLabel(a.category)),
df('Make', a.make),
df('Model', a.model),
df('Last Dex Report', a.dex_report_date || '— No report yet', true),
// Location fields (shown in card but missing from details)
df('Place', a.place),
df('Building', a.building_name),
df('Building #', a.building_number),
df('Floor', a.floor),
df('Room', a.room),
df('Trailer', a.trailer_number),
].filter(Boolean).join('');
// Disney Park badge
if (a.disney_park) {
const parkIcons = {'magic-kingdom':'🏰','epcot':'🌍','hollywood-studios':'🎬','animal-kingdom':'🌿','disney-springs':'🛍️','resort':'🏨','office':'🏢','other':'📍'};
const parkNames = {'magic-kingdom':'Magic Kingdom','epcot':'Epcot','hollywood-studios':'Hollywood Studios','animal-kingdom':'Animal Kingdom','disney-springs':'Disney Springs','resort':'Resort','office':'Office','other':'Other'};
const parkColors = {'magic-kingdom':'#8b5cf6','epcot':'#06b6d4','hollywood-studios':'#f59e0b','animal-kingdom':'#22c55e','disney-springs':'#ec4899','resort':'#3b82f6','office':'#6b7280','other':'#95a5a6'};
const icon = parkIcons[a.disney_park] || '📍';
const name = parkNames[a.disney_park] || a.disney_park;
const color = parkColors[a.disney_park] || '#95a5a6';
document.getElementById('detailFields').innerHTML +=
'<div class="detail-field" style="border:0;padding:4px 0">' +
'<div class="df-label">Disney Park</div>' +
'<span id="disneyParkBadge" style="display:inline-flex;align-items:center;gap:6px;background:' + color + '22;color:' + color + ';border:1px solid ' + color + '44;border-radius:6px;padding:4px 10px;font-size:13px;font-weight:600;cursor:pointer" onclick="changeDisneyPark(' + a.id + ')">' +
icon + ' ' + esc(name) + ' ✏️</span></div>';
}
// Customer / Location / Technician
if (a.customer_name) {
document.getElementById('detailFields').innerHTML += df('Customer',
esc(a.customer_name));
}
if (a.location_name) {
document.getElementById('detailFields').innerHTML += df('Location',
esc(a.location_name));
}
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));
// Photo
const photoCard = document.getElementById('detailPhotoCard');
const photoImg = document.getElementById('detailPhotoImg');
if (a.photo_path) {
photoCard.style.display = 'block';
photoImg.src = a.photo_path;
photoImg.onload = () => { photoImg.style.display = 'inline'; };
photoImg.onerror = () => { photoCard.style.display = 'none'; };
} else {
photoCard.style.display = 'none';
}
// Location / Directions
const hasDir = a.address || a.building_name || a.building_number || a.floor || a.room || a.trailer_number || 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('Trailer', a.trailer_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';
}
// Service Entrances
await loadServiceEntrances(a);
// MSFS Data (Dynamics 365 enrichment)
const msfsCard = document.getElementById('detailMsfsCard');
const msfsFields = document.getElementById('detailMsfsFields');
if (a.msfs) {
const m = a.msfs;
const msfsRows = [
df('Equipment ID', m.equipment_id, true),
df('Connect ID', m.connect_id, true),
df('Compass #', m.compass_asset_number, true),
df('Manufacturer', m.manufacturer),
df('Model', m.model_text),
df('DEX Model', m.dex_model),
df('Canteen Connect GUID', m.canteen_connect_guid, true),
df('Account Name', m.account_name),
df('Install Date', m.install_date),
df('Manufacture Date', m.manufacture_date),
df('Software Version', m.software_version, true),
df('Work Orders', m.work_order_count ? `${m.work_order_count} work orders` : null),
].filter(Boolean).join('');
if (msfsRows) {
msfsFields.innerHTML = msfsRows;
msfsCard.style.display = 'block';
} else {
msfsCard.style.display = 'none';
}
} else {
msfsCard.style.display = 'none';
}
// Seed Data (Cantaloupe/mycantaloupe.com enrichment)
const seedCard = document.getElementById('detailSeedCard');
const seedFields = document.getElementById('detailSeedFields');
if (a.seed) {
const s = a.seed;
// Define which Seed fields to show and in what order
const seedRows = [
df('Device', s.Device, true),
df('Type', s.Type),
df('Class', s.Class),
df('Asset Family', s['Asset Family']),
df('Customer', s.Customer),
df('Management Company', s['Management Company']),
df('Branch', s.Branch),
df('Route', s.Route),
df('Subroute', s.Subroute),
df('Location', s.Location),
df('Location Code', s['Location Code']),
df('Customer Code', s['Customer Code']),
df('Place', s.Place),
df('Barcode', s.Barcode, true),
df('Serial #', s['Serial Number'], true),
df('Valid Address', s['Valid Address']),
df('Business Type', s['Business Type']),
df('Primary Consumer', s['Primary Consumer Type']),
df('Machine Branding', s['Machine Branding']),
df('Has Cashless', s['Has Cashless'] ? '✅ Yes' : '❌ No'),
df('Non-Revenue', s['Non-Revenue']),
df('Phone', s.Phone),
df('Email', s.Email),
df('Fax', s.Fax),
df('Alerts', s.Alerts),
df('Coil Alerts', s['Coil Alerts']),
df('Product Alerts', s['Product Alerts']),
df('Daily Avg Sales', s['Daily Average Sales'] != null ? '$' + parseFloat(s['Daily Average Sales']).toFixed(2) : null),
df('Monthly Sales', s['Monthly Sales'] != null ? '$' + parseFloat(s['Monthly Sales']).toFixed(2) : null),
df('Yearly Sales', s['Yearly Sales'] != null ? '$' + parseFloat(s['Yearly Sales']).toFixed(2) : null),
df('Last Restock', s['Last Restock']),
df('Days Since Restock', s['Days Since Restock']),
df('Last Contact', s['Last Contact Time']),
df('Last DEX Report', s['Last Dex Report Time']),
df('Last Inventory', s['Last Inventory']),
df('Prepick Group', s['Prepick Group']),
df('Added Date', s['Added Date']),
df('Status', s.Status),
df('Make', s.Make),
df('Model', s.Model),
df('State', s.State),
df('Postal Code', s['Postal Code']),
].filter(Boolean).join('');
if (seedRows) {
seedFields.innerHTML = seedRows;
seedCard.style.display = 'block';
} else {
seedCard.style.display = 'none';
}
} else {
seedCard.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 '';
}
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(() => {});
}
// ── Photo viewer ──────────────────────────────────────────────────
function openPhotoModal() {
const img = document.getElementById('detailPhotoImg');
const modal = document.getElementById('photoModal');
const modalImg = document.getElementById('photoModalImg');
if (!img || !modal || !modalImg) return;
modalImg.src = img.src;
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
}
function closePhotoModal(event) {
if (event && event.target && event.target.id !== 'photoModal' && event.target.id !== 'photoModalImg' && event.target.tagName !== 'SPAN') return;
const modal = document.getElementById('photoModal');
if (modal) {
modal.style.display = 'none';
document.body.style.overflow = '';
}
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
const modal = document.getElementById('photoModal');
if (modal && modal.style.display === 'flex') {
modal.style.display = 'none';
document.body.style.overflow = '';
}
}
});
async function navigateToAsset(mode) {
mode = mode || 'driving';
if (!AppState.currentAssetId) {
updateNavSetup();
showToast('Select an asset first', true);
return;
}
if (!AppState.gpsLat || !AppState.gpsLng) {
updateNavSetup();
showToast('GPS location not available. Tap 📍 Get My Location.', true);
return;
}
try {
const nav = await api('/api/assets/' + AppState.currentAssetId +
'/navigation?lat=' + AppState.gpsLat + '&lng=' + AppState.gpsLng + '&mode=' + mode);
// If OSRM route_coords are available, fetch OSRM route for this mode
if (nav.asset_lat && nav.asset_lng) {
try {
const osrmProfile = mode === 'walking' ? 'foot' : 'driving';
const osrmUrl = 'https://router.project-osrm.org/route/v1/' + osrmProfile +
'/' + AppState.gpsLng + ',' + AppState.gpsLat +
';' + nav.asset_lng + ',' + nav.asset_lat +
'?overview=full&geometries=geojson&steps=true&alternatives=false';
const osrmResp = await fetch(osrmUrl);
const osrmData = await osrmResp.json();
if (osrmData && osrmData.routes && osrmData.routes[0]) {
const route = osrmData.routes[0];
nav.route_coords = route.geometry.coordinates.map(c => [c[1], c[0]]);
nav.route_duration_s = route.duration;
nav._osrm_route = route;
}
} catch (osrmErr) {
console.warn('OSRM route fetch failed, using straight line:', osrmErr);
}
}
// 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;
if (!nav) return;
// 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 route_coords if available, else straight line
if (nav.route_coords && nav.route_coords.length > 1) {
L.polyline(nav.route_coords, {
color: '#5b6ef7', weight: 4, opacity: 0.8,
}).addTo(navMap);
} else {
L.polyline([[nav.origin_lat, nav.origin_lng], [nav.asset_lat, nav.asset_lng]], {
color: '#5b6ef7', weight: 4, opacity: 0.8, dashArray: '10, 6',
}).addTo(navMap);
}
// Fit bounds
if (nav.route_coords && nav.route_coords.length > 1) {
navMap.fitBounds(nav.route_coords, { padding: [40, 40], maxZoom: 16 });
} else {
navMap.fitBounds([[nav.origin_lat, nav.origin_lng], [nav.asset_lat, nav.asset_lng]], { padding: [40, 40], maxZoom: 16 });
}
}, 200);
// Populate info card
const distText = nav.distance_km >= 1
? nav.distance_km + ' km' : nav.distance_meters + ' m';
document.getElementById('navDistance').textContent = distText;
document.getElementById('navCardinal').textContent = nav.cardinal || '—';
document.getElementById('navBearing').textContent = nav.bearing ? nav.bearing + '°' : '—';
// Show duration if available from OSRM
const durationEl = document.getElementById('navDuration');
if (durationEl && nav.route_duration_s) {
const mins = Math.round(nav.route_duration_s / 60);
durationEl.textContent = mins + ' min';
durationEl.style.display = '';
} else if (durationEl) {
durationEl.style.display = 'none';
}
document.getElementById('navInfoCard').style.display = '';
// Walking directions
if (nav.walking_directions) {
document.getElementById('navDirections').textContent = nav.walking_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.style.display = '';
} else {
document.getElementById('navGoogleMapsLink').style.display = 'none';
}
}
function updateNavSetup() {
const hasGps = AppState.gpsLat != null;
const hasAsset = AppState.currentAssetId != null;
const setupEl = document.getElementById('navSetup');
const gpsEl = document.getElementById('navSetupGps');
const assetEl = document.getElementById('navSetupAsset');
if (!setupEl) return;
if (!hasGps || !hasAsset) {
setupEl.style.display = 'block';
gpsEl.style.display = hasGps ? 'none' : 'block';
assetEl.style.display = hasAsset ? 'none' : 'block';
} else {
setupEl.style.display = 'none';
}
}
function captureNavGps() {
const btn = document.querySelector('#navSetupGps button');
if (btn) { btn.textContent = '⏳ Acquiring...'; btn.disabled = true; }
navigator.geolocation.getCurrentPosition(
(pos) => {
AppState.gpsLat = pos.coords.latitude;
AppState.gpsLng = pos.coords.longitude;
AppState.gpsAcc = pos.coords.accuracy;
if (btn) { btn.textContent = '📍 Got location! ✓'; btn.disabled = false; }
setTimeout(updateNavSetup, 500);
showToast('📍 Location acquired');
},
() => {
if (btn) { btn.textContent = '📍 Get My Location'; btn.disabled = false; }
showToast('GPS failed — check permissions', true);
},
{ enableHighAccuracy: true, timeout: 15000 }
);
}
let _navSearchTimeout = null;
function searchNavAssets() {
clearTimeout(_navSearchTimeout);
const q = document.getElementById('navAssetSearch')?.value.trim();
if (!q || q.length < 2) {
document.getElementById('navAssetResults').innerHTML = '';
return;
}
_navSearchTimeout = setTimeout(async () => {
try {
const res = await api('/api/assets?search=' + encodeURIComponent(q));
const results = Array.isArray(res) ? res : (res.assets || res.data || []);
const container = document.getElementById('navAssetResults');
if (results.length === 0) {
container.innerHTML = '<div style="color:var(--text3);font-size:12px;">No assets found</div>';
return;
}
container.innerHTML = results.slice(0, 10).map(a =>
`<div style="padding:6px 8px;cursor:pointer;border-radius:6px;background:var(--card2);margin-bottom:3px;"
onclick="selectNavAsset(${a.id})">
<strong>${esc(a.name)}</strong>
<span style="color:var(--text3);font-size:11px;margin-left:4px;">${esc(a.machine_id)}</span>
</div>`
).join('');
} catch (e) {
document.getElementById('navAssetResults').innerHTML =
'<div style="color:var(--red);font-size:12px;">Search failed</div>';
}
}, 300);
}
function selectNavAsset(id) {
AppState.currentAssetId = id;
document.getElementById('navDestName').textContent = '→ Asset #' + id;
document.getElementById('navAssetSearch').value = '';
document.getElementById('navAssetResults').innerHTML = '';
// Fetch the asset name
api('/api/assets/' + id).then(a => {
document.getElementById('navDestName').textContent = '→ ' + a.name;
}).catch(() => {});
updateNavSetup();
// If GPS is also set, navigate immediately
if (AppState.gpsLat != null) {
navigateToAsset('driving');
} else {
showToast('Asset selected. Now get your location.');
}
}
function closeNavigate() {
if (navMap) { navMap.remove(); navMap = null; }
AppState._navData = null;
// Reset navigate tab UI — does NOT switch tabs, caller handles that
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';
}
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<br><span style="font-size:0.85em;opacity:0.7">Check in from the Add Asset tab</span></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<br><span style="font-size:0.85em;opacity:0.7">Check your connection and try again</span><br><button onclick="loadCheckinHistory(\'' + (assetId || '') + '\')" style="margin-top:8px;padding:6px 16px;border:1px solid #ccc;border-radius:6px;background:#fff;cursor:pointer">🔄 Retry</button></div>';
}
}
async function loadServiceEntrances(a) {
const el = document.getElementById('detailServiceEntrances');
const fields = document.getElementById('detailSEFields');
if (!a.location_id) {
el.style.display = 'none';
return;
}
try {
const ses = await api('/api/locations/' + a.location_id + '/service-entrances');
if (!ses || !ses.length) {
el.style.display = 'none';
return;
}
el.style.display = 'block';
fields.innerHTML = ses.map(se => `
<div class="detail-field">
<div class="df-label">${esc(se.name || 'Service Entrance')}</div>
<div style="font-size:13px;">
📍 ${Number(se.latitude).toFixed(5)}, ${Number(se.longitude).toFixed(5)}
${se.notes ? '<br>📝 ' + esc(se.notes) : ''}
</div>
</div>
`).join('');
} catch (e) {
el.style.display = 'none';
}
}
async function changeDisneyPark(assetId) {
const parks = ['magic-kingdom','epcot','hollywood-studios','animal-kingdom','disney-springs','resort','office','other'];
const current = document.getElementById('disneyParkBadge')?.textContent?.trim() || '';
const options = parks.map(p => '<div class="dropdown-item" onclick="setDisneyPark(' + assetId + ',' + "'" + p + "'" + ')">' + parkIcon(p) + ' ' + parkName(p) + (current.includes(parkName(p)) ? ' ✓' : '') + '</div>').join('');
const noneOpt = '<div class="dropdown-item" onclick="setDisneyPark(' + assetId + ',null)">❌ Clear</div>';
showDropdown(document.getElementById('disneyParkBadge'), options + noneOpt);
}
async function setDisneyPark(assetId, park) {
closeDropdown();
try {
await api('/api/assets/' + assetId + '/disney-park', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({disney_park: park})
});
await viewAsset(assetId);
showToast(park ? 'Set to ' + parkName(park) : 'Disney park cleared');
} catch (e) {
showToast(e.message, true);
}
}
// ── Map filter state ──────────────────────────────────────────────────
let mapFilters = { category: null, status: null, make: null, disney_park: null, disney_filter: null, q: '',
customer_id: null, location_id: null, assigned_to: null, branch: null };
let mapFilterDropdownsLoaded = false;
let _mapFilterTimer;
function toggleMapFilterPanel() {
const panel = document.getElementById('mapFilterPanel');
const toggle = document.getElementById('mapFilterToggle');
panel.classList.toggle('open');
toggle.classList.toggle('active', panel.classList.contains('open'));
if (panel.classList.contains('open') && !mapFilterDropdownsLoaded) {
loadMapFilterDropdowns();
}
}
async function loadMapFilterDropdowns() {
if (mapFilterDropdownsLoaded) return;
mapFilterDropdownsLoaded = true;
var panel = document.getElementById('mapFilterPanel');
var loadingMsg = document.createElement('div');
loadingMsg.className = 'loading-spinner';
loadingMsg.innerHTML = '<span class="spinner"></span> Loading filters...';
panel.appendChild(loadingMsg);
try {
var cats = await api('/api/categories');
document.getElementById('mapFilterCategory').innerHTML = '<option value="">All categories</option>' +
cats.map(function(c) { return '<option value="' + esc(c) + '">' + esc(c) + '</option>'; }).join('');
var makes = await api('/api/makes');
document.getElementById('mapFilterMake').innerHTML = '<option value="">All makes</option>' +
makes.map(function(m) { return '<option value="' + esc(m) + '">' + esc(m) + '</option>'; }).join('');
var customers = await api('/api/customers');
document.getElementById('mapFilterCustomer').innerHTML = '<option value="">All customers</option>' +
customers.map(function(c) { return '<option value="' + c.id + '">' + esc(c.name) + '</option>'; }).join('');
var users = await api('/api/users');
document.getElementById('mapFilterAssignedTo').innerHTML = '<option value="">Anyone</option>' +
users.filter(function(u) { return u.role === 'technician' || u.role === 'admin'; })
.map(function(u) { return '<option value="' + esc(u.username) + '">' + esc(u.username) + '</option>'; }).join('');
var branches = await api('/api/branches');
document.getElementById('mapFilterBranch').innerHTML = '<option value="">All branches</option>' +
branches.branches.map(function(b) { return '<option value="' + esc(b.name) + '">' + esc(b.name) + ' (' + b.machine_count + ')</option>'; }).join('');
} catch (e) {
showToast('Failed to load filter options', true);
} finally {
if (loadingMsg && loadingMsg.parentNode) loadingMsg.parentNode.removeChild(loadingMsg);
}
}
function onMapFilterChange() {
mapFilters.category = document.getElementById('mapFilterCategory').value || null;
mapFilters.make = document.getElementById('mapFilterMake').value || null;
var disneyFilterVal = document.getElementById('mapFilterDisneyFilter').value;
if (disneyFilterVal) {
mapFilters.disney_filter = disneyFilterVal;
mapFilters.disney_park = null;
} else {
var parkVal = document.getElementById('mapFilterDisneyPark').value;
mapFilters.disney_park = parkVal || null;
mapFilters.disney_filter = null;
}
mapFilters.customer_id = document.getElementById('mapFilterCustomer').value || null;
var locEl = document.getElementById('mapFilterLocation');
var prevCust = locEl.dataset.customerId;
var custEl = document.getElementById('mapFilterCustomer');
if (custEl.value !== prevCust) {
locEl.innerHTML = '<option value="">All locations</option>';
locEl.disabled = true;
mapFilters.location_id = null;
if (custEl.value) {
loadMapLocationsForCustomer(custEl.value);
}
} else {
mapFilters.location_id = locEl.value || null;
}
locEl.dataset.customerId = custEl.value;
mapFilters.assigned_to = document.getElementById('mapFilterAssignedTo').value || null;
mapFilters.branch = document.getElementById('mapFilterBranch').value || null;
renderMapActiveFilterTags();
applyMapFilter();
}
async function loadMapLocationsForCustomer(customerId) {
try {
var locations = await api('/api/locations?customer_id=' + customerId);
var locEl = document.getElementById('mapFilterLocation');
locEl.innerHTML = '<option value="">All locations</option>' +
locations.map(function(l) { return '<option value="' + l.id + '">' + esc(l.name) + '</option>'; }).join('');
locEl.disabled = false;
} catch (e) { showToast('Failed to load locations', true); }
}
function getMapActiveFilterCount() {
var count = 0;
if (mapFilters.category) count++;
if (mapFilters.make) count++;
if (mapFilters.disney_park) count++;
if (mapFilters.disney_filter) count++;
if (mapFilters.customer_id) count++;
if (mapFilters.location_id) count++;
if (mapFilters.assigned_to) count++;
if (mapFilters.branch) count++;
if (mapFilters.q) count++;
return count;
}
function renderMapActiveFilterTags() {
var el = document.getElementById('mapActiveFilters');
var countEl = document.getElementById('mapFilterCount');
var toggle = document.getElementById('mapFilterToggle');
var count = getMapActiveFilterCount();
countEl.textContent = count;
countEl.style.display = count > 0 ? 'inline' : 'none';
if (toggle) toggle.classList.toggle('active', count > 0);
var tags = [];
if (mapFilters.q) tags.push({key:'q', label:'🔍 "' + mapFilters.q + '"'});
if (mapFilters.category) tags.push({key:'category', label:'📂 ' + mapFilters.category});
if (mapFilters.make) tags.push({key:'make', label:'🏭 ' + mapFilters.make});
if (mapFilters.disney_park) tags.push({key:'disney_park', label: parkIcon(mapFilters.disney_park) + ' ' + parkName(mapFilters.disney_park)});
if (mapFilters.disney_filter === 'disney') tags.push({key:'disney_filter', label:'🏰 Disney only'});
if (mapFilters.disney_filter === 'non_disney') tags.push({key:'disney_filter', label:'🏢 Non-Disney'});
if (mapFilters.customer_id) {
var custSel = document.getElementById('mapFilterCustomer');
var txt = custSel.options[custSel.selectedIndex] ? custSel.options[custSel.selectedIndex].text : 'Customer';
tags.push({key:'customer_id', label:'🟢 ' + txt});
}
if (mapFilters.location_id) {
var locSel = document.getElementById('mapFilterLocation');
var txt = locSel.options[locSel.selectedIndex] ? locSel.options[locSel.selectedIndex].text : 'Location';
tags.push({key:'location_id', label:'📍 ' + txt});
}
if (mapFilters.assigned_to) tags.push({key:'assigned_to', label:'👤 ' + mapFilters.assigned_to});
if (mapFilters.branch) tags.push({key:'branch', label:'🏢 ' + mapFilters.branch});
el.innerHTML = tags.map(function(t) {
return '<span class="filter-tag">' + esc(t.label) + ' <span class="ft-remove" onclick="removeMapFilter(\'' + t.key + '\')">✕</span></span>';
}).join('');
}
function removeMapFilter(key) {
if (key === 'q') {
document.getElementById('mapSearchInput').value = '';
mapFilters.q = '';
} else if (key === 'category') {
document.getElementById('mapFilterCategory').value = '';
mapFilters.category = null;
} else if (key === 'make') {
document.getElementById('mapFilterMake').value = '';
mapFilters.make = null;
} else if (key === 'disney_park') {
document.getElementById('mapFilterDisneyPark').value = '';
mapFilters.disney_park = null;
} else if (key === 'disney_filter') {
document.getElementById('mapFilterDisneyFilter').value = '';
mapFilters.disney_filter = null;
} else if (key === 'customer_id') {
document.getElementById('mapFilterCustomer').value = '';
document.getElementById('mapFilterLocation').innerHTML = '<option value="">All locations</option>';
document.getElementById('mapFilterLocation').disabled = true;
mapFilters.customer_id = null;
mapFilters.location_id = null;
} else if (key === 'location_id') {
document.getElementById('mapFilterLocation').value = '';
mapFilters.location_id = null;
} else if (key === 'assigned_to') {
document.getElementById('mapFilterAssignedTo').value = '';
mapFilters.assigned_to = null;
} else if (key === 'branch') {
document.getElementById('mapFilterBranch').value = '';
mapFilters.branch = null;
}
renderMapActiveFilterTags();
applyMapFilter();
}
function clearMapFilters() {
mapFilters = { category: null, status: null, make: null, disney_park: null, disney_filter: null, q: '',
customer_id: null, location_id: null, assigned_to: null, branch: null };
document.getElementById('mapSearchInput').value = '';
document.getElementById('mapFilterCategory').value = '';
document.getElementById('mapFilterMake').value = '';
document.getElementById('mapFilterDisneyPark').value = '';
document.getElementById('mapFilterDisneyFilter').value = '';
document.getElementById('mapFilterCustomer').value = '';
document.getElementById('mapFilterLocation').innerHTML = '<option value="">All locations</option>';
document.getElementById('mapFilterLocation').disabled = true;
document.getElementById('mapFilterAssignedTo').value = '';
document.getElementById('mapFilterBranch').value = '';
renderMapActiveFilterTags();
applyMapFilter();
}
async function loadAssetPins() {
if (!window._assetPins) window._assetPins = [];
window._assetPins.forEach(function(m) { if (map) map.removeLayer(m); });
window._assetPins = [];
try {
const assets = await api('/api/assets?has_gps=true&limit=2000');
_mapAllAssets = assets;
applyMapFilter();
} catch (e) {
console.error('Failed to load asset pins:', e);
showToast('Failed to load map assets — ' + (e.message || 'connection error'), true);
}
}
function mapDebounceFilter() {
if (_mapFilterTimer) clearTimeout(_mapFilterTimer);
mapFilters.q = (document.getElementById('mapSearchInput').value || '').toLowerCase().trim();
renderMapActiveFilterTags();
_mapFilterTimer = setTimeout(applyMapFilter, 200);
}
function applyMapFilter() {
if (_mapFilterTimer) { clearTimeout(_mapFilterTimer); _mapFilterTimer = null; }
var q = mapFilters.q;
// Clear existing pins
window._assetPins.forEach(function(m) { if (map) map.removeLayer(m); });
window._assetPins = [];
var matched = [];
var allAssets = _mapAllAssets || [];
allAssets.forEach(function(a) {
// Category filter
if (mapFilters.category && a.category !== mapFilters.category) return;
// Make filter
if (mapFilters.make && a.make !== mapFilters.make) return;
// Disney/non-disney filter
if (mapFilters.disney_filter === 'disney' && !a.is_disney) return;
if (mapFilters.disney_filter === 'non_disney' && a.is_disney) return;
// Disney park filter
if (mapFilters.disney_park && a.disney_park !== mapFilters.disney_park) return;
// Text search
if (q) {
var haystack = ((a.name || '') + ' ' + (a.machine_id || '') + ' ' + (a.category || '') + ' ' + (a.company || '') + ' ' + (a.description || '')).toLowerCase();
if (haystack.indexOf(q) === -1) return;
}
matched.push(a);
});
// Render pins for assets with GPS
matched.forEach(function(a) {
if (a.latitude != null && a.longitude != null) {
var catEntry = CATEGORY_MAP[a.category];
var icon = catEntry ? catEntry.icon : (a.category ? a.category.charAt(0).toUpperCase() : '📦');
if (icon.indexOf('<img') !== -1) icon = '🍽';
var popupHtml = '<b>' + esc(a.name) + '</b><br>' + esc(a.machine_id || '') +
'<br><button class="btn btn-outline btn-sm" style="margin-top:6px;" ' +
'onclick="switchTab(\'tabAssets\');viewAsset(' + a.id + ');">View Details</button>';
var catColor = catEntry ? catEntry.color : '#6b7280';
var pinIcon = L.divIcon({
className: 'cat-pin-icon',
html: '<div style="width:28px;height:28px;border-radius:50%;background:' + catColor +
';display:flex;align-items:center;justify-content:center;font-size:15px;' +
'box-shadow:0 2px 5px rgba(0,0,0,0.5);border:2px solid #fff;">' + icon + '</div>',
iconSize: [32, 32],
iconAnchor: [16, 16],
popupAnchor: [0, -18],
});
var marker = L.marker([a.latitude, a.longitude], { icon: pinIcon })
.addTo(map)
.bindPopup(popupHtml);
window._assetPins.push(marker);
}
});
// Fit bounds to visible pins
if (window._assetPins.length > 0) {
var group = L.featureGroup(window._assetPins);
map.fitBounds(group.getBounds().pad(0.1));
}
// Render asset list below map
renderMapAssetList(matched);
}
function renderMapAssetList(assets) {
var el = document.getElementById('mapAssetList');
if (assets.length === 0) {
el.innerHTML = '<div class="map-asset-empty">📦 No assets match your search</div>';
return;
}
var sorted = assets.slice().sort(function(a, b) { return (a.name || '').localeCompare(b.name || ''); });
var html = '';
sorted.forEach(function(a) {
var catEntry = CATEGORY_MAP[a.category];
var icon = catEntry ? catEntry.icon : '📦';
if (icon.indexOf('<img') !== -1) icon = '🍽';
var hasGps = (a.latitude != null && a.longitude != null);
html += '<div class="map-asset-item" onclick="switchTab(\'tabAssets\');viewAsset(' + a.id + ');">' +
'<span class="mai-icon">' + icon + '</span>' +
'<span class="mai-name">' + esc(a.name) + '</span>' +
'<span class="mai-meta">' + esc(a.machine_id || '') + (hasGps ? ' · 📍' : '') + '</span>' +
'<span class="mai-dist">' + esc(a.category || '') + '</span>' +
'</div>';
});
el.innerHTML = html;
}
// ═══════════════════════════════════════════════════════════════════════
// HEATMAP
// ═══════════════════════════════════════════════════════════════════════
async function toggleHeatmap() {
if (!map) return;
const chip = document.getElementById('chipHeat');
if (heatVisible) {
heatVisible = false;
chip.classList.remove('heat-on');
chip.textContent = '🔥 Heatmap';
if (heatLayer) map.removeLayer(heatLayer);
heatLayer = null;
return;
}
heatVisible = true;
chip.classList.add('heat-on');
chip.textContent = '🔥 Heatmap ON';
await loadHeatmapData();
}
async function loadHeatmapData() {
try {
// Get visit stats (visit counts per asset)
const stats = await api('/api/visits/stats');
const visitMap = {};
(stats.visits_per_asset || []).forEach(v => {
visitMap[v.name] = v.count;
});
// Get all assets with coordinates
const assets = await api('/api/assets?has_gps=true&limit=2000');
const heatData = [];
assets.forEach(a => {
if (a.latitude != null && a.longitude != null) {
const count = visitMap[a.name] || 0;
// Higher intensity for more visits; minimum 0.1 so all assets show faintly
const intensity = Math.max(0.1, Math.min(1, count / 10));
heatData.push([a.latitude, a.longitude, intensity]);
}
});
if (heatLayer) map.removeLayer(heatLayer);
if (typeof L.heatLayer === 'function') {
heatLayer = L.heatLayer(heatData, {
radius: 30,
blur: 20,
maxZoom: 17,
max: 1.0,
gradient: { 0.1: '#4ade80', 0.4: '#fbbf24', 0.7: '#f87171', 1.0: '#ef4444' },
}).addTo(map);
} else {
// Fallback: circle markers with opacity
heatLayer = L.layerGroup();
heatData.forEach(([lat, lng, intensity]) => {
L.circleMarker([lat, lng], {
radius: 12 + intensity * 20,
fillColor: '#f87171',
color: 'transparent',
fillOpacity: intensity * 0.6,
}).addTo(heatLayer);
});
heatLayer.addTo(map);
}
} catch (e) {
console.error('Failed to load heatmap:', e);
showToast('Failed to load heatmap data', true);
}
}
// =========================================================================
function renderGpsPreview(lat, lng, containerEl) {
if (!containerEl) return;
containerEl.innerHTML = '';
const wrapper = document.createElement('div');
wrapper.style.width = '100%';
wrapper.style.height = '180px';
wrapper.style.borderRadius = 'var(--radius-sm)';
wrapper.style.overflow = 'hidden';
wrapper.style.marginTop = '8px';
containerEl.appendChild(wrapper);
// Leaflet needs the container to be in the DOM and sized before init
const map = L.map(wrapper, {
center: [lat, lng],
zoom: 15,
zoomControl: false,
attributionControl: false,
dragging: false,
scrollWheelZoom: false,
touchZoom: false,
});
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
}).addTo(map);
L.marker([lat, lng]).addTo(map)
.bindPopup(`${lat.toFixed(5)}, ${lng.toFixed(5)}`);
// Fix tile loading — invalidateSize after tiles load
setTimeout(() => map.invalidateSize(), 300);
}
// ▶ Called when "📍 Extract GPS" button is clicked in photo preview
async function extractGpsFromPhoto(file) {
if (!file) return null;
try {
const gps = await exifr.gps(file);
return gps || null;
} catch (e) {
console.warn('EXIF GPS extraction failed:', e.message);
return null;
}
}
// Boot auth at page load
// ══════════════════════════════════════════════════════════════════════
// ROUTE PLANNER — native integration
// ══════════════════════════════════════════════════════════════════════
const rpState = {
selectedWOs: new Map(),
currentRoute: null,
map: null,
markers: [],
polyline: null,
searchTimer: null,
isReordering: false,
};
// ── Init on tab visit ────────────────────────────────────────────────
document.addEventListener('rpTabActivated', () => {
if (!rpState.map) {
rpInitMap();
} else {
setTimeout(() => rpState.map.invalidateSize(), 200);
}
rpLoadTechs();
rpLoadSavedOrigin();
});
function rpInitMap() {
const el = document.getElementById('routeMap');
if (!el) return;
rpState.map = L.map('routeMap', {
center: [28.5383, -81.3792],
zoom: 10,
zoomControl: true,
attributionControl: false,
});
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
}).addTo(rpState.map);
setTimeout(() => rpState.map.invalidateSize(), 300);
}
// ── Tech Checkboxes ──────────────────────────────────────────────────
async function rpLoadTechs() {
const savedTechs = JSON.parse(localStorage.getItem('rpTechs') || '[]');
try {
const data = await api('/api/workorders/technicians');
const container = document.getElementById('rpTechCheckboxes');
// Keep the "All" checkbox, remove existing tech checkboxes
container.querySelectorAll('label:not(:first-child)').forEach(el => el.remove());
data.technicians.forEach(tech => {
const label = document.createElement('label');
label.style.cssText = 'display:flex;align-items:center;gap:4px;cursor:pointer;font-size:13px;background:var(--bg,#111);padding:4px 10px;border-radius:6px;border:1px solid var(--border,#333);';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.value = tech;
cb.checked = savedTechs.length ? savedTechs.includes(tech) : true;
cb.onchange = rpSaveTechs;
label.appendChild(cb);
label.append(' ' + tech);
container.appendChild(label);
});
rpSyncAllCheckbox();
} catch (e) { showToast('Failed to load technicians', true); console.warn('Could not load technicians:', e); }
}
function rpSaveTechs() {
rpSyncAllCheckbox();
const techs = [];
document.querySelectorAll('#rpTechCheckboxes input[type="checkbox"]:not(#rpTechAll):checked').forEach(cb => techs.push(cb.value));
localStorage.setItem('rpTechs', JSON.stringify(techs));
}
function rpToggleAllTechs() {
const allChecked = document.getElementById('rpTechAll').checked;
document.querySelectorAll('#rpTechCheckboxes input[type="checkbox"]:not(#rpTechAll)').forEach(cb => cb.checked = allChecked);
rpSaveTechs();
}
function rpSyncAllCheckbox() {
const individual = document.querySelectorAll('#rpTechCheckboxes input[type="checkbox"]:not(#rpTechAll)');
const allChecked = individual.length > 0 && Array.from(individual).every(cb => cb.checked);
const allCb = document.getElementById('rpTechAll');
if (allCb) allCb.checked = allChecked;
}
// ── Origin ────────────────────────────────────────────────────────────
function rpLoadSavedOrigin() {
const saved = localStorage.getItem('rpOrigin');
if (saved) document.getElementById('rpOriginInput').value = saved;
}
function rpSetOrigin(lat, lng) {
const val = `${lat}, ${lng}`;
document.getElementById('rpOriginInput').value = val;
localStorage.setItem('rpOrigin', val);
}
function rpUseMyLocation() {
if (!navigator.geolocation) { showToast('Geolocation not available', true); return; }
navigator.geolocation.getCurrentPosition(
pos => rpSetOrigin(pos.coords.latitude, pos.coords.longitude),
() => showToast('Could not get location', true)
);
}
function rpGetOrigin() {
const val = document.getElementById('rpOriginInput').value.trim();
if (!val || !document.getElementById('rpUseOrigin').checked) return null;
const parts = val.split(',').map(s => parseFloat(s.trim()));
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
return { lat: parts[0], lng: parts[1] };
}
return null;
}
// ── Input Tabs ────────────────────────────────────────────────────────
function rpSwitchInputTab(tab) {
document.querySelectorAll('#rpInputTabs .rp-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('#rpInputTabs .rp-tab').forEach(t => {
if (t.textContent.includes(tab === 'paste' ? 'Paste' : 'Search')) t.classList.add('active');
});
document.getElementById('rpTabPaste').style.display = tab === 'paste' ? '' : 'none';
document.getElementById('rpTabSearch').style.display = tab === 'search' ? '' : 'none';
}
// ── Search ────────────────────────────────────────────────────────────
function rpDebounceSearch() {
clearTimeout(rpState.searchTimer);
rpState.searchTimer = setTimeout(rpDoSearch, 300);
}
async function rpDoSearch() {
const q = document.getElementById('rpSearchInput').value.trim();
const container = document.getElementById('rpSearchResults');
if (q.length < 2) { container.innerHTML = ''; return; }
container.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text2,#999);">Searching...</div>';
try {
const data = await api('/api/workorders/search?q=' + encodeURIComponent(q) + '&limit=30');
if (!data.results || data.results.length === 0) {
container.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text2,#999);">No results</div>';
return;
}
let html = '';
data.results.forEach(wo => {
const sel = rpState.selectedWOs.has(wo.id) ? ' selected' : '';
const addr = [wo.address.line1, wo.address.city].filter(Boolean).join(', ');
html += '<div class="search-item' + sel + '" onclick="rpToggleSelect(\'' + wo.id + '\')">' +
'<div class="si-name">' + esc(wo.name) + '</div>' +
'<div class="si-addr">' + esc(wo.account_name || '') + (addr ? ' — ' + esc(addr) : '') + '</div>' +
'</div>';
});
container.innerHTML = html;
} catch (e) {
container.innerHTML = '<div style="text-align:center;padding:20px;color:var(--red,#f85149);">Error searching</div>';
}
}
function rpToggleSelect(woId) {
if (rpState.selectedWOs.has(woId)) {
rpState.selectedWOs.delete(woId);
} else if (rpState.selectedWOs.size < 50) {
rpState.selectedWOs.set(woId, {});
}
rpUpdateSelectedUI();
rpDoSearch(); // re-render search results to show selection
}
function rpUpdateSelectedUI() {
const count = rpState.selectedWOs.size;
document.getElementById('rpSelectedCount').textContent = count;
document.getElementById('rpPlanSelectedCount').textContent = count;
document.getElementById('rpPlanSelectedBtn').disabled = count === 0;
const container = document.getElementById('rpSelectedWOList');
if (count === 0) { container.innerHTML = ''; return; }
let html = '';
rpState.selectedWOs.forEach((_, id) => {
html += '<span class="chip">' + esc(id) + ' <span class="remove" onclick="rpRemoveSelected(\'' + id + '\')">✕</span></span>';
});
container.innerHTML = html;
}
function rpRemoveSelected(woId) {
rpState.selectedWOs.delete(woId);
rpUpdateSelectedUI();
rpDoSearch();
}
// ── Load Today's Route ────────────────────────────────────────────────
async function rpLoadToday() {
const techs = [];
document.querySelectorAll('#rpTechCheckboxes input[type="checkbox"]:not(#rpTechAll):checked').forEach(cb => techs.push(cb.value));
if (techs.length === 0) { showToast('Select at least one technician', true); return; }
rpShowLoading('Loading today\u2019s work orders...');
try {
const params = techs.map(t => 'tech=' + encodeURIComponent(t)).join('&');
const data = await api('/api/workorders/today?' + params);
if (!data.workorders || data.workorders.length === 0) {
rpHideLoading();
showToast('No work orders found for today', true); return;
}
const woIds = data.workorders.map(wo => wo.name);
await rpOptimizeRoute(woIds, data.workorders);
} catch (e) {
rpHideLoading();
showToast('Error loading today\'s route: ' + e.message, true);
}
}
// ── Plan from Paste ───────────────────────────────────────────────────
function rpPlanFromPaste() {
const raw = document.getElementById('rpWoInput').value.trim();
if (!raw) { showToast('Paste some work order numbers first', true); return; }
const ids = raw.split(/[,\n\r]+/).map(s => s.trim()).filter(Boolean);
if (ids.length === 0) { showToast('No valid work order numbers found', true); return; }
rpOptimizeRoute(ids);
}
// ── Plan from Selected ────────────────────────────────────────────────
function rpPlanFromSelected() {
const ids = Array.from(rpState.selectedWOs.keys());
if (ids.length === 0) return;
rpOptimizeRoute(ids);
}
// ── Route Optimization ────────────────────────────────────────────────
async function rpOptimizeRoute(woIds, preloaded) {
rpShowLoading('Optimizing route...');
// Lookup WO details if not preloaded
let woDetails = preloaded || null;
if (!woDetails) {
try {
const data = await api('/api/workorders/lookup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: woIds }),
});
woDetails = data.found || [];
} catch (e) {
rpHideLoading();
showToast('Error looking up work orders: ' + e.message, true); return;
}
}
const origin = rpGetOrigin();
const payload = {
work_order_ids: woIds,
origin: origin,
};
try {
const data = await api('/api/route/optimize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
rpHideLoading();
rpDisplayRoute(data, woDetails);
} catch (e) {
rpHideLoading();
showToast('Error optimizing route: ' + e.message, true);
}
}
// ── Display Route ─────────────────────────────────────────────────────
function rpDisplayRoute(data, woDetails) {
rpState.currentRoute = data;
document.getElementById('routeSection').style.display = 'none';
document.getElementById('rpResultSection').style.display = '';
// Stats
const s = data.summary || {};
const statsHtml =
'<div class="rp-stat"><div class="rp-stat-val">' + (s.total_stops || 0) + '</div><div class="rp-stat-lbl">Stops</div></div>' +
'<div class="rp-stat"><div class="rp-stat-val">' + (s.total_distance_km || 0) + ' km</div><div class="rp-stat-lbl">Distance</div></div>' +
'<div class="rp-stat"><div class="rp-stat-val">' + (s.total_drive_min || 0) + ' min</div><div class="rp-stat-lbl">Drive</div></div>' +
'<div class="rp-stat"><div class="rp-stat-val">' + (s.total_estimated_min || 0) + ' min</div><div class="rp-stat-lbl">Est. Total</div></div>';
document.getElementById('rpRouteStats').innerHTML = statsHtml;
// Map
if (!rpState.map) rpInitMap();
const map = rpState.map;
// Clear old layers
rpState.markers.forEach(m => map.removeLayer(m));
if (rpState.polyline) map.removeLayer(rpState.polyline);
rpState.markers = [];
const route = data.route || [];
if (route.length === 0) {
document.getElementById('rpStopList').innerHTML = '<p style="color:var(--text2,#999);">No stops with GPS coordinates</p>';
return;
}
// Plot markers
const bounds = [];
const markerColors = ['#58a6ff', '#3fb950', '#d29922', '#f85149', '#bc8cff', '#79c0ff'];
route.forEach((stop, i) => {
if (!stop.gps) return;
const color = markerColors[i % markerColors.length];
const marker = L.circleMarker([stop.gps.lat, stop.gps.lng], {
radius: 10,
fillColor: color,
color: '#fff',
weight: 2,
opacity: 1,
fillOpacity: 0.8,
});
marker.bindTooltip((i + 1).toString(), { permanent: true, direction: 'center', className: 'rp-marker-label' });
marker.bindPopup(
'<b>#' + (i + 1) + ' ' + esc(stop.name) + '</b><br>' +
esc(stop.account_name || '') + '<br>' +
esc(stop.address ? [stop.address.line1, stop.address.city].filter(Boolean).join(', ') : '') +
(stop.distance_to_next_km ? '<br>→ ' + stop.distance_to_next_km + ' km' : '')
);
marker.on('click', () => rpShowAsset(stop.name));
marker.addTo(map);
rpState.markers.push(marker);
bounds.push([stop.gps.lat, stop.gps.lng]);
});
// Polyline
const latlngs = route.filter(s => s.gps).map(s => [s.gps.lat, s.gps.lng]);
if (latlngs.length > 1) {
rpState.polyline = L.polyline(latlngs, {
color: '#58a6ff', weight: 3, opacity: 0.7, dashArray: '8, 8',
}).addTo(map);
}
if (bounds.length > 0) {
map.fitBounds(bounds, { padding: [30, 30], maxZoom: 14 });
}
// Stop list
let stopHtml = '';
route.forEach((stop, i) => {
const addr = stop.address ? [stop.address.line1, stop.address.city].filter(Boolean).join(', ') : '';
const drive = stop.drive_min_to_next ? '🚗 ' + stop.drive_min_to_next + ' min (' + stop.distance_to_next_km + ' km)' : '';
const cum = stop.cumulative_min != null ? '⏱ ' + stop.cumulative_min + ' min from start' : '';
const statusBadge = stop.booking_status ? '<span class="badge badge-info">' + esc(stop.booking_status) + '</span>' : '';
const woLink = '<span style="color:var(--accent,#58a6ff);cursor:pointer;" onclick="rpShowAsset(\'' + stop.name + '\')">' + esc(stop.name) + '</span>';
stopHtml += '<div class="stop-item">' +
'<div class="stop-num">' + (i + 1) + '</div>' +
'<div class="stop-body">' +
'<div class="stop-name">' + woLink + ' ' + statusBadge + '</div>' +
'<div class="stop-addr">' + esc(stop.account_name || '') + (addr ? ' — ' + esc(addr) : '') + '</div>' +
'<div class="stop-meta">' + esc(stop.work_type || '') + (stop.priority ? ' · ' + esc(stop.priority) : '') + '</div>' +
(drive ? '<div class="stop-drive">' + drive + '</div>' : '') +
(cum ? '<div class="stop-cumulative">' + cum + '</div>' : '') +
'</div></div>';
});
document.getElementById('rpStopList').innerHTML = stopHtml;
// No-GPS warning
const noGps = data.stops_without_gps || [];
const noGpsContainer = document.getElementById('rpNoGpsWarning');
const noGpsList = document.getElementById('rpNoGpsList');
if (noGps.length > 0) {
noGpsContainer.style.display = '';
noGpsList.innerHTML = noGps.map(s => '<div style="font-size:12px;padding:4px 0;">' + esc(s.name) + ' — ' + esc(s.account_name || '') + '</div>').join('');
} else {
noGpsContainer.style.display = 'none';
}
setTimeout(() => map.invalidateSize(), 300);
}
// ── Asset Detail ──────────────────────────────────────────────────────
async function rpShowAsset(woName) {
const overlay = document.getElementById('rpAssetOverlay');
const modal = document.getElementById('rpAssetModal');
const content = document.getElementById('rpAssetModalContent');
content.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text2,#999);">Loading...</div>';
overlay.style.display = '';
modal.style.display = '';
try {
const data = await api('/api/workorders/' + encodeURIComponent(woName) + '/asset');
const d = data.details || {};
let html = '<div class="asset-header"><div class="asset-title">' + esc(data.asset_name || woName) + '</div></div>';
const sections = {};
Object.entries(d).forEach(([key, val]) => {
const section = key.includes('_') ? key.split('_')[0] : 'details';
if (!sections[section]) sections[section] = '';
sections[section] += '<div class="asset-row"><span class="asset-label">' + key.replace(/_/g, ' ') + '</span><span class="asset-val">' + esc(String(val)) + '</span></div>';
});
Object.entries(sections).forEach(([name, content]) => {
html += '<div class="asset-section-title" style="margin-top:12px;">' + name.toUpperCase() + '</div>' + content;
});
content.innerHTML = html;
} catch (e) {
content.innerHTML = '<p style="color:var(--red);">Could not load asset details: ' + e.message + '</p>';
}
}
function rpCloseAssetModal() {
document.getElementById('rpAssetOverlay').style.display = 'none';
document.getElementById('rpAssetModal').style.display = 'none';
}
// ── Reorder ───────────────────────────────────────────────────────────
function rpToggleReorder() {
rpState.isReordering = !rpState.isReordering;
document.getElementById('rpReorderToggle').textContent = rpState.isReordering ? '✅ Done' : '↕️ Reorder';
const stops = document.getElementById('rpStopList');
if (rpState.isReordering) {
stops.querySelectorAll('.stop-item').forEach((el, i) => {
el.style.cursor = 'grab';
el.draggable = true;
el.ondragstart = (e) => { e.dataTransfer.setData('text/plain', i); };
el.ondragover = (e) => e.preventDefault();
el.ondrop = (e) => {
e.preventDefault();
const from = parseInt(e.dataTransfer.getData('text/plain'));
const to = i;
if (from === to) return;
const route = rpState.currentRoute.route;
const item = route.splice(from, 1)[0];
route.splice(to, 0, item);
rpDisplayRoute(rpState.currentRoute, null);
rpState.isReordering = true;
document.getElementById('rpReorderToggle').textContent = '✅ Done';
stops.querySelectorAll('.stop-item').forEach(el => el.draggable = true);
};
});
} else {
stops.querySelectorAll('.stop-item').forEach(el => { el.draggable = false; el.ondragstart = null; el.ondragover = null; el.ondrop = null; el.style.cursor = ''; });
}
}
// ── Copy as Text ──────────────────────────────────────────────────────
function rpCopyText() {
const route = rpState.currentRoute?.route || [];
const lines = ['🗺️ Daily Route Planner — ' + new Date().toLocaleDateString(), ''];
route.forEach((stop, i) => {
const addr = stop.address ? [stop.address.line1, stop.address.city].filter(Boolean).join(', ') : '';
lines.push((i + 1) + '. ' + stop.name + ' — ' + (stop.account_name || '') + (addr ? ' (' + addr + ')' : ''));
if (stop.drive_min_to_next) lines.push(' 🚗 ' + stop.drive_min_to_next + ' min (' + stop.distance_to_next_km + ' km)');
});
if (rpState.currentRoute?.summary) {
const s = rpState.currentRoute.summary;
lines.push('', '📊 Total: ' + s.total_stops + ' stops, ' + s.total_distance_km + ' km, ~' + s.total_estimated_min + ' min');
}
const text = lines.join('\\n');
navigator.clipboard.writeText(text).then(() => {
// brief flash feedback
const btn = document.querySelector('[onclick="rpCopyText()"]');
if (btn) { btn.textContent = '✅ Copied!'; setTimeout(() => btn.textContent = '📋 Copy', 4000); }
}).catch(() => {
// fallback
const ta = document.createElement('textarea');
ta.value = text; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove();
});
}
// ── Loading State ─────────────────────────────────────────────────────
function rpShowLoading(msg) {
document.getElementById('routeSection').style.display = 'none';
document.getElementById('rpResultSection').style.display = 'none';
document.getElementById('rpLoadingSection').style.display = '';
document.getElementById('rpLoadingMsg').textContent = msg || 'Working...';
}
function rpHideLoading() {
document.getElementById('rpLoadingSection').style.display = 'none';
}
// ── Reset ─────────────────────────────────────────────────────────────
function rpResetAll() {
rpState.currentRoute = null;
document.getElementById('rpResultSection').style.display = 'none';
document.getElementById('rpLoadingSection').style.display = 'none';
document.getElementById('routeSection').style.display = '';
if (rpState.map) {
rpState.markers.forEach(m => rpState.map.removeLayer(m));
if (rpState.polyline) rpState.map.removeLayer(rpState.polyline);
rpState.markers = [];
rpState.polyline = null;
}
}
document.addEventListener('DOMContentLoaded', () => {
initAuth();
initGPS();
});
</script>
</body>
</html>