Files
shawn 874e15c01e Add disney_group grouped park filter categories
- New disney_group query param on /api/assets (park, resort,
  office, springs, other)
- Both list and map filter dropdowns show group options above
  individual parks
- Backend uses IN queries for grouped park values
- Map view handles groups via client-side PARK_GROUP_MAP

Closes #69
2026-06-03 00:57:55 -04:00

7219 lines
413 KiB
HTML
Raw Permalink 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>
<!-- ZXing barcode scanner: local-first, CDN as fallback for offline/blocked networks -->
<script src="/zxing.min.js" onerror="this.remove();var s=document.createElement('script');s.src='https://cdn.jsdelivr.net/npm/@zxing/library@0.20.0/umd/index.min.js';document.head.appendChild(s)"></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;
max-height: 85vh; display: flex; flex-direction: column;
text-align: center;
}
.modal .modal-title { font-size: 17px; font-weight: 700; margin-bottom: 8px; flex-shrink: 0; }
.modal .modal-body { font-size: 14px; color: var(--text2); margin-bottom: 16px; overflow-y: auto; flex: 1 1 auto; min-height: 0; }
.modal .modal-actions { display: flex; gap: 8px; flex-shrink: 0; }
.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 svg { width: 24px; height: 24px; display: block; }
.asset-item .ai-icon.cat-Bev { background: #0a3a65; }
.asset-item .ai-icon.cat-Snack { background: #3a2a10; }
.asset-item .ai-icon.cat-Food { background: #3a1a0a; }
.asset-item .ai-icon.cat-Equipment { background: #1a1a2e; }
.asset-item .ai-icon.cat-Appliances { background: #1a2a3e; }
.asset-item .ai-icon.cat-Other { background: #1a1b26; }
.asset-item .ai-icon.cat-unmapped { background: #2a0a0a; }
.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; }
/* Work Order date chips */
.wo-date-chip {
display: inline-flex; align-items: center; gap: 4px;
padding: 6px 14px; border-radius: 20px; border: 1px solid var(--border);
background: var(--card); color: var(--text2);
font-size: 12px; font-weight: 600; cursor: pointer;
transition: 0.15s; -webkit-tap-highlight-color: transparent;
}
.wo-date-chip:hover { border-color: var(--accent); color: var(--text); }
.wo-date-chip.active {
background: var(--accent-bg); border-color: var(--accent);
color: var(--accent2);
}
/* Work Order card */
.wo-item {
display: flex; align-items: flex-start; gap: 10px;
padding: 12px; border-bottom: 1px solid rgba(255,255,255,0.04);
cursor: pointer;
}
.wo-item:last-child { border-bottom: none; }
.wo-item .wo-status-dot {
width: 10px; height: 10px; border-radius: 50%;
flex-shrink: 0; margin-top: 4px;
}
.wo-item .wo-info { flex: 1; min-width: 0; }
.wo-item .wo-name {
font-weight: 600; font-size: 14px; line-height: 1.3;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.wo-item .wo-account {
font-size: 12px; color: var(--text2); margin-top: 2px;
}
.wo-item .wo-meta {
font-size: 11px; color: var(--text3); margin-top: 3px;
}
.wo-item .wo-meta span { display: inline-block; margin-right: 6px; }
.wo-item .wo-arrow { color: var(--text3); font-size: 16px; padding-top: 2px; }
.wo-status-badge {
font-size: 11px; font-weight: 700; margin-top: 2px; margin-bottom: 2px;
text-transform: uppercase; letter-spacing: 0.3px;
}
.wo-det-status {
display: inline-block; font-weight: 700; padding: 2px 8px;
border-radius: 4px; background: var(--accent-bg); color: var(--accent2);
}
/* Work order detail modal */
.wo-detail-container {
font-size: 14px; line-height: 1.6; text-align: left !important;
}
.wo-det-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 12px; padding-bottom: 10px;
border-bottom: 1px solid var(--border,#e0e0e0);
}
.wo-det-name { font-size: 16px; font-weight: 700; }
.wo-det-status-badge {
display: inline-block; font-size: 11px; font-weight: 700;
padding: 3px 10px; border-radius: 12px;
text-transform: uppercase; letter-spacing: 0.3px;
}
.wo-det-row {
display: flex; justify-content: space-between; align-items: flex-start;
margin-bottom: 6px; gap: 12px;
}
.wo-det-row-full { flex-direction: column; }
.wo-det-label {
font-weight: 600; color: var(--text2,#666);
flex-shrink: 0; min-width: 100px;
}
.wo-det-value { text-align: left; word-break: break-word; }
.wo-det-instr {
white-space: pre-wrap; font-size: 13px;
background: var(--card-bg,#f8f8f8); padding: 8px 10px;
border-radius: 6px; line-height: 1.5;
}
.wo-det-sep {
height: 1px; background: var(--border,#e0e0e0);
margin: 10px 0;
}
.wo-det-section-title {
font-size: 10px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--text2,#888); padding: 4px 0;
}
.wo-det-timeline { padding: 4px 0 4px 8px; border-left: 2px solid var(--border,#e0e0e0); margin: 4px 0; }
.wo-det-tl-row {
display: flex; align-items: baseline; gap: 8px;
padding: 5px 0 5px 14px; position: relative; font-size: 13px;
}
.wo-det-tl-dot {
position: absolute; left: -7px; top: 10px;
width: 10px; height: 10px; border-radius: 50%;
border: 2px solid var(--bg,#fff);
}
.wo-det-tl-label { color: var(--text2,#888); font-weight: 500; flex-shrink: 0; min-width: 130px; font-size: 12px; }
.wo-det-tl-time { color: var(--text,#333); font-size: 12px; }
.wo-det-flag {
display: inline-block; font-size: 11px; font-weight: 600;
padding: 3px 10px; border-radius: 10px;
}
.wo-det-flag.warn { background: var(--amber-bg,#2a2510); color: var(--amber,#fbbf24); }
/* 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-item.stop-done { opacity: 0.5; }
.stop-item.stop-done .stop-num { background: var(--green,#3fb950) !important; }
.stop-item.stop-current {
background: var(--accent-bg,#1a2744); border-color: var(--accent,#58a6ff);
border-left: 3px solid var(--accent,#58a6ff);
}
.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; }
}
/* GPS Map Editor */
.gps-lock-btn {
font-size: 16px; cursor: pointer; user-select: none;
padding: 2px 6px; border-radius: 4px; transition: background 0.15s;
float: right; line-height: 1;
}
.gps-lock-btn:hover { background: rgba(255,255,255,0.08); }
.gps-lock-btn.unlocked { background: var(--amber-bg); border-radius: 4px; }
#detailGpsMap {
z-index: 1; overflow: hidden;
}
#detailGpsMap .leaflet-container {
border-radius: 8px;
}
/* Game-icons sprite references */
.gi {
display: inline-block;
width: 1em;
height: 1em;
fill: currentColor;
vertical-align: -0.125em;
line-height: 1;
}
</style>
</head>
<body>
<!-- ═══════════════════════════════════════════════════════════════════════
HEADER
═══════════════════════════════════════════════════════════════════════ -->
<div class="header">
<button class="hamburger" onclick="openDrawer()" aria-label="Menu" title="Menu (Escape to close)"><svg class="gi" aria-hidden="true"><use href="#gi-menu"/></svg></button>
<h1><svg class="gi" aria-hidden="true"><use href="#gi-crate"/></svg> Canteen Assets</h1>
<div class="header-badges">
<button class="help-btn" onclick="showHelpModal()" title="Help &amp; shortcuts" aria-label="Help"><svg class="gi" aria-hidden="true"><use href="#gi-help"/></svg></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"><svg class="gi" aria-hidden="true"><use href="#gi-position"/></svg> Tap GPS</span>
<span id="queueIndicator" class="queue-indicator" onclick="processOfflineQueue()" title="Offline queue — click to sync"><svg class="gi" aria-hidden="true"><use href="#gi-download"/></svg> <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><svg class="gi" aria-hidden="true"><use href="#gi-satellite"/></svg></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"><svg class="gi" aria-hidden="true"><use href="#gi-cross"/></svg></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"><svg class="gi" aria-hidden="true"><use href="#gi-camera"/></svg></span> Find Asset
</button>
<button class="dn-item" data-tab="tabAssets" onclick="navFromDrawer('tabAssets')">
<span class="dn-icon"><svg class="gi" aria-hidden="true"><use href="#gi-crate"/></svg></span> Assets
</button>
<button class="dn-item" data-tab="tabMap" onclick="navFromDrawer('tabMap')">
<span class="dn-icon"><svg class="gi" aria-hidden="true"><use href="#gi-map"/></svg></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"><svg class="gi" aria-hidden="true"><use href="#gi-compass"/></svg></span> Nav
</button>
<button class="dn-item" data-tab="tabRoute" onclick="navFromDrawer('tabRoute')">
<span class="dn-icon"><svg class="gi" aria-hidden="true"><use href="#gi-map"/></svg></span> Route
</button>
<button class="dn-item" data-tab="tabWorkOrders" onclick="navFromDrawer('tabWorkOrders')">
<span class="dn-icon"><svg class="gi" aria-hidden="true"><use href="#gi-wrench"/></svg></span> Work Orders
</button>
<button class="dn-item" id="logoutBtn" onclick="doLogout()" style="display:none;">
<span class="dn-icon"><svg class="gi" aria-hidden="true"><use href="#gi-exit"/></svg></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 · <svg class="gi" aria-hidden="true"><use href="#gi-help"/></svg> for help
</div>
<a href="https://admin.canteen.ourpad.casa" target="_blank" rel="noopener" class="admin-only" style="display:none;font-size:11px;color:var(--accent2);text-decoration:none;"><svg class="gi" aria-hidden="true"><use href="#gi-gear"/></svg> 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" data-mode="barcode" onclick="setAddAssetMode('barcode')"><svg class="gi" aria-hidden="true"><use href="#gi-camera"/></svg> Barcode</button>
<button class="mode-toggle active" data-mode="ocr" onclick="setAddAssetMode('ocr')"><svg class="gi" aria-hidden="true"><use href="#gi-search"/></svg> OCR</button>
</div>
<!-- ── BARCODE MODE ──────────────────────────────────────────────── -->
<div id="addBarcodeMode" class="add-mode">
<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"><svg class="gi" aria-hidden="true"><use href="#gi-camera"/></svg></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 id="manualSearchFallback" style="margin-top:12px;display:none;">
<div style="font-size:12px;color:var(--text2);margin-bottom:6px;">Or enter ID manually:</div>
<input type="text" id="manualBarcodeInput" class="input-field" placeholder="Connect ID or barcode..." onkeypress="if(event.key==='Enter')handleManualBarcode()">
<button class="btn btn-primary btn-sm" onclick="handleManualBarcode()" style="margin-top:6px;">Search</button>
</div>
</div>
</div>
<!-- ── OCR MODE ──────────────────────────────────────────────────── -->
<div id="addOcrMode" class="add-mode active">
<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"><svg class="gi" aria-hidden="true"><use href="#gi-camera"/></svg></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"><svg class="gi" aria-hidden="true"><use href="#gi-camera"/></svg></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"><svg class="gi" aria-hidden="true"><use href="#gi-cross"/></svg></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">
<svg class="gi" aria-hidden="true"><use href="#gi-target"/></svg> 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>──── Groups ────</option>
<option value="group:park">🏰 Disney Park (all parks)</option>
<option value="group:resort">🏨 Disney Resort</option>
<option value="group:office">🏢 Disney Office</option>
<option value="group:springs">🛍️ Disney Springs</option>
<option value="group:other">📍 Other Disney</option>
<option disabled>── Individual ──</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%;">
<svg class="gi" aria-hidden="true"><use href="#gi-warning"/></svg> 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;"><svg class="gi" aria-hidden="true"><use href="#gi-cross"/></svg> Clear all filters</button>
<button class="btn btn-outline btn-xs" onclick="toggleFilterPanel()" style="flex:1;"><svg class="gi" aria-hidden="true"><use href="#gi-check"/></svg> 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>
<!-- GPS Map Editor -->
<div class="card" id="detailGpsCard" style="display:none;">
<div class="card-title">
<svg class="gi" aria-hidden="true"><use href="#gi-position"/></svg> Location
<span id="detailGpsLockBtn" class="gps-lock-btn" title="GPS is locked — click to edit"><svg class="gi" aria-hidden="true"><use href="#gi-lock"/></svg></span>
</div>
<div id="detailGpsInfo" style="font-size:12px;color:var(--text2);margin-bottom:6px;"></div>
<div id="detailGpsMap" style="height:300px;border-radius:8px;margin-bottom:8px;border:1px solid var(--border);"></div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<button id="detailGpsMyGpsBtn" class="btn btn-outline btn-sm" onclick="getCurrentGps()" title="Get device GPS coordinates"><svg class="gi" aria-hidden="true"><use href="#gi-bullseye"/></svg> My GPS</button>
<button id="detailGpsSaveBtn" class="btn btn-green btn-sm" onclick="saveGpsCoords()" style="display:none;"><svg class="gi" aria-hidden="true"><use href="#gi-save"/></svg> Save GPS</button>
<span id="detailGpsCoords" style="font-size:12px;color:var(--text2);font-family:monospace;"></span>
<span id="detailGpsStatus" style="font-size:11px;color:var(--text3);"></span>
</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"><svg class="gi" aria-hidden="true"><use href="#gi-camera"/></svg> 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()"><svg class="gi" aria-hidden="true"><use href="#gi-cross"/></svg></span>
</div>
<!-- Directions card -->
<div class="card" id="detailDirections" style="display:none;">
<div class="card-title"><svg class="gi" aria-hidden="true"><use href="#gi-position"/></svg> Directions &amp; Access</div>
<div id="detailDirFields"></div>
</div>
<!-- Keys & Badges -->
<div class="card" id="detailAccess" style="display:none;">
<div class="card-title"><svg class="gi" aria-hidden="true"><use href="#gi-key"/></svg> Access</div>
<div id="detailAccessFields"></div>
</div>
<!-- Service Entrances -->
<div class="card" id="detailServiceEntrances" style="display:none;">
<div class="card-title"><svg class="gi" aria-hidden="true"><use href="#gi-truck"/></svg> 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"><svg class="gi" aria-hidden="true"><use href="#gi-chart"/></svg> 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"><svg class="gi" aria-hidden="true"><use href="#gi-seed"/></svg> 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;"><svg class="gi" aria-hidden="true"><use href="#gi-check"/></svg> Check In</button>
<button class="btn btn-outline btn-sm" id="detailDirectionsBtn" onclick="openMapLink()" style="flex:1;min-width:100px;display:none;"><svg class="gi" aria-hidden="true"><use href="#gi-map"/></svg> Directions</button>
<button class="btn btn-outline btn-sm" id="detailNavigateBtn" onclick="navigateToAsset()" style="flex:1;min-width:100px;display:none;"><svg class="gi" aria-hidden="true"><use href="#gi-compass"/></svg> 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()"><svg class="gi" aria-hidden="true"><use href="#gi-flame"/></svg> Heatmap</span>
<span class="map-chip park-on" id="chipParkOutlines" onclick="toggleParkOutlines()"><svg class="gi" aria-hidden="true"><use href="#gi-castle"/></svg> 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)})"><svg class="gi" aria-hidden="true"><use href="#gi-bullseye"/></svg> 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;"><svg class="gi" aria-hidden="true"><use href="#gi-cross"/></svg></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">
<svg class="gi" aria-hidden="true"><use href="#gi-target"/></svg> 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>──── Groups ────</option>
<option value="group:park">🏰 Disney Park (all parks)</option>
<option value="group:resort">🏨 Disney Resort</option>
<option value="group:office">🏢 Disney Office</option>
<option value="group:springs">🛍️ Disney Springs</option>
<option value="group:other">📍 Other Disney</option>
<option disabled>── Individual ──</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;"><svg class="gi" aria-hidden="true"><use href="#gi-cross"/></svg> Clear all filters</button>
<button class="btn btn-outline btn-xs" onclick="toggleMapFilterPanel()" style="flex:1;"><svg class="gi" aria-hidden="true"><use href="#gi-check"/></svg> 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;"><svg class="gi" aria-hidden="true"><use href="#gi-position"/></svg> Your location is needed for navigation</div>
<button class="btn btn-sm btn-primary" onclick="captureNavGps()" style="width:100%;"><svg class="gi" aria-hidden="true"><use href="#gi-position"/></svg> 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;"><svg class="gi" aria-hidden="true"><use href="#gi-search"/></svg> 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;"><svg class="gi" aria-hidden="true"><use href="#gi-car"/></svg> Driving</button>
<button class="btn btn-sm btn-outline" onclick="navigateToAsset('walking')" style="flex:1;"><svg class="gi" aria-hidden="true"><use href="#gi-run"/></svg> Walking</button>
</div>
<div class="card" id="navInfoCard" style="display:none;">
<div class="card-title"><svg class="gi" aria-hidden="true"><use href="#gi-ruler"/></svg> 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"><svg class="gi" aria-hidden="true"><use href="#gi-run"/></svg> 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;"><svg class="gi" aria-hidden="true"><use href="#gi-compass"/></svg> 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><svg class="gi" aria-hidden="true"><use href="#gi-person"/></svg> 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>
<!-- Filter bar (matching WO tab style) -->
<div style="text-align:left;margin-bottom:10px;">
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:6px;">
<button class="filter-toggle" id="rpFilterToggle" onclick="rpToggleFilterPanel()" title="Toggle filter panel" style="font-size:12px;padding:4px 10px;">
<svg class="gi" aria-hidden="true"><use href="#gi-target"/></svg> Filters <span id="rpFilterCount" class="ft-count" style="display:none;">0</span>
</button>
<span style="font-size:11px;color:var(--text2,#999);">Apply filters before loading route</span>
</div>
<div class="filter-panel" id="rpFilterPanel" style="display:none;">
<div class="fp-row">
<div style="flex:1">
<div class="fp-label">Status</div>
<select id="rpFilterStatus" class="input-field" onchange="rpOnFilterChange()">
<option value="">All statuses</option>
</select>
</div>
<div style="flex:1">
<div class="fp-label">Priority</div>
<select id="rpFilterPriority" class="input-field" onchange="rpOnFilterChange()">
<option value="">All priorities</option>
<option value="Urgent">🔴 Urgent</option>
<option value="High">🟠 High</option>
<option value="Medium">🟡 Medium</option>
<option value="Low">🟢 Low</option>
</select>
</div>
</div>
<div class="fp-row">
<div style="flex:1">
<div class="fp-label">Work Type</div>
<select id="rpFilterType" class="input-field" onchange="rpOnFilterChange()">
<option value="">All types</option>
<option value="Install">🔧 Install</option>
<option value="Repair">🔧 Repair</option>
<option value="PM">📋 PM</option>
<option value="Emergency">🚨 Emergency</option>
</select>
</div>
<div style="flex:1">
<div class="fp-label">Date Range</div>
<select id="rpFilterDate" class="input-field" onchange="rpOnFilterChange()">
<option value="today">Today</option>
<option value="week">This Week</option>
<option value="month">This Month</option>
<option value="all">🌐 All</option>
</select>
</div>
</div>
<div class="fp-actions">
<button class="btn btn-outline btn-xs" onclick="rpClearFilters()" style="flex:1;"><svg class="gi" aria-hidden="true"><use href="#gi-cross"/></svg> Clear filters</button>
<button class="btn btn-outline btn-xs" onclick="rpToggleFilterPanel()" style="flex:1;"><svg class="gi" aria-hidden="true"><use href="#gi-check"/></svg> Done</button>
</div>
</div>
<div class="active-filters" id="rpActiveFilters"></div>
</div>
<div class="btn-row">
<button class="btn btn-primary" style="font-size:15px;padding:12px 24px;flex:1;" onclick="rpLoadToday()">
<svg class="gi" aria-hidden="true"><use href="#gi-rocket"/></svg> Load Route
</button>
</div>
<p style="font-size:11px;color:var(--text2,#999);margin-top:6px;">Finds open/scheduled work orders matching filters and plans the optimal route</p>
</div>
<!-- Starting Point -->
<div class="route-card">
<h3><svg class="gi" aria-hidden="true"><use href="#gi-position"/></svg> 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()"><svg class="gi" aria-hidden="true"><use href="#gi-position"/></svg> 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)"><svg class="gi" aria-hidden="true"><use href="#gi-wrench"/></svg> Oviedo Shop</button>
<button class="btn btn-secondary btn-sm" onclick="rpSetOrigin(28.4945,-81.5774)"><svg class="gi" aria-hidden="true"><use href="#gi-factory"/></svg> 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')"><svg class="gi" aria-hidden="true"><use href="#gi-paper"/></svg> Paste WOs</div>
<div class="rp-tab" onclick="rpSwitchInputTab('search')"><svg class="gi" aria-hidden="true"><use href="#gi-search"/></svg> 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;"><svg class="gi" aria-hidden="true"><use href="#gi-rocket"/></svg> 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;"><svg class="gi" aria-hidden="true"><use href="#gi-rocket"/></svg> 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()"><svg class="gi" aria-hidden="true"><use href="#gi-paper"/></svg> Print</button>
<button class="btn btn-secondary btn-sm" onclick="rpCopyText()"><svg class="gi" aria-hidden="true"><use href="#gi-paper"/></svg> 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><svg class="gi" aria-hidden="true"><use href="#gi-position"/></svg> 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);"><svg class="gi" aria-hidden="true"><use href="#gi-warning"/></svg> Stops without GPS</h3>
<div id="rpNoGpsList"></div>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════════════
TAB: WORK ORDERS
═══════════════════════════════════════════════════════════════════════ -->
<div id="tabWorkOrders" class="tab-panel">
<div id="woListView">
<!-- Search bar -->
<div class="search-bar">
<input id="woSearch" type="text" class="input-field" placeholder="Search WO#, account, city..." oninput="loadWorkOrders()">
<button class="clear-btn" id="woClearSearch" onclick="clearWOSearch()" title="Clear search" style="display:none;"><svg class="gi" aria-hidden="true"><use href="#gi-cross"/></svg></button>
</div>
<!-- Date range chips -->
<div style="display:flex;gap:6px;margin-bottom:8px;flex-wrap:wrap;">
<span class="wo-date-chip" data-range="today" onclick="setWODateRange('today')">Today</span>
<span class="wo-date-chip" data-range="week" onclick="setWODateRange('week')">This Week</span>
<span class="wo-date-chip" data-range="month" onclick="setWODateRange('month')">This Month</span>
<span class="wo-date-chip" data-range="custom" onclick="setWODateRange('custom')">Custom</span>
<span class="wo-date-chip wo-all-chip" data-range="all" onclick="setWODateRange('all')">🌐 All</span>
</div>
<!-- Custom date picker (hidden by default) -->
<div id="woCustomDateRow" style="display:none;gap:8px;margin-bottom:8px;align-items:center;flex-wrap:wrap;">
<label style="font-size:12px;color:var(--text2);">From:</label>
<input id="woDateFrom" type="date" class="input-field" style="flex:1;min-width:120px;" onchange="onWODateCustomChange()">
<label style="font-size:12px;color:var(--text2);">To:</label>
<input id="woDateTo" type="date" class="input-field" style="flex:1;min-width:120px;" onchange="onWODateCustomChange()">
</div>
<!-- Filter toggle -->
<div style="display:flex;gap:6px;align-items:flex-start;margin-bottom:8px;flex-wrap:wrap;">
<button class="filter-toggle" id="woFilterToggle" onclick="toggleWOFilterPanel()" title="Toggle filter panel">
<svg class="gi" aria-hidden="true"><use href="#gi-target"/></svg> Filters <span id="woFilterCount" class="ft-count" style="display:none;">0</span>
</button>
</div>
<!-- Filter panel (collapsible) -->
<div class="filter-panel" id="woFilterPanel">
<div class="fp-row">
<div style="flex:1">
<div class="fp-label">Status</div>
<select id="woFilterStatus" class="input-field" onchange="onWOFilterChange()">
<option value="">All statuses</option>
</select>
</div>
<div style="flex:1">
<div class="fp-label">Technician</div>
<select id="woFilterTech" class="input-field" onchange="onWOFilterChange()">
<option value="">All technicians</option>
</select>
</div>
</div>
<div class="fp-row">
<div style="flex:1">
<div class="fp-label">Priority</div>
<select id="woFilterPriority" class="input-field" onchange="onWOFilterChange()">
<option value="">All priorities</option>
<option value="Urgent">🔴 Urgent</option>
<option value="High">🟠 High</option>
<option value="Medium">🟡 Medium</option>
<option value="Low">🟢 Low</option>
</select>
</div>
<div style="flex:1">
<div class="fp-label">Work Type</div>
<select id="woFilterType" class="input-field" onchange="onWOFilterChange()">
<option value="">All types</option>
<option value="Install">🔧 Install</option>
<option value="Repair">🔧 Repair</option>
<option value="PM">📋 PM</option>
<option value="Emergency">🚨 Emergency</option>
</select>
</div>
</div>
<div class="fp-actions">
<button class="btn btn-outline btn-xs" onclick="clearAllWOFilters()" style="flex:1;"><svg class="gi" aria-hidden="true"><use href="#gi-cross"/></svg> Clear all filters</button>
<button class="btn btn-outline btn-xs" onclick="toggleWOFilterPanel()" style="flex:1;"><svg class="gi" aria-hidden="true"><use href="#gi-check"/></svg> Done</button>
</div>
</div>
<div class="active-filters" id="woActiveFilters"></div>
<div class="result-count" id="woResultCount"></div>
<div id="woList"></div>
<div id="woPagination" class="pagination-bar" style="display:none;margin-top:8px;"></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);"><svg class="gi" aria-hidden="true"><use href="#gi-wrench"/></svg> Machine Details</span>
<button onclick="rpCloseAssetModal()" style="background:none;border:none;color:var(--text2,#999);font-size:20px;cursor:pointer;padding:4px 8px;"><svg class="gi" aria-hidden="true"><use href="#gi-cross"/></svg></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"><svg class="gi" aria-hidden="true"><use href="#gi-camera"/></svg></span> Find Asset
</button>
<button class="tab-btn" data-tab="tabAssets" onclick="switchTab('tabAssets')" title="Browse and search your assets">
<span class="tab-icon"><svg class="gi" aria-hidden="true"><use href="#gi-crate"/></svg></span> Assets
</button>
<button class="tab-btn" data-tab="tabMap" onclick="switchTab('tabMap')" title="View all assets on a map">
<span class="tab-icon"><svg class="gi" aria-hidden="true"><use href="#gi-map"/></svg></span> Map
</button>
<button class="tab-btn" data-tab="tabNavigate" onclick="switchTab('tabNavigate')" title="Navigate to an asset">
<span class="tab-icon"><svg class="gi" aria-hidden="true"><use href="#gi-compass"/></svg></span> Nav
</button>
<button class="tab-btn" data-tab="tabWorkOrders" onclick="switchTab('tabWorkOrders')" title="View work orders">
<span class="tab-icon">🔧</span> Orders
</button>
<button class="tab-btn" data-tab="tabRoute" onclick="switchTab('tabRoute')" title="Plan a route">
<span class="tab-icon"><svg class="gi" aria-hidden="true"><use href="#gi-map"/></svg></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;"><svg class="gi" aria-hidden="true"><use href="#gi-help"/></svg> 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>
<li><kbd>?</kbd> — Show this help modal (anywhere)</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 <svg class="gi" aria-hidden="true"><use href="#gi-menu"/></svg> 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"><svg class="gi" aria-hidden="true"><use href="#gi-lock"/></svg></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;
}
}
// 15s timeout via Promise.race instead of AbortController — the SW
// intercepts all API requests and AbortController signals don't properly
// propagate through the Service Worker layer, causing indefinite hangs
// on mobile browsers (Chrome/Safari known bug).
const TIMEOUT_MS = 10000;
const fetchPromise = fetch(url, opts);
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Request timed out — check your connection')), TIMEOUT_MS)
);
try {
let res;
try {
res = await Promise.race([fetchPromise, timeoutPromise]);
} catch (e) {
// timeoutPromise won — the fetch may still complete in the background
// but the user gets a clear timeout message instead of hanging.
if (e.message && e.message.includes('timed out')) throw e;
// If fetchPromise rejects first (network error), re-throw
throw e;
}
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'; }
function isAdmin() { return AppState.currentUser && AppState.currentUser.role === 'admin'; }
// ── 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';
});
// Admin-only elements
document.querySelectorAll('.admin-only').forEach(el => {
el.style.display = isAdmin() ? 'block' : '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;
}
// ? — show keyboard shortcut overlay (only when not typing in an input)
if (e.key === '?' && !e.ctrlKey && !e.metaKey && !e.altKey) {
var tag = document.activeElement?.tagName || '';
if (!['INPUT', 'TEXTAREA', 'SELECT'].includes(tag)) {
e.preventDefault();
showShortcutOverlay();
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.innerHTML = gi('📍') + ` ${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.innerHTML = gi('📍') + ' Tap for GPS';
else badge.innerHTML = gi('📍') + ' ' + 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.innerHTML = gi('📍') + ' 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.innerHTML = gi('📍') + ' Locating...';
badge.className = 'gps-badge waiting';
requestLocation().then(() => {
updateGpsBadge();
// Re-enable for next re-capture
badge.onclick = arguments.callee;
}).catch(() => {
badge.innerHTML = gi('📍') + ' 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
});
// Stop pulsing animation after 10s if GPS still hasn't resolved
setTimeout(() => {
if (badge.classList.contains('waiting')) {
badge.style.animation = 'none';
}
}, 10000);
}
function updateGpsBadge() {
const badge = document.getElementById('gpsBadge');
if (AppState.gpsLat != null) {
badge.className = 'gps-badge ok';
badge.innerHTML = gi('📍') + ` ${AppState.gpsLat.toFixed(4)}, ${AppState.gpsLng.toFixed(4)}`;
} else {
badge.className = 'gps-badge waiting';
badge.innerHTML = gi('📍') + ' 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);
}
// =========================================================================
// OFFLINE QUEUE
// =========================================================================
const offlineQueue = [];
function updateOnlineStatus() {
const banner = document.getElementById('offlineBanner');
const indicator = document.getElementById('queueIndicator');
if (!navigator.onLine) {
if (banner) banner.classList.add('show');
if (indicator) indicator.style.display = offlineQueue.length > 0 ? 'flex' : 'none';
showToast('📡 Offline — changes will sync when connected', false);
} else {
if (banner) banner.classList.remove('show');
if (indicator) indicator.style.display = 'none';
if (offlineQueue.length > 0) {
processOfflineQueue();
}
}
}
function processOfflineQueue() {
const count = offlineQueue.length;
if (count === 0) {
showToast('No queued actions to sync', false);
return;
}
showToast('Syncing ' + count + ' queued action(s)...', false);
const queue = [...offlineQueue];
offlineQueue.length = 0;
let synced = 0;
let failed = 0;
queue.forEach((item, i) => {
// Attempt each queued action
const attempt = item.action();
if (attempt && typeof attempt.then === 'function') {
attempt.then(() => synced++).catch(() => {
failed++;
offlineQueue.push(item); // Re-queue on failure
});
} else {
synced++;
}
});
setTimeout(() => {
updateOnlineStatus();
if (failed === 0) {
showToast('✅ Synced ' + synced + ' action(s)', false);
} else {
showToast('⚠️ Synced ' + synced + ', ' + failed + ' failed (will retry)', true);
}
}, 500);
}
function enqueueOffline(actionFn, description) {
offlineQueue.push({ action: actionFn, description: description || 'queued action', timestamp: Date.now() });
const countEl = document.getElementById('queueIndicatorCount');
if (countEl) countEl.textContent = offlineQueue.length;
const indicator = document.getElementById('queueIndicator');
if (indicator) indicator.style.display = 'flex';
showToast('📡 Saved offline: ' + (description || 'action') + ' — will sync when connected', false);
}
// =========================================================================
// 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.innerHTML = 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 showShortcutOverlay() {
// Use the help modal as the overlay (it already lists all shortcuts)
document.getElementById('helpModalOverlay').classList.add('open');
showToast('Press ? again or Esc to close', false);
}
function closeHelpModal(e) {
if (e && e.target !== e.currentTarget) return;
document.getElementById('helpModalOverlay').classList.remove('open');
localStorage.setItem('canteen_help_seen', '1');
}
document.getElementById('modalConfirmBtn').addEventListener('click', () => closeModal(true));
// =========================================================================
// DRAWER
// =========================================================================
function openDrawer() {
// Don't open drawer when login overlay is showing
if (!document.getElementById('loginOverlay').classList.contains('hidden')) return;
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',
tabWorkOrders: 'tabWorkOrders',
};
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 — sync visual panel + start correct camera
if (tabId === 'tabAddAsset') {
setAddAssetMode(addAssetMode);
} 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();
}
// Work Orders tab — load data on first visit
if (tabId === 'tabWorkOrders') {
// Ensure default chip state
document.querySelectorAll('.wo-date-chip').forEach(function(chip) {
chip.classList.toggle('active', chip.dataset.range === woFilters.date_range);
});
loadWorkOrders();
}
// 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 = 'ocr';
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();
} else if (mode === 'ocr') {
startOcrCamera();
}
}
// ── 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() {
// Focused 1D formats for fast scanning on mobile.
// Removed TRY_HARDER (too slow on mobile) and 2D formats (QR, DataMatrix,
// PDF417, Aztec) which are rare for canteen machine 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.ITF,
];
hints.set(ZXing.DecodeHintType.POSSIBLE_FORMATS, fmts);
return hints;
}
function _barcodeOptions() {
return {
delayBetweenScanAttempts: 300,
delayBetweenScanSuccess: 2000, // 2s cooldown after a successful scan
};
}
let zxingLoadAttempted = false;
function checkZxingLoaded() {
if (typeof ZXing !== 'undefined') return true;
if (!zxingLoadAttempted) {
zxingLoadAttempted = true;
console.warn('ZXing library failed to load from CDN');
}
return false;
}
async function startScanning() {
if (addAssetMode !== 'barcode') return;
if (scanningActive) return;
// Check ZXing loaded from CDN
if (!checkZxingLoaded()) {
setScanStatus('Barcode library not loaded — enter ID manually below', 'error');
showFallbackCameraError('Barcode scanner library failed to load.<br>Enter the ID manually instead.');
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
// NOTE: BrowserMultiFormatReader constructor takes 0 args — hints and options
// must be set as instance properties for them to take effect.
codeReader = new ZXing.BrowserMultiFormatReader();
codeReader.reader.hints = _barcodeHints();
codeReader.timeBetweenScansMillis = 2000; // delayBetweenScanSuccess
codeReader._timeBetweenDecodingAttempts = 300; // delayBetweenScanAttempts
// 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) {
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');
// Camera readiness check — give it 10s (was 5s, too tight for some mobile browsers)
const cameraFailTimer = setTimeout(() => {
if (video.readyState === 0 && scanningActive) {
scanningActive = false;
video.style.display = 'none';
if (codeReader) {
try { codeReader.reset(); } catch (e) { /* ignore */ }
}
handleCameraError(new Error('No camera stream — connect a camera or enter ID manually'));
}
}, 10000);
codeReader.decodeFromVideoDevice(deviceId, video, (result, err) => {
clearTimeout(cameraFailTimer);
if (result && scanningActive) {
const val = result.getText();
if (val && val !== currentScannedMachineId) {
handleBarcode(val.trim());
}
} else if (err && scanningActive) {
// NotFoundException, ChecksumException, FormatException are normal
// "no barcode on this frame" results — silently continue scanning.
// We use instanceof because ZXing's minified exception classes all
// have message="undefined", making message-text filtering unreliable.
if (!(err instanceof ZXing.NotFoundException ||
err instanceof ZXing.ChecksumException ||
err instanceof ZXing.FormatException)) {
console.error('ZXing camera error:', err);
handleCameraError(new Error(err.message || err));
}
}
});
} catch (e) {
console.error('Camera error:', e);
handleCameraError(e);
}
}
function handleCameraError(e) {
// Stop scanning so ZXing doesn't fire more callbacks that re-create the
// fallback UI in a loop — each re-create re-focuses the input, causing
// the mobile keyboard to pop repeatedly.
scanningActive = false;
if (codeReader) {
try { codeReader.reset(); } catch (_) { /* ignore */ }
codeReader = null;
}
setScanStatus('Camera failed: ' + e.message, 'error');
const ph = document.getElementById('cameraPlaceholder');
if (ph) {
ph.style.display = 'block';
ph.innerHTML = `<span class="cam-icon">${gi("📷")}</span><div class="cam-hint" style="margin-top:4px;">Camera: ${e.message}</div>`;
}
// Show manual search fallback
const fallback = document.getElementById('manualSearchFallback');
if (fallback) {
fallback.style.display = 'block';
fallback.innerHTML = `
<div style="background:var(--red-bg);color:var(--red);padding:10px;border-radius:8px;margin-bottom:10px;font-size:13px;">
<strong>Camera unavailable</strong><br>
${getCameraErrorMessage(e)}
</div>
<div style="font-size:12px;color:var(--text2);margin-bottom:6px;">Enter ID manually:</div>
<input type="text" id="manualBarcodeInput" class="input-field" placeholder="Connect ID or barcode..." onkeypress="if(event.key==='Enter')handleManualBarcode()">
<button class="btn btn-primary btn-sm" onclick="handleManualBarcode()" style="margin-top:6px;width:100%;">Search</button>
<button class="btn btn-outline btn-sm" onclick="startScanning()" style="margin-top:6px;width:100%;">Try camera again</button>
`;
// Focus the manual input for convenience
setTimeout(() => document.getElementById('manualBarcodeInput')?.focus(), 100);
}
}
function showFallbackCameraError(extraMsg) {
// Show just the manual search fallback without re-creating placeholder
const fallback = document.getElementById('manualSearchFallback');
if (fallback) {
fallback.style.display = 'block';
fallback.innerHTML = `
<div style="background:var(--red-bg);color:var(--red);padding:10px;border-radius:8px;margin-bottom:10px;font-size:13px;">
<strong>Camera unavailable</strong><br>
${extraMsg}
</div>
<div style="font-size:12px;color:var(--text2);margin-bottom:6px;">Enter ID manually:</div>
<input type="text" id="manualBarcodeInput" class="input-field" placeholder="Connect ID or barcode..." onkeypress="if(event.key==='Enter')handleManualBarcode()">
<button class="btn btn-primary btn-sm" onclick="handleManualBarcode()" style="margin-top:6px;width:100%;">Search</button>
<button class="btn btn-outline btn-sm" onclick="startScanning()" style="margin-top:6px;width:100%;">Try camera again</button>
`;
setTimeout(() => document.getElementById('manualBarcodeInput')?.focus(), 100);
}
}
function stopScanning() {
scanningActive = false;
// Explicitly stop all camera tracks BEFORE resetting ZXing — on iOS
// Safari, just setting srcObject=null via codeReader.reset() doesn't
// release the hardware immediately, and an active camera stream can
// block fetch() on mobile browsers.
const video = document.getElementById('cameraVideo');
if (video) {
const stream = video.srcObject;
if (stream && typeof stream.getTracks === 'function') {
stream.getTracks().forEach(t => { try { t.stop(); } catch (_) {} });
}
video.srcObject = null;
}
if (codeReader) {
try { codeReader.reset(); } catch (e) { /* ignore */ }
codeReader = null;
}
if (video) video.style.display = 'none';
const ph = document.getElementById('cameraPlaceholder');
if (ph) {
ph.style.display = '';
ph.innerHTML = '<span class="cam-icon">' + gi('📷') + '</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; }
}
function getCameraErrorMessage(err) {
if (err.name === 'NotAllowedError') {
return 'Camera permission denied. Please allow camera access in your browser settings and reload.';
}
if (err.name === 'NotFoundError') {
return 'No camera found on this device. Enter an ID manually below.';
}
if (err.name === 'NotReadableError') {
return 'Camera is in use by another app. Close other apps using the camera and try again.';
}
if (err.name === 'OverconstrainedError') {
return 'No suitable camera found. Try a different device or enter an ID manually.';
}
return err.message || 'Unknown camera error. Try entering an ID manually.';
}
function handleManualBarcode() {
const input = document.getElementById('manualBarcodeInput');
if (!input) return;
const value = input.value.trim();
if (!value) {
showToast('Please enter a Connect ID or barcode', true);
return;
}
input.value = '';
handleBarcode(value);
}
async function handleBarcode(machineId) {
try {
if (barcodeDebounce) return;
barcodeDebounce = machineId;
currentScannedMachineId = machineId;
// Stop the camera scanner before the API call — on mobile browsers
// an active camera stream can throttle concurrent fetch requests.
stopScanning();
// Yield to the event loop so the browser fully releases camera hardware
// before we issue the fetch — critical on iOS Safari where an active
// stream can block concurrent network requests.
await new Promise(r => setTimeout(r, 200));
setScanStatus(`Scanned: ${machineId} — looking up...`, 'working');
const sr = document.getElementById('scanResult');
if (sr) sr.style.display = 'none';
const cc = document.getElementById('checkinCard');
if (cc) cc.style.display = 'none';
try {
const asset = await api(`/api/assets/search?machine_id=${encodeURIComponent(machineId)}`);
// Asset exists — show result in-place with scan new button
showToast(`${esc(asset.name)} — found!`, false);
showScannedAsset(asset);
} catch (e) {
if (e.message === 'Asset not found' || e.message.includes('404')) {
showNewAssetForm(machineId);
} else {
setScanStatus('Lookup error: ' + e.message, 'error');
}
}
setTimeout(() => { barcodeDebounce = null; }, 2000);
} catch (e) {
// Safety net — any unexpected error (DOM null, stopScanning fail, etc.)
// gets surfaced rather than being silently swallowed.
setScanStatus('Error: ' + (e.message || e), '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"><span style="margin-right:6px">${categoryInfo(asset.category).icon}</span>${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>
<button class="btn btn-outline btn-sm" onclick="registerNewAsset('${esc(asset.machine_id)}')" style="flex:1;"> Register</button>
<button class="btn btn-primary btn-sm" onclick="resetBarcodeScanner()" style="flex:1;">Scan New</button>
</div>
`;
// If we have GPS, auto-checkin silently
if (hasGps) {
doAutoCheckin(asset.id);
}
}
function resetBarcodeScanner() {
// Reset the scan result UI and restart barcode scanner
const sr = document.getElementById('scanResult');
if (sr) { sr.style.display = 'none'; sr.innerHTML = ''; }
const cc = document.getElementById('checkinCard');
if (cc) cc.style.display = 'none';
setScanStatus('Ready — scan another barcode', 'success');
stopScanning();
// Re-show the camera placeholder
const ph = document.getElementById('cameraPlaceholder');
if (ph) {
ph.style.display = 'block';
ph.innerHTML = '<span class="cam-icon">' + gi('📷') + '</span><div class="cam-hint">Tap to start camera</div>';
}
const cv = document.getElementById('cameraVideo');
if (cv) cv.style.display = 'none';
const fallback = document.getElementById('manualSearchFallback');
if (fallback) fallback.style.display = 'none';
scanningActive = false;
currentScannedMachineId = null;
// Auto-start the camera
setTimeout(startScanning, 300);
}
function resetOcrScanner() {
// Reset the OCR result UI and restart OCR camera
stopOcrCamera();
const or = document.getElementById('ocrResult');
if (or) { or.style.display = 'none'; or.innerHTML = ''; }
const og = document.getElementById('ocrGpsBadge');
if (og) og.style.display = 'none';
const oca = document.getElementById('ocrCameraArea');
if (oca) oca.style.display = 'block';
setOcrStatus('Point camera at the machine sticker and tap capture', 'success');
const ph = document.getElementById('ocrPlaceholder');
if (ph) {
ph.style.display = 'block';
ph.innerHTML = '<span class="cam-icon">' + gi('📷') + '</span><div class="cam-hint" style="margin-top:4px;">Tap to start camera</div>';
}
AppState.ocrGpsSaved = false;
// Auto-start the camera
setTimeout(startOcrCamera, 300);
}
async function doAutoCheckin(assetId) {
try {
await api('/api/checkins', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
asset_id: assetId,
user_id: AppState.currentUser?.id || null,
latitude: AppState.gpsLat,
longitude: AppState.gpsLng,
accuracy: AppState.gpsAcc,
notes: 'Auto check-in on scan',
}),
});
showToast('📍 GPS saved — check the Map tab');
} catch (e) {
console.warn('Auto check-in failed:', e);
showToast('GPS check-in failed: ' + (e.message || 'network error'), true);
}
}
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>
<div style="margin-top:10px;">
<button class="btn btn-outline btn-sm" onclick="registerNewAsset('${esc(machineId)}')" style="width:100%;margin-bottom:6px;"> Register as New Asset</button>
<button class="btn btn-primary btn-sm" onclick="resetBarcodeScanner()" style="width:100%;">Scan New</button>
</div>`;
}
// ── Register scanned barcode as a new asset ──────────────────────────
function registerNewAsset(machineId) {
const name = prompt('Enter a name for the new asset (machine_id: ' + machineId + '):');
if (!name) return;
api('/api/assets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
machine_id: machineId,
name: name,
category: 'other',
status: 'active',
}),
}).then(asset => {
showToast('✅ Asset created: ' + esc(asset.name), false);
showScannedAsset(asset);
}).catch(e => {
showToast('❌ Failed to create asset: ' + (e.message || e), true);
});
}
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.innerHTML = gi('📍') + ' 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.innerHTML = gi('📍') + ' Tap for GPS';
coords.style.display = 'none';
}
}
function captureCheckinGps() {
const status = document.getElementById('checkinGpsStatus');
status.innerHTML = gi('📍') + ' Locating...';
status.className = 'gps-badge waiting';
if (!navigator.geolocation) {
status.className = 'gps-badge err';
status.innerHTML = gi('📍') + ' 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.innerHTML = gi('📍') + ' Permission denied — tap to retry';
showToast('Please allow GPS access in your browser settings', true);
} else {
status.innerHTML = gi('📍') + ' ' + 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,
user_id: AppState.currentUser?.id || null,
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';
// ── Helper: render matched_assets list ──────────────────────────────
const hasMatched = data.matched_assets && data.matched_assets.length > 0;
let matchedHtml = '';
if (hasMatched) {
matchedHtml = '<div style="margin-top:8px;border-top:1px solid var(--border);padding-top:8px;">' +
'<div style="font-size:12px;font-weight:600;color:var(--text2);margin-bottom:6px;">📋 DB matches found by serial/ID:</div>';
data.matched_assets.forEach(function(m) {
matchedHtml += '<div style="background:var(--card2);border-radius:6px;padding:8px;margin-bottom:4px;">' +
'<div style="font-weight:600;font-size:14px;">' + esc(m.name || 'Unnamed') + '</div>' +
'<div style="font-size:11px;color:var(--text2);margin-top:2px;">' +
'ID: ' + esc(m.machine_id) + (m.serial_number ? ' · SN: ' + esc(m.serial_number) : '') +
' · matched: <code style="background:var(--accent-bg);padding:1px 4px;border-radius:3px;font-size:10px;">' + esc(m.matched_on) + '</code>' +
'</div>' +
'<button class="btn btn-outline btn-sm" onclick="switchTab(\'tabAssets\');viewAsset(' + m.asset_id + ');" style="margin-top:4px;width:100%;">View Details</button>' +
'</div>';
});
matchedHtml += '</div>';
}
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>` : ''}
${matchedHtml}
<div class="or-searching" style="margin-top:8px;font-size:13px;color:var(--text2);">🔍 Searching for asset...</div>
<div style="margin-top:8px;"><button class="btn btn-outline btn-sm" onclick="retakeOcr()" style="width:100%;">🔄 Retake</button></div>`;
setOcrStatus('Machine ID found! Searching...', 'working');
// Auto-search the asset
lookupOcrAsset(data.machine_id);
} else if (hasMatched) {
// Machine ID not found, but we have DB matches from serial/ID cross-reference
el.innerHTML = `
<div style="font-size:16px;font-weight:600;color:var(--green);">✅ Asset found via serial/ID match</div>
${data.raw_text ? `<div class="or-raw">OCR text: ${esc(data.raw_text)}</div>` : ''}
${matchedHtml}
<div style="margin-top:8px;display:flex;gap:6px;">
<button class="btn btn-primary btn-sm" onclick="resetOcrScanner()" style="flex:1;">Scan New</button>
<button class="btn btn-outline btn-sm" onclick="retakeOcr()" style="flex:1;">🔄 Retake</button>
</div>`;
setOcrStatus('Asset found via serial/ID match!', 'success');
} 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>` : ''}
<div style="margin-top:8px;"><button class="btn btn-outline btn-sm" onclick="retakeOcr()" style="width:100%;">🔄 Try Again</button></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.innerHTML = gi('📍') + ' 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');
const oci = categoryInfo(asset.category);
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;"><span style="margin-right:6px">${oci.icon}</span>${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);">${gi("📍")} GPS location saved to asset</div>`;
} catch (_) {}
}
} else {
el.innerHTML += `<div style="margin-top:8px;font-size:12px;color:var(--amber);">${gi("📍")} GPS not available — check-in skipped</div>`;
}
if (AppState.ocrGpsSaved) {
el.innerHTML += `<div style="margin-top:8px;font-size:12px;color:var(--green);">${gi("📍")} GPS saved to asset</div>`;
AppState.ocrGpsSaved = false;
}
el.innerHTML += `<div style="margin-top:8px;display:flex;gap:6px;">
<button class="btn btn-outline btn-sm" onclick="switchTab('tabAssets');viewAsset(${asset.id});" style="flex:1;">View Details</button>
<button class="btn btn-primary btn-sm" onclick="resetOcrScanner()" style="flex:1;">Scan New</button>
<button class="btn btn-outline btn-sm" onclick="retakeOcr()" style="flex:1;">🔄 Retake</button>
</div>`;
} catch (e) {
if (e.message === 'Asset not found' || e.message.includes('404')) {
const el = document.getElementById('ocrResult');
// Try to find by serial number from OCR raw_text
(async function searchOcrSerials() {
const rawText = document.querySelector('#ocrResult .or-raw');
if (rawText) {
const numbers = rawText.textContent.match(/\d{4,}/g);
if (numbers) {
for (const n of numbers.slice(0, 3)) {
try {
const bySerial = await api('/api/assets/search?machine_id=' + encodeURIComponent(n.trim()));
if (bySerial) {
const oci = categoryInfo(bySerial.category);
el.innerHTML = `
<div class="or-id">${esc(machineId)} ❌ → ${esc(bySerial.machine_id)} ✅</div>
<div class="sr-name" style="font-size:16px;font-weight:700;margin:4px 0;"><span style="margin-right:6px">${oci.icon}</span>${esc(bySerial.name)}</div>
<div class="or-meta">Found via serial: ${esc(n)} · ${esc(catLabel(bySerial.category))}</div>
<div style="margin-top:8px;display:flex;gap:6px;">
<button class="btn btn-outline btn-sm" onclick="switchTab('tabAssets');viewAsset(${bySerial.id});" style="flex:1;">View Details</button>
<button class="btn btn-primary btn-sm" onclick="resetOcrScanner()" style="flex:1;">Scan New</button>
<button class="btn btn-outline btn-sm" onclick="retakeOcr()" style="flex:1;">🔄 Retake</button>
</div>`;
setOcrStatus('Asset found via serial number!', 'success');
AppState.currentAssetId = bySerial.id;
return;
}
} catch (_) {}
}
}
}
// No serial match either — show not-found
el.innerHTML = `
<div class="or-id">${esc(machineId)} ❌</div>
<div class="or-meta" style="color:var(--amber);">Machine ID not found in database</div>
<div style="margin-top:8px;"><button class="btn btn-outline btn-sm" onclick="retakeOcr()" style="width:100%;">🔄 Retake</button>
<button class="btn btn-outline btn-sm" onclick="registerNewAsset('${esc(machineId)}')" style="width:100%;margin-top:4px;"> Register as New Asset</button></div>`;
setOcrStatus('Asset not found', '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,
};
}
// ── Retake OCR photo after capture ──────────────────────────────────
function retakeOcr() {
// Reset the OCR UI for a new capture
document.getElementById('ocrResult').style.display = 'none';
document.getElementById('ocrResult').innerHTML = '';
document.getElementById('ocrGpsBadge').style.display = 'none';
setOcrStatus('', '');
const camArea = document.getElementById('ocrCameraArea');
if (camArea) camArea.style.display = '';
startOcrCamera();
}
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();
// Hide the camera viewport entirely — don't show placeholder after capture
const camArea = document.getElementById('ocrCameraArea');
if (camArea) camArea.style.display = 'none';
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,
user_id: AppState.currentUser?.id || null,
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: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 512 512"><path fill="currentColor" d="m156.6 342.7l3.5 18.7l-15.6 29.7l.1 2.6c1.1 51.3 16.2 75.3 31.8 84.3c13.7 9 28.9 7 40.3 2c6.5 8 18.5 17 39.3 17s32.8-9 39.3-17c11.4 5 26.6 7 40.3-2c15.6-9 30.7-33 31.8-84.3l.1-2.6l-15.6-29.7l3.5-18.7h-21.2l-4.1 21.9l16.4 31.3c-.5 16.1-2.4 29.1-5.4 39.1c-4.2 14-10.3 22-17.5 26c-9.2 5-19.3 2-24.9-3l-11.1-9l-5.3 13s-6.2 14-26.3 14s-26.3-14-26.3-14l-5.3-13l-11.1 9c-13.9 12-45.5 11-47.8-62.1l16.4-31.3l-4.1-21.9zm84.5 52.3c8.1 0 14.8 7 14.8 15s-6.7 15-14.8 15c-8.2 0-14.8-7-14.8-15s6.6-15 14.8-15m61.5-21.9c-2.5 9.2-11.9 14.6-21.1 12.2c-9.2-2.5-14.7-12-12.2-21.1c2.5-9.2 11.9-14.7 21.1-12.2c9.2 2.4 14.7 11.9 12.2 21.1M224.5 354c5.8 0 10.5 4.7 10.5 10.5s-4.7 10.5-10.5 10.5s-10.5-4.7-10.5-10.5s4.7-10.5 10.5-10.5M361 238H149l5.8 88h200.4zm-208.4-16.7h20.8c.7-44.9 22.9-58.1 40.5-73.2c13.1-11.3 24.1-23.4 26.9-44.7h30.4c2.8 21.3 13.8 33.4 26.9 44.7c17.6 15.1 39.8 28.3 40.5 73.2h20.8c-.5-45.8-19.1-64.9-37.7-80.6C307 128.2 291.5 119 291.4 93V82.6h-70.8V93c-.1 26-15.6 35.2-30.3 47.7c-18.6 15.7-37.2 34.8-37.7 80.6m86.1-40.1c6.8 0 12.4 5.6 12.4 12.4c0 6.9-5.6 12.4-12.4 12.4s-12.4-5.5-12.4-12.4c0-6.8 5.6-12.4 12.4-12.4m36.8-23.2c5.8 0 10.5 4.7 10.5 10.5s-4.7 10.5-10.5 10.5s-10.5-4.7-10.5-10.5s4.7-10.5 10.5-10.5M302 53.7h-92v16.6h92zm-82.8-37.2h73.6c2.5 0 4.8 1 6.5 2.7s2.7 4 2.7 6.5v15.8h-92V25.7c0-2.5 1-4.8 2.7-6.5s4-2.7 6.5-2.7"/></svg>', color: '#0a7ab5' },
'Snack': { icon: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 512 512"><path fill="currentColor" d="M132.684 31.388a1.4 1.4 0 0 0-.29.004c-.396.048-.768.25-1.398.609c-1.26.719-3.215 2.597-5.039 5.484c-3.648 5.776-6.593 15.148-7.516 22.34c-2.004 15.623 3.42 23.692 13.293 31.436c5 3.92 11.236 7.328 17.914 10.515c-3.23-13.668-3.189-27.344-3.732-38.859c-.419-8.882-1.232-16.509-3.107-21.623c-1.853-5.052-3.884-7.627-8.987-9.62c-.472-.156-.833-.262-1.138-.286m111.212 11.478c-27.446.122-54.797 1.187-81.492 2.78c.891 5.354 1.23 10.856 1.492 16.423c.048 1.01.092 2.025.137 3.037c21.693 20.288 34.01 21.811 44.826 20.485c5.516-.677 10.93-2.497 17.13-3.977a73 73 0 0 1 3.081-.658c8.481-8.258 19.868-14.662 33.342-20.033A61.8 61.8 0 0 1 287.7 56.56c8.175.303 15.944 2.17 22.854 5.03c6.499 2.69 12.318 6.169 17.218 10.23c2.24-.62 4.518-.928 6.819-.967c7.174-.12 14.565 2.29 21.66 5.385c11.352 4.952 22.62 12.632 31.791 20.908c2.124 1.917 4.14 3.82 6.033 5.737l16.018-3.11l2.63-1.925c13.21-9.668 22.782-20.853 30.01-31.409c-56.662-17.009-121.396-23.16-186.43-23.564q-6.204-.038-12.406-.01zm-142.01 7.254a4102 4102 0 0 0-36.077 3.254c1.134 11.198 6.268 21.673 18.056 32.58l3.17 2.934l22.328 4.808c-6.756-9.505-10.64-21.648-8.777-36.162c.309-2.406.74-4.894 1.3-7.414m183.048 24.371c-5.166.056-10.503 1.019-15.856 3.153C255.233 83.162 245 89.696 239.15 96.478c-5.85 6.78-7.914 13.268-6.27 22.378q.141.764.302 1.51l33.588 7.235l69.66-13.526c-4.783-5.828-9.006-12.019-12.248-18.79c-2.489-5.199-10.313-12.84-20.512-17.061c-5.1-2.111-10.69-3.475-16.533-3.702a44 44 0 0 0-2.203-.03zm57.095 15.85c3.411 5.378 7.23 10.978 11.621 16.22a151 151 0 0 0 3.252 3.54l15.147-2.94c-7.09-5.78-15.437-11.126-22.996-14.424a45.4 45.4 0 0 0-7.024-2.396m-174.496.594c.94 5.71 2.199 11.12 3.799 16.109l43.184 9.303a43.9 43.9 0 0 1 1.423-13.678a71 71 0 0 1-4.888.787c-11.8 1.447-26.322-.936-43.518-12.521M91.93 108.356l-8.383 76.563l94.193 2.305c21.335-19.254 48.606-30.866 78.479-30.866c31.857 0 60.758 13.206 82.652 34.805l77.983 1.908l-5.041-75.295l-145.231 28.198zm164.289 66.002c-58.224 0-106.217 52.74-106.217 119.13s47.993 119.128 106.217 119.128s106.217-52.739 106.217-119.129s-47.994-119.129-106.217-119.129M75.785 202.733l-6.281 62.846C63.95 321.138 77.74 377.93 99.787 435.145l1.66 4.31l-2.531 3.86c-7.597 11.588-19.783 21.862-33.562 31.42c14.767 1.264 29.76 2.284 44.917 3.102l22.924-29.006l14.121 11.16l-14.927 18.89c14.169.58 28.459.997 42.832 1.278l1.113-25.775l17.984.777l-1.093 25.283c16.038.191 32.158.21 48.296.127v-25.629h18v25.5c16.873-.173 33.736-.457 50.543-.818l-3.898-24.117l17.77-2.873l4.296 26.58c16.438-.401 32.687-.872 48.893-1.346l-13.9-23.219l15.445-9.246l19.074 31.867c16.498-.494 32.835-.987 48.902-1.44c-12.73-11.767-25.39-24.23-32.433-39.46l-1.111-2.402l.367-2.623s12.397-89.64 12.533-132.92c.073-23.34-2.226-55.719-5.145-87.25l-66.455-1.625c16.34 23.275 26.034 52.45 26.034 83.937c0 75.138-55.176 137.13-124.217 137.13c-69.042 0-124.217-61.992-124.217-137.13c0-33.654 11.075-64.665 29.502-88.656zm237.53 41.409c6.184-.143 12.236 2.243 17.081 5.77c6.891 5.014 12.332 12.414 15.83 21.1c6.998 17.373 5.063 41.493-15.263 57.376c-17.671 13.808-38.32 16.338-57.145 10.445a80 80 0 0 1-6.324-2.295c-12.764 3.93-27.601 9.065-42.97 9.707c-16.562.692-34.526-5.338-46.34-23.398c-5.325-8.14-10.014-18.55-11.545-29.149c-.766-5.3-.78-10.759 1.148-16.115c1.929-5.356 6.402-10.43 12.367-12.79l.145-.056l.146-.052a32.5 32.5 0 0 1 10.29-1.885c6.457-.127 12.625 1.658 17.83 4.586c7.93 4.46 14.327 11 20.703 17.789c.692.737 1.384 1.481 2.076 2.222c29.671 1.337 40.027-5.507 47.39-13.54c3.755-4.098 6.69-8.995 10.442-14.147s8.697-10.777 16.314-13.93a21.6 21.6 0 0 1 7.824-1.638zm1.013 17.925a4.5 4.5 0 0 0-1.955.346c-3.299 1.365-5.61 3.723-8.648 7.895c-3.039 4.171-6.427 9.934-11.721 15.71c-8.033 8.765-20.623 16.386-41.225 18.745c7.154 6.034 14.434 10.94 21.575 14.23l13.15 4.242c11.839 2.249 22.923-.082 34.375-9.03c14.157-11.063 14.339-24.833 9.652-36.468c-2.343-5.817-6.195-10.7-9.726-13.271c-2.318-1.687-4.071-2.343-5.477-2.399m-122.54 18.5c-1.556.022-3.21.362-5.126 1.028c-1.288.523-1.468.776-1.94 2.086c-.49 1.365-.757 4.062-.269 7.443c.977 6.761 4.824 15.8 8.793 21.867c8.623 13.182 17.991 15.796 30.527 15.272c6.35-.266 13.304-1.726 20.454-3.641c-11.335-8.295-20.188-18.721-28.08-27.125c-6.088-6.482-11.659-11.753-16.407-14.424c-2.374-1.335-4.366-2.162-6.426-2.422a11 11 0 0 0-1.527-.084z"/></svg>', color: '#c97d0e' },
'Food': { icon: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 512 512"><path fill="currentColor" d="M250.97 29.357c-106.557-.21-211.806 52.74-203.48 164.053c138.91 11.4 276.71 8.893 414.174.662c10.58-107.69-100.753-164.498-210.693-164.715zm59.876 23.996c11.165 0 20.216 4.468 20.216 9.98s-9.051 9.981-20.216 9.981s-20.217-4.468-20.217-9.98s9.051-9.98 20.217-9.98zm-111.852 19.83c11.165 0 20.217 4.469 20.217 9.98c0 5.513-9.052 9.981-20.217 9.981s-20.216-4.468-20.217-9.98s9.052-9.98 20.217-9.98zm178.057 34.02c11.165 0 20.216 4.468 20.217 9.98s-9.052 9.98-20.217 9.98s-20.217-4.468-20.217-9.98s9.052-9.98 20.217-9.98m-273.59 21.535c11.165 0 20.216 4.468 20.217 9.98s-9.052 9.98-20.217 9.98s-20.217-4.467-20.217-9.98c0-5.512 9.052-9.98 20.217-9.98m313.77 30.043c11.165 0 20.216 4.468 20.216 9.98s-9.051 9.98-20.217 9.98s-20.216-4.468-20.216-9.98s9.051-9.98 20.216-9.98zm-137.91 8.045c11.166 0 20.218 4.47 20.216 9.982c0 5.512-9.051 9.98-20.217 9.98s-20.216-4.468-20.216-9.98c-.002-5.513 9.05-9.982 20.216-9.982zM57.618 212.339c-18.964.405-9.028 24.485 14.383 24.573c128.554 10.208 236.673 9.686 372.117-2.42c16.096-2.708 25.212-13.087 10.824-21.969c-131.579 7.67-263.81 10.045-397.324-.184m403.024 40.612c-131.224 13.6-277.594 10.525-390.065 1.904c-46.983-6.226-47.875 46.785-17.014 52.309c146.18 14.663 271.826 10.735 415.137-.53c25.007-1.144 14.554-55.328-8.058-53.683M20.986 366.679l15.332 9.434c6.342-8.416 17.876-32.05 33.319-32.192c19.122-.174 22.345 39.302 41.98 39.103c22.607-.228 37.828-31.548 52.447-30.882c22.09 1.008 26.333 35.9 43.557 35.928c24.089.04 31.439-36.39 46.805-35.334c21.458 1.475 33.246 28.274 50.879 29.178c19.004.974 30.654-33.027 43.265-32.748c16.61.366 28.31 32.46 54.24 33.193c15.345.434 29.694-37.411 37.005-36.815c14.417 1.174 26.549 20.548 36.085 34.835l15.114-9.776c-12.029-16.216-30.117-44.428-52.558-44.017c-20.907.382-25.948 38.114-38.102 37.943c-23.28-.328-33.756-32.164-52.598-33.346c-19.356-1.214-30.475 33.636-43.353 32.768c-18.954-1.277-27.303-29.16-49.475-29.917c-19.62-.67-34.121 37.669-46.793 36.044c-18.139-2.326-20.226-36.378-43.317-37.836c-19.11-1.207-37.562 30.604-50.314 29.999c-17.525-.833-25.243-40.224-45.41-39.986c-21.826.258-39.145 30.34-48.108 44.424m47.553 36.174c-8.342.686-18.198 4.251-21.85 12.424c-2.452 6.662 19.173 8.558 21.114 8.695c128.615 8.104 254.354 8.26 389.8-1.345c9.225-.655 13.935-3.147 15.252-4.414c3.124-8.208-23.168-13.935-25.818-14.004c-185.01-1.178-279.257 2.209-378.498-1.356m395.555 37.192c-126.786 6.957-283.18 9.384-408.123.707l.521 38.67c135.917 4.617 275.647 3.99 406.658-.088z"/></svg>', color: '#e67e22' },
'Equipment': { icon: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 512 512"><path fill="currentColor" d="M151 16c-14.774 0-30 15.226-30 30v105c0 14.774 11.946 26.718 26.718 26.718H181V334.28h-33.282c-14.773 0-26.718 11.946-26.718 26.718v105c0 14.774 15.227 30 30 30h30v-90l75-45l75 45v90h30c14.774 0 30-15.226 30-30v-105c0-14.773-11.946-26.718-26.718-26.718H331V177.718h33.282C379.056 177.718 391 165.772 391 151V46c0-14.773-15.226-30-30-30h-30v90l-75 45l-75-45V16z"/></svg>', color: '#2c3e50' },
'Appliances': { icon: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 512 512"><path fill="currentColor" d="m71.03 41l-11.5 46H452.5L441 41zM25 105v46h158v-28h146v28h158v-46zm176 36v78h110v-78zM41 169v276.3L68.82 487H443.2l27.8-41.7V169H329v68H183v-68zm196.5 78.1l16.9 30.3h.8l16.9-30.3l15.8 8.8l-24.1 43.2v39.8l33.5-19.8l24.4-42.9l15.6 8.8l-17.3 30.4l.7 1.1l34-.1v18l-48.4.2l-34.3 20.3l34.4 20.4l48.5.3l-.2 18l-33.9-.2l-.7 1.1l17.3 30.4l-15.6 8.8l-24.4-42.9l-33.6-19.9v39.8l24.1 43.2l-15.8 8.8l-16.8-30.2h-1l-16.8 30.2l-15.8-8.8l24.1-43.2v-40.2l-33.5 19.8l-24.4 42.9l-15.6-8.8l17.3-30.4l-.7-1.1l-34 .1v-18l48.4-.2l33.5-19.9l-33.5-19.8l-48.3-.2v-18l34.1.1l.6-1l-17.3-30.5l15.6-8.8l24.5 43l33.3 19.7v-40.3l-24.1-43.2z"/></svg>', color: '#3b82f6' },
'Markets': { icon: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 512 512"><path fill="currentColor" d="M256.18 21c-23.242 0-46.577 3.01-63.186 8.54c-8.304 2.763-14.868 6.196-18.808 9.558c-3.94 3.36-5.167 5.956-5.186 8.96L168.943 57H41v14h430V57H342.967l.033-9.033c.01-3.002-1.17-5.55-5.057-8.895s-10.407-6.773-18.677-9.535C302.724 24.014 279.42 21 256.18 21M38.277 89l-10.443 94h80.9l20.243-86.36L122.81 183h81.07l17.114-86.68l-3.096 86.68h75.75l-4.634-86.518L307.694 183h81.497l-6.167-86.36L403.266 183h80.9l-10.443-94zM25.834 201l-.51 4.588C39.822 226.445 52.968 235 64 235c11.32 0 24.852-8.89 39.8-30.96l.714-3.04zm95.687 0l-.32 4.498C135.753 226.495 148.935 235 160 235c11.293 0 24.788-8.85 39.693-30.803l.63-3.197zm95.736 0l-.156 4.352C231.69 226.455 244.908 235 256 235c11.08 0 24.28-8.525 38.85-29.576l-.237-4.424h-77.357zm94.324 0l.674 3.12c15 22.192 28.503 31.044 39.773 31.013c11.03-.03 24.212-8.62 38.772-29.637l-.32-4.496zm95.906 0l.713 3.04C423.147 226.11 436.68 235 448 235c11.032 0 24.178-8.555 38.676-29.412l-.51-4.588h-78.68zM112 223.31C97.313 242.11 81.492 253 64 253c-13.972 0-26.884-6.906-39-19.264V487h318V279h114v208h30V233.736C474.884 246.094 461.972 253 448 253c-17.49 0-33.31-10.888-47.996-29.684c-14.664 18.808-30.432 29.77-47.926 29.817c-17.508.048-33.352-10.87-48.092-29.807C289.303 242.116 273.486 253 256 253c-17.492 0-33.313-10.89-48-29.69c-14.687 18.8-30.508 29.69-48 29.69s-33.313-10.89-48-29.69M55 279h258v178H55zm18 18v142h222V297zm288 0v71.064l78 .573V297zM88 312h128c-108.235 8-116.31 24-128 113.11zm273 74.066v13.998l78 .573v-14.002zm0 32V487h78v-68.365z"/></svg>', color: '#7c3aed' },
'Coffee': { icon: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 512 512"><path fill="currentColor" d="M144 32S94.11 69.4 96 96c1.604 22.57 44.375 25.665 48 48c1.91 11.772-16 32-16 32s48-25.373 48-48s-42.8-25.978-48-48c-3.875-16.414 16-48 16-48m80 0s-49.89 37.4-48 64c1.604 22.57 44.375 25.665 48 48c1.91 11.772-16 32-16 32s48-25.373 48-48s-42.8-25.978-48-48c-3.875-16.414 16-48 16-48m80 0s-49.89 37.4-48 64c1.604 22.57 44.375 25.665 48 48c1.91 11.772-16 32-16 32s48-25.373 48-48s-42.8-25.978-48-48c-3.875-16.414 16-48 16-48M73.293 201c1.43 63.948 18.943 179.432 74.707 238h152c55.764-58.568 73.278-174.052 74.707-238zm319.598.445c-.186 9.152-.652 19.252-1.472 30.057C419.312 235.162 441 259.142 441 288c0 31.374-25.626 57-57 57a56.7 56.7 0 0 1-12.764-1.465c-2.912 9.62-6.176 19.165-9.84 28.51A87.5 87.5 0 0 0 384 375c48.155 0 87-38.845 87-87c0-45.153-34.153-82.12-78.11-86.555zM42.763 457c1.507 5.193 3.854 11.2 6.955 16.37c2.637 4.394 5.69 8.207 8.428 10.58C60.882 486.32 63 487 64 487h320c1 0 3.118-.678 5.855-3.05c2.738-2.373 5.79-6.186 8.428-10.58c3.1-5.17 5.448-11.177 6.955-16.37z"/></svg>', color: '#a16207' },
'Other': { icon: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 512 512"><path fill="currentColor" d="M247 16v89h9c14.9 0 23 11.7 23.8 23.6c.4 6-1.2 11.5-4.7 15.4c-3.5 4-9.1 7-19.1 7c-5 0-11.1-2.8-15.6-7.4c-4.6-4.5-7.4-10.6-7.4-15.6h-18c0 11 5.2 20.9 12.6 28.4c7.5 7.4 17.4 12.6 28.4 12.6c14 0 25.4-5 32.5-13c7.2-8.1 10-18.6 9.3-28.6c-1.2-17.5-13.4-35.18-32.8-39.42V16zm-47.9 140.5L61.34 247h32.7l114.86-75.5zm113.8 0l-9.8 15L418 247h32.6zM41 265v222h430V265zm38 23h18v176H79zm48 0h18v176h-18zm48 0h18v176h-18zm48 0h18v176h-18zm48 0h18v176h-18zm48 0h18v176h-18zm48 0h18v176h-18zm48 0h18v176h-18z"/></svg>', color: '#6b7280' },
};
const CATEGORY_UNMAPPED_ICON = '❓';
const CATEGORY_UNMAPPED_COLOR = '#b91c1c';
const CATEGORY_FALLBACK_ICON = '📦'; // null/missing category
function categoryInfo(cat) {
if (!cat) return { icon: CATEGORY_FALLBACK_ICON, color: '#6b7280', mapped: false };
const e = CATEGORY_MAP[cat];
if (e) return { icon: e.icon, color: e.color, mapped: true, wide: e.wide };
return { icon: CATEGORY_UNMAPPED_ICON, color: CATEGORY_UNMAPPED_COLOR, mapped: false };
}
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'};
const PARK_GROUP_ICONS = {'park':'🏰','resort':'🏨','office':'🏢','springs':'🛍️','other':'📍'};
const PARK_GROUP_NAMES = {'park':'Disney Park','resort':'Disney Resort','office':'Disney Office','springs':'Disney Springs','other':'Other Disney'};
const PARK_GROUP_MAP = {
'park': ['magic-kingdom','epcot','hollywood-studios','animal-kingdom'],
'resort': ['resort'],
'office': ['office'],
'springs': ['disney-springs'],
'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_group: 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;
assetFilters.disney_group = null;
} else {
var parkVal = parkEl.value;
if (parkVal && parkVal.startsWith('group:')) {
// Grouped park category
assetFilters.disney_group = parkVal.replace('group:', '');
assetFilters.disney_park = null;
assetFilters.disney_filter = null;
} else if (parkVal) {
assetFilters.disney_park = parkVal;
assetFilters.disney_group = null;
assetFilters.disney_filter = null;
} else {
assetFilters.disney_park = null;
assetFilters.disney_group = 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_group) 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_group) tags.push({key:'disney_group', label: (PARK_GROUP_ICONS[assetFilters.disney_group] || '📍') + ' ' + (PARK_GROUP_NAMES[assetFilters.disney_group] || assetFilters.disney_group)});
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_group') {
document.getElementById('filterDisneyPark').value = '';
assetFilters.disney_group = 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_group: 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();
}
// ═══════════════════════════════════════════════════════════════════════
// WORK ORDERS TAB
// ═══════════════════════════════════════════════════════════════════════
let woFilters = { q: '', status: null, tech: null, priority: null, work_type: null, date_range: 'week', date_from: null, date_to: null };
let woOffset = 0;
const WO_PAGE_SIZE = 50;
let woTotal = 0;
let woFilterDropdownsLoaded = false;
let woDebounce = null;
function woStatusColor(code) {
// 690970000=Unscheduled, 690970001=Scheduled, 690970002=In Progress, 690970003=Completed, 690970004=Posted, 690970005=Cancelled, 690959000=Postponed
const map = {
'690970000': '#8b8fa3', // Unscheduled - gray
'690970001': '#5b6ef7', // Scheduled - blue
'690970002': '#fbbf24', // In Progress - amber
'690970003': '#4ade80', // Completed - green
'690970004': '#4ade80', // Posted - green
'690970005': '#f87171', // Cancelled - red
'690959000': '#fbbf24', // Postponed - amber
};
return map[code] || '#8b8fa3';
}
function woStatusIcon(code) {
const map = {
'690970000': '📋', // Unscheduled
'690970001': '📅', // Scheduled
'690970002': '🔧', // In Progress
'690970003': '✅', // Completed
'690970004': '✅', // Posted
'690970005': '❌', // Cancelled
'690959000': '⏰', // Postponed
};
return map[code] || '📋';
}
function setWODateRange(range) {
// Update chip UI
document.querySelectorAll('.wo-date-chip').forEach(function(chip) {
chip.classList.toggle('active', chip.dataset.range === range);
});
woFilters.date_range = range;
var customRow = document.getElementById('woCustomDateRow');
if (range === 'custom') {
customRow.style.display = 'flex';
// Default to this week if empty
if (!document.getElementById('woDateFrom').value) {
var now = new Date();
var dayOfWeek = now.getDay();
var diff = now.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1);
var monday = new Date(now.setDate(diff));
document.getElementById('woDateFrom').value = monday.toISOString().split('T')[0];
document.getElementById('woDateTo').value = new Date().toISOString().split('T')[0];
}
woFilters.date_from = document.getElementById('woDateFrom').value || null;
woFilters.date_to = document.getElementById('woDateTo').value || null;
} else {
customRow.style.display = 'none';
woFilters.date_from = null;
woFilters.date_to = null;
}
renderWOActiveFilterTags();
woOffset = 0;
_loadWorkOrders();
}
function onWODateCustomChange() {
var fromVal = document.getElementById('woDateFrom').value;
var toVal = document.getElementById('woDateTo').value;
if (fromVal || toVal) {
woFilters.date_range = 'custom';
document.querySelectorAll('.wo-date-chip').forEach(function(chip) {
chip.classList.toggle('active', chip.dataset.range === 'custom');
});
woFilters.date_from = fromVal || null;
woFilters.date_to = toVal || null;
renderWOActiveFilterTags();
woOffset = 0;
_loadWorkOrders();
}
}
function toggleWOFilterPanel() {
var panel = document.getElementById('woFilterPanel');
var toggle = document.getElementById('woFilterToggle');
panel.classList.toggle('open');
toggle.classList.toggle('active', panel.classList.contains('open'));
if (panel.classList.contains('open') && !woFilterDropdownsLoaded) {
loadWOFilterDropdowns();
}
}
async function loadWOFilterDropdowns() {
if (woFilterDropdownsLoaded) return;
woFilterDropdownsLoaded = true;
var panel = document.getElementById('woFilterPanel');
var loadingMsg = document.createElement('div');
loadingMsg.className = 'loading-spinner';
loadingMsg.innerHTML = '<span class="spinner"></span> Loading filters...';
panel.appendChild(loadingMsg);
try {
// Load statuses from API response
try {
// Pre-populate status options from known codes
var statusSel = document.getElementById('woFilterStatus');
var statuses = [
{code:'690970000', label:'📋 Unscheduled'},
{code:'690970001', label:'📅 Scheduled'},
{code:'690970002', label:'🔧 In Progress'},
{code:'690970003', label:'✅ Completed'},
{code:'690970004', label:'✅ Posted'},
{code:'690970005', label:'❌ Cancelled'},
{code:'690959000', label:'⏰ Postponed'},
];
statusSel.innerHTML = '<option value="">All statuses</option>' +
statuses.map(function(s) { return '<option value="' + s.code + '">' + s.label + '</option>'; }).join('');
} catch(e) {}
// Load technicians
try {
var techs = await api('/api/workorders/technicians');
var techSel = document.getElementById('woFilterTech');
techSel.innerHTML = '<option value="">All technicians</option>' +
(techs.technicians || []).map(function(t) { return '<option value="' + esc(t) + '">' + esc(t) + '</option>'; }).join('');
} catch(e) {}
} catch (e) {
showToast('Failed to load filter options', true);
} finally {
if (loadingMsg && loadingMsg.parentNode) loadingMsg.parentNode.removeChild(loadingMsg);
}
}
function onWOFilterChange() {
var statusEl = document.getElementById('woFilterStatus');
var techEl = document.getElementById('woFilterTech');
var priorityEl = document.getElementById('woFilterPriority');
var typeEl = document.getElementById('woFilterType');
woFilters.status = statusEl.value || null;
woFilters.tech = techEl.value || null;
woFilters.priority = priorityEl.value || null;
woFilters.work_type = typeEl.value || null;
renderWOActiveFilterTags();
woOffset = 0;
_loadWorkOrders();
}
function getWOActiveFilterCount() {
var count = 0;
if (woFilters.q) count++;
if (woFilters.status) count++;
if (woFilters.tech) count++;
if (woFilters.priority) count++;
if (woFilters.work_type) count++;
if (woFilters.date_range && woFilters.date_range !== 'today' && woFilters.date_range !== 'all') count++;
if (woFilters.date_from) count++;
if (woFilters.date_to) count++;
return count;
}
function renderWOActiveFilterTags() {
var el = document.getElementById('woActiveFilters');
var countEl = document.getElementById('woFilterCount');
var toggle = document.getElementById('woFilterToggle');
var count = getWOActiveFilterCount();
if (countEl) { countEl.textContent = count; countEl.style.display = count > 0 ? 'inline' : 'none'; }
if (toggle) toggle.classList.toggle('active', count > 0);
var tags = [];
if (woFilters.q) tags.push({key:'q', label:'🔍 "' + woFilters.q + '"'});
if (woFilters.status) {
var sel = document.getElementById('woFilterStatus');
var txt = sel.options[sel.selectedIndex] ? sel.options[sel.selectedIndex].text : woFilters.status;
tags.push({key:'status', label:'📋 ' + txt});
}
if (woFilters.tech) tags.push({key:'tech', label:'👤 ' + woFilters.tech});
if (woFilters.priority) tags.push({key:'priority', label:'🏷️ ' + woFilters.priority});
if (woFilters.work_type) tags.push({key:'work_type', label:'🔧 ' + woFilters.work_type});
if (woFilters.date_range === 'today') tags.push({key:'date_range', label:'📅 Today'});
else if (woFilters.date_range === 'week') tags.push({key:'date_range', label:'📅 This Week'});
else if (woFilters.date_range === 'month') tags.push({key:'date_range', label:'📅 This Month'});
else if (woFilters.date_range === 'all') { /* no tag — default state */ }
else if (woFilters.date_from && woFilters.date_to) tags.push({key:'date_range', label:'📅 ' + woFilters.date_from + ' → ' + woFilters.date_to});
else if (woFilters.date_from) tags.push({key:'date_range', label:'📅 From ' + woFilters.date_from});
else if (woFilters.date_to) tags.push({key:'date_range', label:'📅 Until ' + woFilters.date_to});
if (el) el.innerHTML = tags.map(function(t) {
return '<span class="filter-tag">' + esc(t.label) + ' <span class="ft-remove" onclick="removeWOFilter(\'' + t.key + '\')">✕</span></span>';
}).join('');
}
function removeWOFilter(key) {
if (key === 'q') {
document.getElementById('woSearch').value = '';
woFilters.q = '';
document.getElementById('woClearSearch').style.display = 'none';
} else if (key === 'status') {
document.getElementById('woFilterStatus').value = '';
woFilters.status = null;
} else if (key === 'tech') {
document.getElementById('woFilterTech').value = '';
woFilters.tech = null;
} else if (key === 'priority') {
document.getElementById('woFilterPriority').value = '';
woFilters.priority = null;
} else if (key === 'work_type') {
document.getElementById('woFilterType').value = '';
woFilters.work_type = null;
} else if (key === 'date_range') {
// Reset to All (no date filter)
setWODateRange('all');
return; // setWODateRange already calls render + load
}
renderWOActiveFilterTags();
woOffset = 0;
_loadWorkOrders();
}
function clearAllWOFilters() {
woFilters = { q: '', status: null, tech: null, priority: null, work_type: null, date_range: 'all', date_from: null, date_to: null };
document.getElementById('woSearch').value = '';
document.getElementById('woClearSearch').style.display = 'none';
document.getElementById('woFilterStatus').value = '';
document.getElementById('woFilterTech').value = '';
document.getElementById('woFilterPriority').value = '';
document.getElementById('woFilterType').value = '';
document.getElementById('woCustomDateRow').style.display = 'none';
document.querySelectorAll('.wo-date-chip').forEach(function(c) {
c.classList.toggle('active', c.dataset.range === 'all');
});
renderWOActiveFilterTags();
woOffset = 0;
_loadWorkOrders();
}
function clearWOSearch() {
document.getElementById('woSearch').value = '';
woFilters.q = '';
document.getElementById('woClearSearch').style.display = 'none';
woOffset = 0;
loadWorkOrders();
}
function loadWorkOrders() {
var q = document.getElementById('woSearch').value.trim();
woFilters.q = q;
var clearBtn = document.getElementById('woClearSearch');
if (clearBtn) clearBtn.style.display = q ? 'block' : 'none';
renderWOActiveFilterTags();
clearTimeout(woDebounce);
woDebounce = setTimeout(_loadWorkOrders, 200);
}
function getWODateParams() {
var now = new Date();
var today = now.toISOString().split('T')[0];
switch (woFilters.date_range) {
case 'today':
return { date_from: today, date_to: today, date_field: 'createdon' };
case 'week': {
var d = new Date();
var day = d.getDay();
var diff = d.getDate() - day + (day === 0 ? -6 : 1);
var monday = new Date(d);
monday.setDate(diff);
var sunday = new Date(d);
sunday.setDate(diff + 6);
return { date_from: monday.toISOString().split('T')[0], date_to: sunday.toISOString().split('T')[0], date_field: 'createdon' };
}
case 'month': {
var d = new Date();
var first = new Date(d.getFullYear(), d.getMonth(), 1);
var last = new Date(d.getFullYear(), d.getMonth() + 1, 0);
return { date_from: first.toISOString().split('T')[0], date_to: last.toISOString().split('T')[0], date_field: 'createdon' };
}
case 'custom':
return {
date_from: woFilters.date_from || null,
date_to: woFilters.date_to || null,
date_field: 'createdon'
};
case 'all':
return { date_from: null, date_to: null, date_field: null };
default:
return { date_from: today, date_to: today, date_field: 'createdon' };
}
}
async function _loadWorkOrders() {
var listEl = document.getElementById('woList');
var countEl = document.getElementById('woResultCount');
if (listEl) listEl.innerHTML = '<div class="loading-spinner"><span class="spinner"></span> Loading work orders...</div>';
if (countEl) countEl.textContent = '';
var params = new URLSearchParams();
if (woFilters.q) params.set('q', woFilters.q);
// Date range
var dateParams = getWODateParams();
if (dateParams.date_from) params.set('date_from', dateParams.date_from);
if (dateParams.date_to) params.set('date_to', dateParams.date_to);
if (dateParams.date_field) params.set('date_field', dateParams.date_field);
// Filters
if (woFilters.status) params.set('status', woFilters.status);
if (woFilters.tech) params.set('tech', woFilters.tech);
params.set('limit', String(WO_PAGE_SIZE));
params.set('offset', String(woOffset));
try {
var result = await api('/api/workorders/list?' + params.toString());
var wos = result.results || [];
woTotal = result.total || wos.length;
renderWorkOrderList(wos);
renderWOPagination();
if (countEl) countEl.textContent = woTotal + ' work order' + (woTotal !== 1 ? 's' : '') + ' found';
} catch (e) {
var el = document.getElementById('woList');
if (el) el.innerHTML = '<div class="empty-state"><div class="es-icon">⚠️</div><strong>Failed to load work orders</strong><br>Route planner backend may be unavailable</div>';
showToast('Failed to load work orders — ' + (e.message || 'connection error'), true);
}
}
function renderWorkOrderList(wos) {
var el = document.getElementById('woList');
if (!el) return;
if (!wos || !wos.length) {
el.innerHTML = '<div class="empty-state"><div class="es-icon">📋</div>No work orders found — try adjusting filters or date range</div>';
return;
}
el.innerHTML = wos.map(function(w) {
var color = woStatusColor(w.status_code);
var icon = woStatusIcon(w.status_code);
var dateStr = '';
if (w.datewindow_start) {
try {
var d = new Date(w.datewindow_start);
dateStr = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
} catch(e) { dateStr = w.datewindow_start; }
}
var accountName = w.account_display_name || w.account_name || '';
var statusLabel = w.status || '';
var techStr = w.technicians ? '👤 ' + w.technicians : '';
var cityStr = [w.city, w.state].filter(Boolean).join(', ');
var priorityStr = w.priority ? '🏷️ ' + w.priority : '';
var typeStr = w.work_type ? '🔧 ' + w.work_type : '';
return '<div class="wo-item" onclick="showWODetail(\'' + esc(w.id) + '\')">' +
'<div class="wo-status-dot" style="background:' + color + '"></div>' +
'<div class="wo-info">' +
'<div class="wo-name">' + icon + ' ' + esc(w.name) + '</div>' +
'<div class="wo-status-badge" style="color:' + color + '">' + esc(statusLabel) + '</div>' +
(accountName ? '<div class="wo-account">' + esc(accountName) + '</div>' : '') +
'<div class="wo-meta">' +
(dateStr ? '<span>📅 ' + esc(dateStr) + '</span>' : '') +
(w.territory ? '<span>🏢 ' + esc(w.territory) + '</span>' : '') +
(techStr ? '<span>' + esc(techStr) + '</span>' : '') +
(cityStr ? '<span>📍 ' + esc(cityStr) + '</span>' : '') +
'</div>' +
'<div class="wo-meta" style="color:var(--text2);">' +
(priorityStr || typeStr ? esc(priorityStr + (priorityStr && typeStr ? ' · ' : '') + typeStr) : '') +
'</div>' +
'</div>' +
'<div class="wo-arrow"></div>' +
'</div>';
}).join('');
}
function renderWOPagination() {
var el = document.getElementById('woPagination');
if (!el) return;
var totalPages = Math.ceil(woTotal / WO_PAGE_SIZE);
var currentPage = Math.floor(woOffset / WO_PAGE_SIZE) + 1;
if (totalPages <= 1) {
el.style.display = 'none';
return;
}
el.style.display = 'flex';
el.innerHTML =
'<button class="btn btn-outline btn-xs" onclick="woPrevPage()" ' + (currentPage <= 1 ? 'disabled' : '') + '>← Prev</button>' +
'<span style="flex:1;text-align:center;font-size:12px;color:var(--text2);">Page ' + currentPage + ' of ' + totalPages + '</span>' +
'<button class="btn btn-outline btn-xs" onclick="woNextPage()" ' + (currentPage >= totalPages ? 'disabled' : '') + '>Next →</button>';
}
function woPrevPage() {
if (woOffset > 0) {
woOffset = Math.max(0, woOffset - WO_PAGE_SIZE);
_loadWorkOrders();
}
}
function woNextPage() {
if (woOffset + WO_PAGE_SIZE < woTotal) {
woOffset += WO_PAGE_SIZE;
_loadWorkOrders();
}
}
function showWODetail(woId) {
// Open work order in a simple modal with key info
showModal(
'Work Order Details',
'<div class="loading-spinner"><span class="spinner"></span> Loading...</div>',
'Close',
null
);
// Fetch single WO detail using the lookup endpoint
api('/api/workorders/lookup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: [woId] })
}).then(function(data) {
var wos = data.found || data || [];
var wo = Array.isArray(wos) ? wos[0] : null;
if (!wo) {
document.querySelector('#modalBody').innerHTML = '<div class="empty-state">Work order not found</div>';
return;
}
// Color for status badge
var statusColor = wo.status_code ? woStatusColor(wo.status_code) : '#999';
var statusIcon = wo.status_code ? woStatusIcon(wo.status_code) : '';
// Format dates for readability
function fmtDate(d) {
if (!d) return null;
try { return new Date(d).toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' }); }
catch(e) { return d; }
}
function fmtDateShort(d) {
if (!d) return null;
try { return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); }
catch(e) { return d; }
}
// Duration formatter (minutes → human)
function fmtDur(m) {
if (m == null || m === '') return null;
var n = parseInt(m, 10);
if (isNaN(n)) return m;
if (n < 60) return n + ' min';
return Math.floor(n / 60) + 'h ' + (n % 60) + 'm';
}
// Build address string (combine both sources)
var addrParts = [];
if (wo.address_line1) addrParts.push(wo.address_line1);
else if (wo.address && wo.address.line1) addrParts.push(wo.address.line1);
if (wo.city) addrParts.push(wo.city);
if (wo.state) addrParts.push(wo.state);
if (wo.postal_code) addrParts.push(wo.postal_code);
else if (wo.address && wo.address.postal) addrParts.push(wo.address.postal);
var addrStr = addrParts.length ? addrParts.join(', ') : null;
function row(label, value) {
return '<div class="wo-det-row"><span class="wo-det-label">' + label + '</span><span class="wo-det-value">' + value + '</span></div>';
}
function fullRow(label, value) {
return '<div class="wo-det-row wo-det-row-full"><span class="wo-det-label">' + label + '</span><span class="wo-det-value">' + value + '</span></div>';
}
function sep() { return '<div class="wo-det-sep"></div>'; }
function tlRow(icon, label, dateVal, isDone) {
var dotColor = dateVal ? (isDone !== false ? 'var(--green)' : 'var(--amber)') : 'var(--text3)';
var check = dateVal ? (isDone !== false ? ' ✓' : '') : '';
return '<div class="wo-det-tl-row"><span class="wo-det-tl-dot" style="background:' + dotColor + '"></span><span class="wo-det-tl-label">' + icon + ' ' + label + '</span><span class="wo-det-tl-time">' + (dateVal ? esc(fmtDate(dateVal)) + check : '<span class="text-muted">—</span>') + '</span></div>';
}
// ── Status flags ──
var flags = [];
if (wo.awaiting_parts) flags.push('<span class="wo-det-flag warn">🔧 Awaiting Parts</span>');
if (wo.customer_hold) flags.push('<span class="wo-det-flag warn">⏸ Customer Hold</span>');
var flagsHtml = flags.length ? '<div style="display:flex;gap:6px;flex-wrap:wrap;padding:6px 0;">' + flags.join('') + '</div>' : '';
var html = '<div class="wo-detail-container">';
// Header: WO# + status badge + substatus
html += '<div class="wo-det-header">';
html += '<div class="wo-det-name">' + statusIcon + ' ' + esc(wo.name || 'N/A') + '</div>';
html += '<div class="wo-det-status-badge" style="background:' + statusColor + '22;color:' + statusColor + ';border:1px solid ' + statusColor + '44;">' + esc(wo.status || '') + '</div>';
html += '</div>';
if (wo.substatus) {
html += '<div style="text-align:center;font-size:12px;color:var(--text2);margin-bottom:8px;">' + esc(wo.substatus) + '</div>';
}
html += flagsHtml;
// Section 1: Account & Assignment
html += sep();
html += '<div class="wo-det-section-title">Account &amp; Assignment</div>';
html += row('Account', esc(wo.account_name || wo.account_display_name || 'N/A'));
if (wo.technicians) html += row('Technicians', '👤 ' + esc(wo.technicians));
if (wo.priority) html += row('Priority', esc(wo.priority));
if (wo.trade) html += row('Trade', esc(wo.trade));
if (wo.line_of_business) html += row('LOB', esc(wo.line_of_business));
// Section 2: Work Details
html += sep();
html += '<div class="wo-det-section-title">Work Details</div>';
if (wo.work_type) html += row('Work type', esc(wo.work_type));
if (wo.incident_type) html += row('Incident type', esc(wo.incident_type));
if (wo.primary_resolution) html += row('Resolution', esc(wo.primary_resolution));
if (wo.territory) html += row('Territory', esc(wo.territory));
if (wo.functional_location) html += row('Location in facility', esc(wo.functional_location));
if (wo.customer_asset_name) html += row('Customer asset', esc(wo.customer_asset_name));
if (wo.equipment_serial_in) html += row('Equip serial in', esc(wo.equipment_serial_in));
if (wo.equipment_serial_out) html += row('Equip serial out', esc(wo.equipment_serial_out));
// Section 3: Timeline
html += sep();
html += '<div class="wo-det-section-title">Timeline</div>';
html += '<div class="wo-det-timeline">';
// Created is always present
html += tlRow('📝', 'Created', wo.created_on, true);
// Scheduled — when status moves to scheduled, or datewindow assigned
html += tlRow('📅', 'Scheduled', wo.datewindow_start, !!wo.datewindow_start);
// First arrived
html += tlRow('📍', 'First arrived on site', wo.first_arrived_on, !!wo.first_arrived_on);
// Completed
html += tlRow('✅', 'Work completed', wo.completed_on, !!wo.completed_on);
// Paused (awaiting parts or customer hold)
if (wo.paused_on) {
html += tlRow('⏸', 'Paused' + (wo.awaiting_parts ? ' (awaiting parts)' : wo.customer_hold ? ' (customer hold)' : ''), wo.paused_on, false);
}
if (wo.paused_end) {
html += tlRow('▶️', 'Resumed', wo.paused_end, true);
}
// Closed
html += tlRow('🔒', 'Posted/Closed', wo.time_closed, !!wo.time_closed);
// Modified
if (wo.modified_on) {
html += tlRow('✏️', 'Last modified', wo.modified_on, true);
}
// Status change
if (wo.status_changed_on) {
html += tlRow('🔄', 'Status changed', wo.status_changed_on, true);
}
html += '</div>';
// Closed by
if (wo.closed_by) {
html += '<div style="text-align:right;font-size:11px;color:var(--text2);padding:4px 0;">Closed by: ' + esc(wo.closed_by) + '</div>';
}
// Section 4: Time Windows
html += sep();
html += '<div class="wo-det-section-title">Scheduled Windows</div>';
if (wo.datewindow_start) html += row('Date window', esc(fmtDateShort(wo.datewindow_start)) + (wo.datewindow_end ? ' ' + esc(fmtDateShort(wo.datewindow_end)) : ''));
if (wo.timewindow_start) html += row('Time window', esc(fmtDate(wo.timewindow_start)) + (wo.timewindow_end ? ' ' + esc(fmtDate(wo.timewindow_end)) : ''));
// Section 5: Duration
html += sep();
html += '<div class="wo-det-section-title">Duration</div>';
if (wo.onsite_duration) html += row('On-site', esc(fmtDur(wo.onsite_duration)));
if (wo.travel_duration) html += row('Travel', esc(fmtDur(wo.travel_duration)));
if (wo.total_duration) html += row('Total (actual)', esc(fmtDur(wo.total_duration)));
if (wo.estimated_duration) html += row('Estimated', esc(fmtDur(wo.estimated_duration)));
if (wo.incident_estimated_duration) html += row('Incident est.', esc(fmtDur(wo.incident_estimated_duration)));
if (wo.total_pause_duration && wo.total_pause_duration > 0) html += row('Paused time', esc(fmtDur(wo.total_pause_duration)));
// Section 6: Contact / Location
html += sep();
html += '<div class="wo-det-section-title">Location &amp; Contact</div>';
if (addrStr) html += row('Address', esc(addrStr));
if (wo.country) html += row('Country', esc(wo.country));
// Section 7: Notes
html += sep();
html += '<div class="wo-det-section-title">Notes</div>';
if (wo.summary) html += fullRow('Summary', esc(wo.summary));
if (wo.booking_summary) html += fullRow('Booking note', esc(wo.booking_summary));
if (wo.instructions) html += fullRow('Instructions', '<div class="wo-det-instr">' + esc(wo.instructions) + '</div>');
if (wo.additional_notes) html += fullRow('Additional notes', esc(wo.additional_notes));
// Section 8: Sign-off
if (wo.signed_by) {
html += sep();
html += '<div class="wo-det-section-title">Sign-off</div>';
html += row('Signed by', esc(wo.signed_by));
}
html += '</div>';
document.querySelector('#modalBody').innerHTML = html;
}).catch(function(e) {
document.querySelector('#modalBody').innerHTML = '<div class="empty-state">Failed to load details: ' + esc(e.message) + '</div>';
});
}
// ═══════════════════════════════════════════════════════════════════════
// END WORK ORDERS TAB
// ═══════════════════════════════════════════════════════════════════════
// _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_group) params.set('disney_group', assetFilters.disney_group);
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 ci = categoryInfo(a.category);
return `
<div class="asset-item" onclick="viewAsset(${a.id})">
<div class="ai-icon${ci.wide ? ' ai-icon-wide' : ''}" style="background:${ci.color}22;color:${ci.color}">${ci.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, disney_group) {
assetFilters.category = category;
assetFilters.status = status;
assetFilters.make = make;
assetFilters.disney_park = disney_park;
assetFilters.disney_group = disney_group || null;
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_group ? 'group:' + disney_group : (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);
window._currentAssetDetail = a; // store for work order modal, etc.
const ci = categoryInfo(a.category);
document.getElementById('detailName').innerHTML = `<span style="margin-right:8px">${ci.icon}</span>${esc(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';
}
// Render GPS Map Editor card (at top of detail view)
renderGpsCard(a);
// 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 ? `<a href="#" onclick="showWorkOrders(event, this, '${esc(m.equipment_id || '')}')" style="color:var(--accent2);text-decoration:underline;">${m.work_order_count} work orders</a>` : 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 showWorkOrders(event, el, equipmentId) {
event.preventDefault();
// Get the MSFS data from the stored current asset detail
const msfs = (window._currentAssetDetail || {}).msfs;
if (!msfs || !msfs.work_orders || !msfs.work_orders.length) {
showToast('No work order details available', true);
return;
}
const items = msfs.work_orders.map(wo => {
const statusColor = wo.status_code === 690970003 ? '#4ade80' :
wo.status_code === 690970001 ? '#fbbf24' :
wo.status_code === 690970002 ? '#22d3ee' : '#5b5f73';
return `<div style="padding:8px 0;border-bottom:1px solid var(--border);">
<div style="display:flex;justify-content:space-between;align-items:center;">
<strong style="font-size:13px;">${esc(wo.number)}</strong>
<span style="font-size:11px;padding:1px 6px;border-radius:4px;background:${statusColor}22;color:${statusColor};">${esc(wo.status)}</span>
</div>
${wo.type ? `<div style="font-size:12px;color:var(--text2);margin-top:2px;">${esc(wo.type)}</div>` : ''}
${wo.created_on ? `<div style="font-size:11px;color:var(--text3);margin-top:2px;">📅 ${esc(wo.created_on.slice(0,10))}</div>` : ''}
${wo.notes ? `<div style="font-size:11px;color:var(--text3);margin-top:4px;line-height:1.4;max-height:60px;overflow-y:auto;">${esc(wo.notes)}</div>` : ''}
</div>`;
}).join('');
showModal(`Work Orders — ${msfs.work_order_count} total`, items, 'Close', 'btn-outline');
}
// ── GPS Map Editor ──────────────────────────────────────────────────
let _gpsState = { map: null, marker: null, unlocked: false, asset: null };
function renderGpsCard(a) {
const card = document.getElementById('detailGpsCard');
const mapDiv = document.getElementById('detailGpsMap');
const infoEl = document.getElementById('detailGpsInfo');
const coordsEl = document.getElementById('detailGpsCoords');
const lockBtn = document.getElementById('detailGpsLockBtn');
const saveBtn = document.getElementById('detailGpsSaveBtn');
const statusEl = document.getElementById('detailGpsStatus');
// Clean up previous map instance
if (_gpsState.map) {
_gpsState.map.remove();
_gpsState.map = null;
_gpsState.marker = null;
}
_gpsState.asset = a;
const hasGps = a.latitude != null && a.longitude != null;
// If no GPS, show card as unlocked and editable
_gpsState.unlocked = !hasGps;
card.style.display = 'block';
lockBtn.onclick = toggleGpsLock;
if (hasGps) {
infoEl.innerHTML = gi('📍') + ' GPS coordinates on file — click ' + gi('🔒') + ' to unlock and edit';
lockBtn.innerHTML = gi('🔒');
lockBtn.className = 'gps-lock-btn';
lockBtn.title = 'GPS is locked — click to edit';
saveBtn.style.display = 'none';
statusEl.textContent = '';
} else {
infoEl.innerHTML = gi('📍') + ' No GPS data — tap the map to set coordinates';
lockBtn.innerHTML = gi('🔓');
lockBtn.className = 'gps-lock-btn unlocked';
lockBtn.title = 'Editable — tap lock to disable editing';
statusEl.textContent = 'Editable';
}
const defaultLat = hasGps ? a.latitude : 28.5383;
const defaultLng = hasGps ? a.longitude : -81.3792;
// Update coords display
coordsEl.textContent = `${defaultLat.toFixed(6)}, ${defaultLng.toFixed(6)}`;
// Initialize map next tick so the div is in DOM
requestAnimationFrame(() => {
if (!mapDiv.offsetParent && mapDiv.parentNode) {
// Map may not be visible yet — use setTimeout
setTimeout(() => initGpsEditorMap(defaultLat, defaultLng, a), 100);
} else {
initGpsEditorMap(defaultLat, defaultLng, a);
}
});
}
function initGpsEditorMap(lat, lng, a) {
const mapDiv = document.getElementById('detailGpsMap');
if (!mapDiv || mapDiv.offsetHeight === 0) {
// Retry if map isn't visible yet (hidden tab, not yet scrolled)
setTimeout(() => initGpsEditorMap(lat, lng, a), 300);
return;
}
const map = L.map(mapDiv, {
zoomControl: true,
attributionControl: false,
}).setView([lat, lng], 15);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
}).addTo(map);
const marker = L.marker([lat, lng], {
draggable: _gpsState.unlocked,
}).addTo(map);
marker.on('dragend', function() {
const pos = marker.getLatLng();
const latRounded = Math.round(pos.lat * 1000000) / 1000000;
const lngRounded = Math.round(pos.lng * 1000000) / 1000000;
marker.setLatLng([latRounded, lngRounded]);
document.getElementById('detailGpsCoords').textContent =
`${latRounded.toFixed(6)}, ${lngRounded.toFixed(6)}`;
});
// When map is clicked (not dragging), move marker there
map.on('click', function(e) {
if (!_gpsState.unlocked) return;
const latRounded = Math.round(e.latlng.lat * 1000000) / 1000000;
const lngRounded = Math.round(e.latlng.lng * 1000000) / 1000000;
marker.setLatLng([latRounded, lngRounded]);
document.getElementById('detailGpsCoords').textContent =
`${latRounded.toFixed(6)}, ${lngRounded.toFixed(6)}`;
});
_gpsState.map = map;
_gpsState.marker = marker;
// Invalidate size after a short delay to fix rendering in hidden containers
setTimeout(() => map.invalidateSize(), 350);
}
function toggleGpsLock() {
const lockBtn = document.getElementById('detailGpsLockBtn');
const saveBtn = document.getElementById('detailGpsSaveBtn');
const statusEl = document.getElementById('detailGpsStatus');
const infoEl = document.getElementById('detailGpsInfo');
_gpsState.unlocked = !_gpsState.unlocked;
if (_gpsState.unlocked) {
lockBtn.innerHTML = gi('🔓');
lockBtn.className = 'gps-lock-btn unlocked';
lockBtn.title = 'Click to lock GPS';
saveBtn.style.display = '';
statusEl.textContent = 'Editable — drag the marker or tap the map';
infoEl.innerHTML = gi('📍') + ' Drag the marker or click the map to set GPS position';
} else {
lockBtn.innerHTML = gi('🔒');
lockBtn.className = 'gps-lock-btn';
lockBtn.title = 'GPS is locked — click to edit';
saveBtn.style.display = 'none';
statusEl.textContent = 'Locked';
infoEl.innerHTML = gi('📍') + ' GPS position is locked — click ' + gi('🔒') + ' to edit';
}
// Update marker draggability
if (_gpsState.marker) {
_gpsState.marker.dragging[_gpsState.unlocked ? 'enable' : 'disable']();
}
}
async function saveGpsCoords() {
if (!_gpsState.marker || !_gpsState.asset) return;
const pos = _gpsState.marker.getLatLng();
const lat = Math.round(pos.lat * 1000000) / 1000000;
const lng = Math.round(pos.lng * 1000000) / 1000000;
const saveBtn = document.getElementById('detailGpsSaveBtn');
saveBtn.textContent = '⏳ Saving...';
saveBtn.disabled = true;
try {
await api('/api/assets/' + _gpsState.asset.id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ latitude: lat, longitude: lng }),
});
showToast('✅ GPS coordinates saved!', false);
// Update asset data and re-lock
_gpsState.asset.latitude = lat;
_gpsState.asset.longitude = lng;
// Re-lock
_gpsState.unlocked = false;
const lockBtn = document.getElementById('detailGpsLockBtn');
lockBtn.innerHTML = gi('🔒');
lockBtn.className = 'gps-lock-btn';
lockBtn.title = 'GPS is locked — click to edit';
document.getElementById('detailGpsSaveBtn').style.display = 'none';
document.getElementById('detailGpsStatus').textContent = 'Locked';
document.getElementById('detailGpsInfo').innerHTML = gi('📍') + ' GPS coordinates saved';
if (_gpsState.marker) {
_gpsState.marker.dragging.disable();
}
// Refresh detail to show updated data
setTimeout(() => viewAsset(_gpsState.asset.id), 1200);
} catch (e) {
showToast('Failed to save GPS: ' + e.message, true);
} finally {
saveBtn.textContent = '💾 Save GPS';
saveBtn.disabled = false;
}
}
async function getCurrentGps() {
// Check browser geolocation support
if (!navigator.geolocation) {
showToast('GPS not supported on this device', true);
return;
}
const myGpsBtn = document.getElementById('detailGpsMyGpsBtn');
myGpsBtn.textContent = '⏳ Getting GPS...';
myGpsBtn.disabled = true;
navigator.geolocation.getCurrentPosition(
function(pos) {
const lat = Math.round(pos.coords.latitude * 1000000) / 1000000;
const lng = Math.round(pos.coords.longitude * 1000000) / 1000000;
// Update the marker on the GPS editor map
if (_gpsState.marker) {
_gpsState.marker.setLatLng([lat, lng]);
} else {
showToast('GPS map not initialized yet', true);
myGpsBtn.textContent = '📍 My GPS';
myGpsBtn.disabled = false;
return;
}
// Update coords display
document.getElementById('detailGpsCoords').textContent =
`${lat.toFixed(6)}, ${lng.toFixed(6)}`;
// Auto-unlock if currently locked
if (!_gpsState.unlocked) {
toggleGpsLock();
}
// Show save button and update status/info
document.getElementById('detailGpsSaveBtn').style.display = '';
document.getElementById('detailGpsStatus').textContent =
`📍 Device GPS (accuracy: ${Math.round(pos.coords.accuracy)}m)`;
showToast(`📍 GPS position set: ${lat}, ${lng}`, false);
myGpsBtn.textContent = '📍 My GPS';
myGpsBtn.disabled = false;
},
function(err) {
const msgs = {
1: 'GPS permission denied — allow location access in your browser settings',
2: 'GPS position unavailable — try moving to an open area',
3: 'GPS request timed out — try again',
};
showToast('GPS error: ' + (msgs[err.code] || err.message), true);
myGpsBtn.textContent = '📍 My GPS';
myGpsBtn.disabled = false;
},
{
enableHighAccuracy: true,
timeout: 15000,
maximumAge: 60000,
}
);
}
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.innerHTML = gi('📍') + ' Got location! ' + gi('✅') + ''; btn.disabled = false; }
setTimeout(updateNavSetup, 500);
showToast('📍 Location acquired');
},
() => {
if (btn) { btn.innerHTML = gi('📍') + ' 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,
user_id: AppState.currentUser?.id || null,
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">' + gi('📋') + ' 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)}${c.username ? ' · 👤 ' + esc(c.username) : ''}</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_group: 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;
mapFilters.disney_group = null;
} else {
var parkVal = document.getElementById('mapFilterDisneyPark').value;
if (parkVal && parkVal.startsWith('group:')) {
mapFilters.disney_group = parkVal.replace('group:', '');
mapFilters.disney_park = null;
mapFilters.disney_filter = null;
} else if (parkVal) {
mapFilters.disney_park = parkVal;
mapFilters.disney_group = null;
mapFilters.disney_filter = null;
} else {
mapFilters.disney_park = null;
mapFilters.disney_group = 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_group) 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_group) tags.push({key:'disney_group', label: (PARK_GROUP_ICONS[mapFilters.disney_group] || '📍') + ' ' + (PARK_GROUP_NAMES[mapFilters.disney_group] || mapFilters.disney_group)});
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_group') {
document.getElementById('mapFilterDisneyPark').value = '';
mapFilters.disney_group = 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_group: 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 (individual or group)
if (mapFilters.disney_group) {
var groupParks = PARK_GROUP_MAP[mapFilters.disney_group] || [];
if (groupParks.indexOf(a.disney_park) === -1) return;
}
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 ci = categoryInfo(a.category);
var icon = ci.icon;
if (icon.indexOf('<img') !== -1) icon = '🍽';
var gpsInfo = (a.latitude != null && a.longitude != null) ?
a.latitude.toFixed(6) + ', ' + a.longitude.toFixed(6) : '';
var popupHtml = '<b>' + esc(a.name) + '</b><br>' + esc(a.machine_id || '') +
(gpsInfo ? '<br><span style="font-size:11px;color:var(--text3,#888);font-family:monospace;">📍 ' + gpsInfo + '</span>' : '') +
'<br><button class="btn btn-outline btn-sm" style="margin-top:6px;" ' +
'onclick="switchTab(\'tabAssets\');viewAsset(' + a.id + ');">View Details</button>';
var catColor = ci.color;
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 ci = categoryInfo(a.category);
var icon = ci.icon;
var hasGps = (a.latitude != null && a.longitude != null);
var gpsDisplay = '';
if (hasGps) {
gpsDisplay = ' · <span style="font-family:monospace;font-size:10px;">' + a.latitude.toFixed(5) + ', ' + a.longitude.toFixed(5) + '</span>';
}
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 ? ' · 📍' : '') + gpsDisplay + '</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,
isReordering: false,
searchTimer: null,
currentStopIndex: 0, // which stop is currently active
};
// ── 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 work orders...');
try {
const filters = rpGetFilters();
const params = techs.map(t => 'tech=' + encodeURIComponent(t)).join('&');
// Add filter params
if (filters.status) params += '&status=' + encodeURIComponent(filters.status);
if (filters.priority) params += '&priority=' + encodeURIComponent(filters.priority);
if (filters.work_type) params += '&work_type=' + encodeURIComponent(filters.work_type);
if (filters.date_range) params += '&date_range=' + encodeURIComponent(filters.date_range);
const data = await api('/api/workorders/today?' + params);
if (!data.workorders || data.workorders.length === 0) {
rpHideLoading();
showToast('No work orders found matching filters', true); return;
}
const woIds = data.workorders.map(wo => wo.name);
rpState.currentStopIndex = 0;
await rpOptimizeRoute(woIds, data.workorders);
} catch (e) {
rpHideLoading();
showToast('Error loading 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 = '';
let hasReachedCurrent = false;
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>';
const isCurrent = i === rpState.currentStopIndex;
if (isCurrent) hasReachedCurrent = true;
const gps = stop.gps;
const mapsUrl = gps ? 'https://www.google.com/maps/dir/?api=1&destination=' + gps.lat + ',' + gps.lng : '';
const isDone = i < rpState.currentStopIndex;
stopHtml += '<div class="stop-item' + (isCurrent ? ' stop-current' : '') + (isDone ? ' stop-done' : '') + '">' +
'<div class="stop-num" style="' + (isDone ? 'background:var(--green,#3fb950);' : '') + (isCurrent ? 'background:var(--accent,#58a6ff);box-shadow:0 0 0 3px var(--accent-bg,#1a2744);' : '') + '">' +
(isDone ? '✓' : (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) : '') + (stop.booking_status ? ' · ' + esc(stop.booking_status) : '') + '</div>' +
(drive ? '<div class="stop-drive">' + drive + '</div>' : '') +
(cum ? '<div class="stop-cumulative">' + cum + '</div>' : '') +
'<div class="stop-actions" style="display:flex;gap:6px;margin-top:6px;flex-wrap:wrap;">' +
(gps ? '<button class="btn btn-secondary btn-xs" onclick="window.open(\'' + mapsUrl + '\',\'_blank\')" title="Open in Google Maps"><svg class="gi" aria-hidden="true"><use href="#gi-compass"/></svg> Navigate</button>' : '') +
(isCurrent ? '<button class="btn btn-primary btn-xs" onclick="rpNextStop()"><svg class="gi" aria-hidden="true"><use href="#gi-check"/></svg> Mark Done & Next</button>' : '') +
'</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').innerHTML = rpState.isReordering ? gi('✅') + ' Done' : gi('↕️') + ' 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').innerHTML = gi('✅') + ' 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.innerHTML = gi('✅') + ' Copied!'; setTimeout(() => btn.innerHTML = gi('📋') + ' 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;
}
}
// ── Next Stop ──────────────────────────────────────────────────────────
function rpNextStop() {
const route = rpState.currentRoute?.route || [];
if (rpState.currentStopIndex >= route.length - 1) {
showToast('✅ All stops completed! Start a new route.', false);
return;
}
rpState.currentStopIndex++;
rpDisplayRoute(rpState.currentRoute, null);
// Auto-navigate to next stop via Google Maps
const nextStop = route[rpState.currentStopIndex];
if (nextStop && nextStop.gps) {
const url = 'https://www.google.com/maps/dir/?api=1&destination=' +
nextStop.gps.lat + ',' + nextStop.gps.lng +
'&travelmode=driving';
showToast('→ Next: ' + (nextStop.name || 'Stop ' + (rpState.currentStopIndex + 1)), false);
// Offer to navigate
setTimeout(async () => {
if (await showModal('Navigate', 'Open directions to ' + (nextStop.account_name || nextStop.name) + ' in Google Maps?', 'Navigate', 'btn-primary')) {
window.open(url, '_blank');
}
}, 500);
}
}
// ── Route Filters ──────────────────────────────────────────────────────
function rpToggleFilterPanel() {
const panel = document.getElementById('rpFilterPanel');
panel.style.display = panel.style.display === 'none' ? '' : 'none';
}
function rpGetFilters() {
return {
status: document.getElementById('rpFilterStatus').value,
priority: document.getElementById('rpFilterPriority').value,
work_type: document.getElementById('rpFilterType').value,
date_range: document.getElementById('rpFilterDate').value,
};
}
function rpOnFilterChange() {
const f = rpGetFilters();
let count = 0;
if (f.status) count++;
if (f.priority) count++;
if (f.work_type) count++;
const badge = document.getElementById('rpFilterCount');
if (count > 0) { badge.textContent = count; badge.style.display = ''; }
else { badge.style.display = 'none'; }
// Show active filter chips
const chips = [];
if (f.status) chips.push('Status: ' + f.status);
if (f.priority) chips.push('Priority: ' + f.priority);
if (f.work_type) chips.push('Type: ' + f.work_type);
const container = document.getElementById('rpActiveFilters');
if (chips.length > 0) {
container.innerHTML = chips.map(c =>
'<span class="filter-chip">' + esc(c) + '</span>'
).join('');
container.style.display = '';
} else {
container.style.display = 'none';
}
}
function rpClearFilters() {
document.getElementById('rpFilterStatus').value = '';
document.getElementById('rpFilterPriority').value = '';
document.getElementById('rpFilterType').value = '';
document.getElementById('rpFilterDate').value = 'today';
rpOnFilterChange();
}
function rpLoadStatusOptions() {
// Load status options from WO tab or use defaults
const statusSelect = document.getElementById('rpFilterStatus');
if (!statusSelect) return;
// Match the WO tab options
const statuses = ['Scheduled', 'In Progress', 'Completed', 'Cancelled', 'On Hold'];
statuses.forEach(s => {
const opt = document.createElement('option');
opt.value = s;
opt.textContent = s;
statusSelect.appendChild(opt);
});
}
// Call on init
document.addEventListener('rpTabActivated', () => {
rpLoadStatusOptions();
});
// Game-icons SVG helper for dynamic content
function gi(name) {
const map = {
'📍': '<svg class="gi" aria-hidden="true"><use href="#gi-position"/></svg>',
'🔒': '<svg class="gi" aria-hidden="true"><use href="#gi-lock"/><\/svg>',
'🔓': '<svg class="gi" aria-hidden="true"><use href="#gi-unlock"/><\/svg>',
'📷': '<svg class="gi" aria-hidden="true"><use href="#gi-camera"/><\/svg>',
'📋': '<svg class="gi" aria-hidden="true"><use href="#gi-paper"/><\/svg>',
'✅': '<svg class="gi" aria-hidden="true"><use href="#gi-check"/><\/svg>',
'↕️': '<svg class="gi" aria-hidden="true"><use href="#gi-cycle"/><\/svg>',
'⚠': '<svg class="gi" aria-hidden="true"><use href="#gi-warning"/><\/svg>',
};
return map[name] || name;
}
// ── Register service worker for PWA caching ──────────────────────
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').then(reg => {
reg.onupdatefound = () => {
const installing = reg.installing;
if (installing) {
installing.onstatechange = () => {
if (installing.state === 'installed' && navigator.serviceWorker.controller) {
console.log('PWA update available — reload to activate');
}
};
}
};
}).catch(e => console.warn('SW registration failed:', e));
}
document.addEventListener('DOMContentLoaded', () => {
initAuth();
initGPS();
// First-visit shortcut discovery toast
if (!localStorage.getItem('canteen_shortcuts_shown')) {
setTimeout(() => {
showToast('💡 Press ? for keyboard shortcuts · Esc to close', false);
localStorage.setItem('canteen_shortcuts_shown', 'true');
}, 3000);
}
// Online/offline detection
window.addEventListener('online', updateOnlineStatus);
window.addEventListener('offline', updateOnlineStatus);
updateOnlineStatus();
});
</script>
<!-- Game Icons SVG sprite -->
<svg xmlns="http://www.w3.org/2000/svg" style="display:none" id="gi-sprite">
<symbol id="gi-menu" viewBox="0 0 512 512"><path fill="currentColor" d="M32 96v64h448V96zm0 128v64h448v-64zm0 128v64h448v-64z"/></symbol><symbol id="gi-camera" viewBox="0 0 512 512"><path fill="currentColor" d="M109.285 30.402c-14.82-.007-30.414 2.144-46.822 6.932l-.145.05a10 10 0 0 0-.496.173a10 10 0 0 0-.39.152a10 10 0 0 0-.47.218a10 10 0 0 0-.384.193a10 10 0 0 0-.463.273a10 10 0 0 0-.342.22a10 10 0 0 0-.435.32a10 10 0 0 0-.324.256a10 10 0 0 0-.397.357a10 10 0 0 0-.297.29a10 10 0 0 0-.37.405a10 10 0 0 0-.247.293a10 10 0 0 0-.342.463a10 10 0 0 0-.215.315a10 10 0 0 0-.282.472a10 10 0 0 0-.193.354a10 10 0 0 0-.233.497a10 10 0 0 0-.156.367a10 10 0 0 0-.178.512a10 10 0 0 0-.122.386a10 10 0 0 0-.17.688L33.42 156.55a9.5 9.5 0 0 0 5.625 10.614l82.023 34.578l31.62 162.133l-116.534 25.313a9.5 9.5 0 0 0-7.5 9.156l-1.062 76.47A9.5 9.5 0 0 0 39.967 484l146.468-46.813l24.123-4.677c.177.003.353.015.53.015c9.956 0 19.332-4.742 25.88-12.472c6.543-7.724 9.966-17.79 9.956-28.16v-.026c.006-6.574-1.372-13.037-4.045-18.855l-25.273-130.57l125.774 53.02l.155.058a10 10 0 0 0 .545.197a10 10 0 0 0 .35.11a10 10 0 0 0 .543.138a10 10 0 0 0 .416.086a10 10 0 0 0 .483.073a10 10 0 0 0 .482.05a10 10 0 0 0 .43.02a10 10 0 0 0 .54.004a10 10 0 0 0 .363-.01l83.406-5.28a9.5 9.5 0 0 0 7.97-5.438l50.522-106.954l.008-.014v-.002l.02-.047a9.5 9.5 0 0 0-5.51-13.137a93.5 93.5 0 0 0-23.956-9.148L152.273 36.688a10 10 0 0 0-.802-.268c-13.317-3.843-27.364-6.01-42.185-6.018zM82.938 44.125a9.5 9.5 0 0 1 3.687.72l290.75 116.53a9.51 9.51 0 0 1 5.77 10.79a9.5 9.5 0 0 0-.707 1.46l-.836 2.07a9.51 9.51 0 0 1-11.29 3.336L79.563 62.47a9.5 9.5 0 0 1 3.376-18.345zm357.308 128.367c4.4.038 8.75.508 13.068 1.404l14.74 5.72l-23.67 50.114c.912-4.058 1.378-8.082 1.335-11.855c-.1-8.732-2.94-15.95-7.845-19.563s-11.688-3.48-18.375.25c-1.65.92-3.305 2.063-4.906 3.375c.614-.822 1.19-1.72 1.687-2.687c1.417-2.757 2.248-5.952 2.22-8.78c-.032-2.835-.945-5.243-2.563-6.47a4.35 4.35 0 0 0-2.75-.906c-1.01.016-2.102.332-3.156.937c-2.107 1.212-4.08 3.52-5.5 6.282c-1.417 2.764-2.25 5.948-2.217 8.782c.03 2.833.945 5.242 2.562 6.47c1.617 1.225 3.798 1.147 5.906-.064c-3.416 3.562-6.546 7.9-9.092 12.72c-4.608 8.716-7.226 18.798-7.125 27.53s3 15.98 7.906 19.594c4.904 3.614 11.624 3.45 18.31-.28c4.178-2.332 8.29-6.018 11.91-10.582l-8.41 17.8l-62.75 4l37.253-92.39c14.635-7.746 28.272-11.513 41.463-11.4zm-298.078 38.145l16.014 6.752l29.732 139.555a32 32 0 0 0-9.342-1.41c-2.724 0-5.4.366-7.988 1.037l-28.416-145.935zm37.353 15.748l17.017 7.174l23 118.83a32 32 0 0 0-8.447-1.156c-1.643 0-3.266.14-4.865.39l-26.704-125.24zM211.09 370.3c3.863 0 8.258 1.978 11.472 5.772s5.433 9.89 5.424 15.795a9.47 9.532 0 0 0 0 .026c.01 5.905-2.21 12-5.423 15.795a17.1 17.1 0 0 1-5.188 4.142l-6.143 1.19c2.106-5.273 3.184-11.002 3.178-16.827v-.025a46 46 0 0 0-1.342-11.107a9.5 9.5 0 0 0-.193-2.248l-2.656-12.458c.29-.022.583-.055.87-.055zm-32.518 4.3c3.864 0 8.26 1.978 11.475 5.773s5.43 9.89 5.422 15.795a9.47 9.532 0 0 0 0 .025c.008 5.905-2.21 12-5.423 15.795c-3.215 3.795-7.61 5.774-11.475 5.774c-3.863 0-8.233-1.98-11.447-5.774s-5.458-9.89-5.45-15.795a9.47 9.532 0 0 0 0-.025c-.008-5.905 2.236-12 5.45-15.795s7.584-5.773 11.447-5.773zm-34.496 10.59a46 46 0 0 0-1.312 10.978v.025c-.01 10.37 3.41 20.437 9.953 28.16q1.259 1.486 2.648 2.817L46.78 461.875l.782-55.72l96.514-20.966z"/></symbol><symbol id="gi-map" viewBox="0 0 512 512"><path fill="currentColor" d="M227.4 34.7c-10.1 0-20.2.2-30.2.5l6.1 65.6l-61.1-62.5c-31.3 2.5-62.5 6.6-93.8 12.5l34.2 28.4l-48-.6c35.1 100.2 6.9 182.6-.3 292.1L130 476.5c10-1.3 19.9-2.4 29.6-3.3l21.5-42.2l18.6 28.8l41.5-33.5l.8 43c82.9-.2 157.7 9.1 235.7 7.9c-28.2-73-31.2-143.6-31.9-209.2l-33.3-19.1l32.7-33.9c-.4-21.3-1.3-42-3.6-61.9l-57.4.7l50.2-41.7c-3.8-15.5-9-30.4-16.1-44.7l-29.5-23.9C335 38 281.2 34.6 227.4 34.7m58.7 37c10.6 24.75 21.1 49.5 31.7 74.3c7.5-10.5 14.9-21 22.4-31.5c16 27.2 32 54.3 48 81.5l-16.2 9.5l-33.3-56.7l-42.5 59.4l-15.2-10.9l24-33.5l-21.9-51.5l-24.6 40.1l12 22.6l-16.5 8.8l-18.3-34.5l-24.8 58.2l-17.2-7.4l32.5-76.2l7.7-18c4.8 9.2 9.6 18.3 14.5 27.4c12.5-20.6 25.1-41.11 37.7-61.6M91.2 128c6.72 1.6 13.4 3.4 19.2 5.3c-2.1 5.9-4.1 11.8-6.2 17.6c-5.79-1.6-11.72-3.4-16.9-4.7c1.39-6 2.62-12.1 3.9-18.2m37.9 13.4c6.3 3.8 12 7.2 17 12.8L132.6 167c-4-3.7-8.6-7-12.8-9.4zm28.7 32.3c2.1 7.4 2.1 15.7 1.6 22.5l-18.5-2.4c.1-5.1.3-10-1-14.5zm-21.2 35.7l17.2 7.1c-3.3 6.6-5.1 12.7-8.6 17.8l-16.3-9c2.6-5.4 5.6-10.8 7.7-15.9m-16.5 34.1l17.7 6.1c-1.5 5.4-3 11.2-3.6 16.2l-18.6-2c1.3-7.5 2.1-14 4.5-20.3m207.8 17.4c8.5 1 14.6 3 21.7 7.1l-9.8 16c-4.1-2.8-9.4-3.8-13.5-4.5zm-21.2 1.5c1.1 6.1 2.5 12.2 3.9 18.3c-5.9 1.3-11.7 3.3-16.5 5.1l-6.8-17.4c6.7-2.4 13.5-4.7 19.4-6m-37.9 15.9l11 15.1c-5.6 4-11.8 7.8-16.8 10.6l-8.9-16.4c5.1-2.9 10.6-6.3 14.7-9.3M135.3 281c1.5 4.7 4.2 9.2 6.9 12.1l-13.8 12.6c-5.5-5.7-9.5-13.5-11.2-20.1zm230.3 3.3c3.5 6.4 6.8 12.7 8.7 19.1l-17.8 5.6c-2-5.4-4.3-10.8-6.8-14.8zm-127.4 10.9l6.9 17.3c-6.4 2.7-12.9 4.8-18.6 6.5l-5-18c5.9-1.6 11.3-3.8 16.7-5.8m-83.8 6.2c5.3 1.7 10.8 3.4 15.7 4.2c-1.2 6.1-2 12.3-2.8 18.5c-7-1-14.5-3.3-20.5-5.7zm50 3.5l2.8 18.5c-7.2 1.3-13.4 1.6-19.8 1.9l-.4-18.7c5.9-.2 11.6-.8 17.4-1.7m174.5 18c1 6.4 1.6 12.9 2.2 19.3l-18.7 1.5c-.4-6-.9-11.9-2-17.8zm-67.6 30.8c18.9 3.5 44.9 16.2 68.9 33.9c7.4-9.9 14.4-20.4 21.3-31.1l30.1 12.9c-4.7 12.3-15 25.6-28.6 37.2c17 16.2 30.9 34.5 37 53c-13.8-18.1-31.1-31.8-50.3-42.8c-23.4 15.8-52.7 25.9-79.6 20.4c22.9-4.4 40.6-16.6 55.8-32.6c-16.5-7.5-33.8-13.9-51.3-20.1z"/></symbol><symbol id="gi-compass" viewBox="0 0 512 512"><path fill="currentColor" d="m203.97 23l-18.032 4.844l11.656 43.468c-25.837 8.076-50.32 21.653-71.594 40.75L94.53 80.594l-13.218 13.22l31.376 31.374c-19.467 21.125-33.414 45.53-41.813 71.343l-42.313-11.343l-4.843 18.063l42.25 11.313c-6.057 27.3-6.157 55.656-.345 83L23.72 308.78l4.843 18.064l41.812-11.22a193.3 193.3 0 0 0 31.25 59.876l-29.97 52.688l-16.81 29.593l29.56-16.842l52.657-29.97a193.3 193.3 0 0 0 60.094 31.407l-11.22 41.844l18.033 4.81l11.218-41.905a195.7 195.7 0 0 0 83-.375l11.312 42.28l18.063-4.81l-11.344-42.376c25.812-8.4 50.217-22.315 71.342-41.78l31.375 31.373l13.22-13.218l-31.47-31.47a193.3 193.3 0 0 0 40.72-71.563l43.53 11.657l4.813-18.063l-43.625-11.686a195.7 195.7 0 0 0-.344-82.063l43.97-11.78l-4.813-18.063L440.908 197c-6.73-20.866-17.08-40.79-31.032-58.844l29.97-52.656l16.842-29.563l-29.593 16.844l-52.656 29.97c-17.998-13.875-37.874-24.198-58.657-30.906l11.783-44L309.5 23l-11.78 43.97c-27-5.925-55.02-6.05-82.064-.376zm201.56 85L297.25 298.313l-.75.437l-40.844-40.875l-148.72 148.72l-2.186 1.25l109.125-191.75l41.78 41.78L405.532 108zm-149.686 10.594c21.858 0 43.717 5.166 63.594 15.47l-116.625 66.342l-2.22 1.28l-1.28 2.22l-66.25 116.406c-26.942-52.04-18.616-117.603 25.03-161.25c26.99-26.988 62.38-40.468 97.75-40.468zm122.72 74.594c26.994 52.054 18.67 117.672-25.002 161.343c-43.66 43.662-109.263 52.005-161.312 25.033l116.438-66.282l2.25-1.25l1.25-2.25l66.375-116.592z"/></symbol><symbol id="gi-exit" viewBox="0 0 512 512"><path fill="currentColor" d="M217 28.098v455.804l142-42.597V70.697zm159.938 26.88l.062 2.327V87h16V55zM119 55v117.27h18V73h62V55zm258 50v16h16v-16zm0 34v236h16V139zm-240 58.727V233H41v46h96v35.273L195.273 256zM244 232c6.627 0 12 10.745 12 24s-5.373 24-12 24s-12-10.745-12-24s5.373-24 12-24M137 339.73h-18V448h18zM377 393v14h16v-14zm0 32v23h16v-23zM32 471v18h167v-18zm290.652 0l-60 18H480v-18z"/></symbol><symbol id="gi-gear" viewBox="0 0 512 512"><path fill="currentColor" d="m438.582 296.069l53.302-14.694l-.024-50.728l-53.278-14.743c-4.76-21.901-13.445-42.325-25.185-60.608l27.323-48.157l-35.904-35.855l-48.078 27.3C338.4 86.818 317.975 78.16 296.053 73.347l-14.746-53.224H230.64l-14.77 53.224c-21.874 4.813-42.324 13.472-60.61 25.235l-48.13-27.298l-35.88 35.883l27.3 48.077c-11.74 18.336-20.401 38.76-25.236 60.66l-53.198 14.744v50.727l53.198 14.694a186.3 186.3 0 0 0 25.235 60.658l-27.298 48.157l35.88 35.83l48.128-27.274a186.2 186.2 0 0 0 60.66 25.186l14.72 53.25l50.693-.024l14.72-53.225c21.923-4.813 42.348-13.47 60.686-25.212l48.127 27.327l35.805-35.883l-27.273-48.155c11.714-18.31 20.4-38.708 25.185-60.635M200.588 122.394h110.819l78.333 78.358v9.207h-55.162c-15.844-26.933-45.134-45.051-78.582-45.051c-33.445 0-62.735 18.118-78.578 45.051H122.23v-9.207zm55.408 88.542c24.88 0 45.072 20.196 45.072 45.052c0 24.929-20.19 45.1-45.072 45.1c-24.872 0-45.068-20.17-45.068-45.1c0-24.856 20.196-45.052 45.068-45.052m55.411 179.05H200.588l-78.357-78.358v-9.612h55.106a91.7 91.7 0 0 0 14.253 18.417c17.206 17.206 40.082 26.681 64.407 26.681c24.333 0 47.208-9.476 64.415-26.683a91.6 91.6 0 0 0 14.25-18.415h55.079v9.612z"/></symbol><symbol id="gi-crate" viewBox="0 0 512 512"><path fill="currentColor" d="M247 16v89h9c14.9 0 23 11.7 23.8 23.6c.4 6-1.2 11.5-4.7 15.4c-3.5 4-9.1 7-19.1 7c-5 0-11.1-2.8-15.6-7.4c-4.6-4.5-7.4-10.6-7.4-15.6h-18c0 11 5.2 20.9 12.6 28.4c7.5 7.4 17.4 12.6 28.4 12.6c14 0 25.4-5 32.5-13c7.2-8.1 10-18.6 9.3-28.6c-1.2-17.5-13.4-35.18-32.8-39.42V16zm-47.9 140.5L61.34 247h32.7l114.86-75.5zm113.8 0l-9.8 15L418 247h32.6zM41 265v222h430V265zm38 23h18v176H79zm48 0h18v176h-18zm48 0h18v176h-18zm48 0h18v176h-18zm48 0h18v176h-18zm48 0h18v176h-18zm48 0h18v176h-18zm48 0h18v176h-18z"/></symbol><symbol id="gi-search" viewBox="0 0 512 512"><path fill="currentColor" d="M333.78 20.188c-39.97 0-79.96 15.212-110.405 45.656c-58.667 58.667-60.796 152.72-6.406 213.97l-15.782 15.748l13.25 13.25l15.75-15.78c61.248 54.39 155.3 52.26 213.968-6.407c60.887-60.886 60.888-159.894 0-220.78C413.713 35.4 373.753 20.187 333.78 20.187zm0 18.562c35.15 0 70.285 13.44 97.158 40.313c53.745 53.745 53.744 140.6 0 194.343c-51.526 51.526-133.46 53.643-187.5 6.375l.218-.217c-2.35-2.05-4.668-4.17-6.906-6.407c-2.207-2.206-4.288-4.496-6.313-6.812l-.218.22c-47.27-54.04-45.152-135.976 6.374-187.502C263.467 52.19 298.63 38.75 333.78 38.75m0 18.813c-30.31 0-60.63 11.6-83.81 34.78c-46.362 46.362-46.362 121.234 0 167.594c10.14 10.142 21.632 18.077 33.905 23.782c-24.91-19.087-40.97-49.133-40.97-82.94c0-15.323 3.292-29.888 9.22-43c-4.165 20.485.44 40.88 14.47 54.907c24.583 24.585 68.744 20.318 98.624-9.562s34.146-74.04 9.56-98.625a52.4 52.4 0 0 0-7.655-6.313c45.13 8.648 79.954 46.345 84.25 92.876c4.44-35.07-6.82-71.726-33.813-98.72c-23.18-23.18-53.47-34.78-83.78-34.78zM176.907 297.688L42.094 432.5l34.562 34.563L211.47 332.25zM40 456.813L24 472.78L37.22 486l15.968-16z"/></symbol><symbol id="gi-paper" viewBox="0 0 512 512"><path fill="currentColor" d="M281.293 94.283a9 9 0 0 1-.23.203c-.027.023-.048.046-.073.07c.113-.103.143-.126.303-.273m.754.62c-.922-.026-1.59.26-1.79.333c-18.276 16.014-26.02 33.42-43.35 51.993c-17.25 18.485-43.688 36.204-96.677 51.225l-31.087 42.13a9 9 0 0 1-7.163 3.657l-50.75.45l-14.69-8.344l-2.622 119.744l14.603-8.426h57.28a9 9 0 0 1 6.934 3.264c13.526 16.35 20.025 32.04 37.946 39.838l200.556 61.878c12.478-.538 22.443-2.015 28.625-5.568c5.266-3.025 8.892-7.468 10.952-16.584c-5.457-2.305-23.53-9.945-47.185-19.853c-13.495-5.652-27.03-11.294-37.236-15.494c-5.103-2.1-9.382-3.842-12.37-5.027a161 161 0 0 0-3.397-1.317c-.208-.077-.253-.09-.367-.13c-.157.023-.3.105-1.897-.677c-.994-.486-4.692-10.586-4.692-10.588c0 0 5.642-6.027 6.47-6.234c2.54-.635 2.98-.25 3.483-.18c.2.028.253.05.388.074c.13.016.294.036.637.073c.89.095 2.327.236 4.183.41c3.713.345 9.13.827 15.67 1.397c13.082 1.14 30.68 2.633 48.37 4.112c17.69 1.477 35.475 2.942 48.945 4.03c6.734.544 12.392.992 16.406 1.3c2.006.155 3.606.275 4.702.352c.445.03.74.05 1 .065c7.954-.59 12.096-2.93 14.38-5.373c2.315-2.48 3.274-5.563 3.075-9.477c-.392-7.7-6.855-16.7-13.162-18.697l-125.047-16.39a9 9 0 0 1 .357-17.886s34.4-3.114 69.246-6.35c17.425-1.62 34.963-3.27 48.35-4.575c6.695-.654 12.355-1.22 16.42-1.652c2.03-.215 3.67-.397 4.798-.533c.516-.062 1.142-.195 1.516-.27c10.906-3.095 16.196-7.17 18.164-10.054c2.01-2.944 1.993-5.088.517-8.644c-2.946-7.1-15.285-15.783-23.27-16.322l-140.36-3.662a9 9 0 0 1-1.555-17.817s30.514-6.195 61.904-12.542c15.695-3.174 31.608-6.386 44.04-8.88c12.434-2.496 20.85-4.184 23.35-4.617c9.49-1.643 13.86-5.275 16.143-9.164c2.282-3.888 2.655-8.88.996-14.175c-2.902-9.267-11.46-17.814-23.172-18.067a26 26 0 0 0-5.204.422l-.19.03l-148.954 23.665c-7.51 10.38-14.5 15.897-23.953 22.977c-2.022 57.078-12.448 85.543-27.33 100.578c-15.283 15.436-34.4 14.424-40.335 15.908l-4.368-17.46c12.134-3.035 21.968-1.066 31.91-11.11s20.786-34.636 22.338-92.803a9 9 0 0 1 3.606-6.965c12.425-9.295 17.212-12.277 25.073-23.886l35.527-78.43c-1.064-7.994-5.013-11.687-9.64-13.893c-2.5-1.192-5.197-1.74-7.043-1.79z"/></symbol><symbol id="gi-save" viewBox="0 0 512 512"><path fill="currentColor" d="M64 48c-8.726 0-16 7.274-16 16v384c0 8.726 7.274 16 16 16h215v-16H64V64h63.375v97.53c0 3.924 3.443 7.095 7.72 7.095h169.81c4.277 0 7.72-3.17 7.72-7.094V64h69.22c.428.318.8.548 1.467 1.094c2.05 1.675 4.962 4.264 8.375 7.406c6.827 6.283 15.65 14.837 24.313 23.5s17.217 17.486 23.5 24.313c3.142 3.413 5.73 6.324 7.406 8.374c.546.668.776 1.04 1.094 1.47V330.25l16 16V128c0-2.68-.657-3.402-1.03-4.156a15 15 0 0 0-1.095-1.844c-.74-1.1-1.575-2.19-2.594-3.438c-2.036-2.492-4.768-5.55-8.03-9.093c-6.524-7.09-15.155-16-23.938-24.782s-17.692-17.414-24.78-23.938c-3.545-3.262-6.6-5.994-9.094-8.03c-1.247-1.02-2.337-1.855-3.438-2.595c-.55-.37-1.09-.72-1.844-1.094c-.754-.373-1.477-1.03-4.156-1.03zm87.72 16h48.56c4.277 0 7.72 4.425 7.72 9.938v70.124c0 5.513-3.443 9.938-7.72 9.938h-48.56c-4.277 0-7.72-4.425-7.72-9.938V73.938c0-5.512 3.443-9.937 7.72-9.937zM114 212c-4.432 0-8 3.568-8 8v184c0 4.432 3.568 8 8 8h165v-28h-76.72l15.345-15.375l128-128L352 234.28l6.375 6.345L406 288.25V220c0-4.432-3.568-8-8-8zm238 47.75L245.75 366H297v128h110V366h51.25zM448 384v64h-23v16h23c8.726 0 16-7.274 16-16v-64z"/></symbol><symbol id="gi-key" viewBox="0 0 512 512"><path fill="currentColor" d="M226.878 16v243.274a120.023 120.023 0 0 0 30 236.71a119.996 119.996 0 0 0 29.998-236.242v-93.747h59.998v-44.998h-59.998v-45h89.997V16zm25.312 299.99a60 60 0 0 1 2.343 0a60 60 0 0 1 2.343 0a59.998 59.998 0 0 1 0 119.996a60.044 60.044 0 0 1-4.688-119.996z"/></symbol><symbol id="gi-lock" viewBox="0 0 512 512"><path fill="currentColor" d="M253.922 20.86c-38.28 0-74.42 9.152-106.36 25.376l11.967 20.73c29.084-15.037 62.08-23.538 97.044-23.538c35.998 0 69.908 9.015 99.613 24.892l10.727-18.58c-33.535-18.404-72.04-28.88-112.992-28.88zM131.246 55.356C66.09 95.27 21.88 166.017 18.902 247.297h25.97c2.682-71.938 41.318-134.77 98.45-171.022l-12.076-20.918zm251.682 4.028l-10.662 18.467c55.79 36.48 93.37 98.538 96.01 169.447h20.67c-2.88-78.62-44.34-147.377-106.018-187.915zm-133.62 2.88C184.307 64.66 127.58 99.067 94.4 150.218l46.106 26.618a141 141 0 0 0-9.393 16.158L85 166.373c-13.8 26.61-21.603 56.844-21.603 88.926c0 32.418 7.97 62.948 22.042 89.76l45.917-26.51a141 141 0 0 0 9.457 16.12L94.92 361.166c33.25 50.713 89.725 84.778 154.393 87.162v-52.113c2.685.152 5.39.238 8.112.238q5.34-.002 10.578-.392v52.07c63.662-3.702 118.987-38.127 151.375-88.706l-44.652-25.78c3.45-5.17 6.565-10.58 9.318-16.2l44.64 25.77c13.475-26.36 21.074-56.24 21.074-87.915c0-31.34-7.447-60.917-20.656-87.073l-44.82 25.875a140 140 0 0 0-9.26-16.233l44.857-25.897c-32.316-51.016-87.88-85.79-151.877-89.512v52.544c-3.492-.26-7.018-.395-10.576-.395c-2.724 0-5.428.085-8.113.238V62.264zm-37.177 69.422l20.276 35.145a92.7 92.7 0 0 1 24.867-3.383c8.623 0 16.97 1.184 24.89 3.388l20.278-35.15l40.14 23.078l-20.355 35.283c11.824 11.63 20.54 26.41 24.785 42.985h40.722v46.156h-40.74c-4.253 16.56-12.974 31.322-24.795 42.94l20.383 35.33l-40.14 23.077l-20.315-35.213a92.7 92.7 0 0 1-24.852 3.38c-8.6 0-16.927-1.18-24.83-3.374l-20.312 35.207l-40.136-23.078l20.37-35.312c-11.828-11.62-20.554-26.39-24.81-42.957H126.84v-46.155h40.697c4.246-16.583 12.967-31.37 24.8-43.006l-20.343-35.264l40.137-23.078zm-193.185 134.3c3.365 80.998 47.677 151.42 112.797 191.085l12.725-22.04c-57.045-35.686-95.94-97.754-99.492-169.046zm449.23 0c-3.5 70.267-41.33 131.58-97.036 167.5l11.3 19.572c61.64-40.295 103.206-108.731 106.46-187.071h-20.726zm-113.18 176.915c-29.42 15.485-62.903 24.264-98.42 24.264c-34.484 0-67.055-8.27-95.846-22.922l-12.646 21.9c31.814 16.06 67.766 25.116 105.838 25.116c40.746 0 79.07-10.366 112.484-28.596z"/></symbol><symbol id="gi-unlock" viewBox="0 0 512 512"><path fill="currentColor" d="M78.53 157.188c-23.843 0-43.93 21.364-50.28 50.593c-4.216 5.54-6.75 12.435-6.75 19.876c0 7.506 2.56 14.467 6.844 20.03c6.475 28.963 26.485 50.064 50.187 50.064c24.293 0 44.72-22.173 50.657-52.25H234.97v65.594h19.31v-44.22h18.69v44.22h20.092V286h18.688v25.094h15.125V245.5h17.97v-33.47h-215.19c-5.248-31.382-26.14-54.843-51.124-54.843zm345.91 12.092c-31.78 0-57.813 26.036-57.813 57.814c0 17.65 7.836 33.673 20.47 44.312l-25.22 72.938l-4.28 12.406H490.72l-4.314-12.406l-25.187-72.938c12.63-10.64 20.467-26.662 20.467-44.312c0-31.642-25.47-57.813-57.25-57.813zm0 18.69c21.617 0 38.562 17.372 38.562 39.124c0 14.002-7.057 26.117-17.875 33l-6.22 3.97l2.408 6.967l23.156 67.033H383.81L407 271.03l2.406-6.967l-6.22-3.97c-10.817-6.882-17.873-18.997-17.873-33c0-21.615 17.507-39.124 39.125-39.124zM54.47 213.375c8.003 0 14.313 6.28 14.313 14.28c0 8.003-6.31 14.282-14.31 14.282c-8.003 0-14.282-6.28-14.282-14.28c0-8.003 6.28-14.282 14.28-14.282z"/></symbol><symbol id="gi-chart" viewBox="0 0 512 512"><path fill="currentColor" d="M23 23v466h466v-18H41v-82.184l85.854-57.234l70.023 70.022l65.133-260.536L387.28 203.7l67.79-107.97l19.317 11.858l6.102-71.1l-60.644 37.616l19.884 12.207l-59.01 93.99l-130.732-65.366l-62.865 251.462l-57.98-57.978L41 367.184V23z"/></symbol><symbol id="gi-person" viewBox="0 0 512 512"><path fill="currentColor" d="M250.882 22.802c-23.366 3.035-44.553 30.444-44.553 65.935c0 19.558 6.771 36.856 16.695 48.815l11.84 14.263l-18.217 3.424c-12.9 2.425-22.358 9.24-30.443 20.336c-8.085 11.097-14.266 26.558-18.598 44.375c-7.843 32.28-9.568 71.693-9.842 106.436h42.868l11.771 157.836c29.894 6.748 61.811 6.51 90.602.025l10.414-157.86h40.816c-.027-35.169-.477-75.126-7.584-107.65c-3.918-17.934-9.858-33.372-18.04-44.343c-8.185-10.97-18.08-17.745-32.563-19.989l-18.592-2.88l11.736-14.704c9.495-11.897 15.932-28.997 15.932-48.082c0-37.838-23.655-65.844-49.399-65.844z"/></symbol><symbol id="gi-wrench" viewBox="0 0 512 512"><path fill="currentColor" d="M151 16c-14.774 0-30 15.226-30 30v105c0 14.774 11.946 26.718 26.718 26.718H181V334.28h-33.282c-14.773 0-26.718 11.946-26.718 26.718v105c0 14.774 15.227 30 30 30h30v-90l75-45l75 45v90h30c14.774 0 30-15.226 30-30v-105c0-14.773-11.946-26.718-26.718-26.718H331V177.718h33.282C379.056 177.718 391 165.772 391 151V46c0-14.773-15.226-30-30-30h-30v90l-75 45l-75-45V16z"/></symbol><symbol id="gi-target" viewBox="0 0 512 512"><path fill="currentColor" d="M107.563 21.406c122.47 187.613 107.72 216.17-74.97 55.813c83.344 103.73 183.05 185.66 287.876 260.75c-47.685 54.762-65.51 116.2-39.283 141.374c27.95 26.827 95.317 2.292 150.25-54.938s76.668-125.547 48.72-152.375c-26.4-25.338-87.652-4.57-140.657 46.033C267.523 213.846 194.602 110.56 107.562 21.406zm324.906 266.781c9.865-.14 18.234 2.508 24.25 8.282c21.386 20.528 4.908 72.768-37.126 116.56c-42.035 43.794-93.957 62.81-115.344 42.283c-19.952-19.15-6.325-65.992 29.72-107.75c3.706 2.625 7.405 5.264 11.124 7.875c-26.5 31.075-36.24 65.542-21.406 79.78c16.03 15.39 54.65 1.294 86.156-31.53c31.507-32.825 44-71.99 27.97-87.375c-15.205-14.592-50.578-2.533-81.064 26.75c-2.594-3.763-5.187-7.52-7.78-11.282c28.526-27.297 60.18-43.263 83.5-43.592zm-22.033 37.375c5.66-.08 10.457 1.44 13.907 4.75c12.267 11.774 2.827 41.758-21.28 66.875c-24.11 25.118-53.89 36.024-66.158 24.25c-11.245-10.793-3.876-36.93 16-60.562c5.976 4.173 11.97 8.327 17.97 12.47c-9.746 11-14.027 22.66-9.563 27.124c4.904 4.903 18.483-.735 30.343-12.595s17.498-25.47 12.594-30.375c-4.778-4.778-17.8.476-29.438 11.72q-6.316-9.138-12.625-18.283c16.46-15.875 34.774-25.184 48.25-25.375z"/></symbol><symbol id="gi-check" viewBox="0 0 512 512"><path fill="currentColor" d="M17.47 250.9C88.82 328.1 158 397.6 224.5 485.5c72.3-143.8 146.3-288.1 268.4-444.37L460 26.06C356.9 135.4 276.8 238.9 207.2 361.9c-48.4-43.6-126.62-105.3-174.38-137z"/></symbol><symbol id="gi-cross" viewBox="0 0 512 512"><path fill="currentColor" d="M105.367 18.328c23.14 15.444 46.098 31.27 68.55 47.572c-45.055-20.895-94.51-35.918-149.37-44.246c46.697 26.72 91.596 55.58 135.705 85.524c-37.203-18.033-77.48-32.22-121.602-41.37c58.218 34.322 109.368 72.465 154.71 114.206C136.02 227.227 86.295 284.717 45.79 354.18c27.11-24.29 54.91-47.545 82.868-70.68C81.942 339.36 45.05 405.01 20.2 482.135c20.36-24.62 40.988-48.203 61.905-70.817c44.7-67.485 89.567-147.11 148.856-170.418c-29.61 30.708-63.36 75.164-98.25 118.145c40.99-40.437 83.09-77.46 126.415-111.512c61.598 70.49 110.757 149.38 152.145 235.873c-6.738-44.794-16.796-87.384-30.03-127.666l46.444 65.53s-26.037-72.69-43.66-101.987c40.76 55.91 78.208 114.428 112.328 175.205c-18.674-89.454-50.512-169.772-98.893-238.224a1783 1783 0 0 1 100.93 109.045C465.048 288.827 423.58 221.82 372.214 167c40.224-25.887 81.48-49.73 123.863-71.783a757 757 0 0 0-92.006 21.934c21.836-16.173 44.41-32.124 67.024-47.523c-37.987 11.91-74.633 25.775-109.067 41.433c42.668-27.673 86.32-53.668 131.004-78.602h-.003c-67.47 18.055-130.83 42.19-188.998 73.548c-56.294-41.79-122.01-71.787-198.663-87.68z"/></symbol><symbol id="gi-help" viewBox="0 0 512 512"><path fill="currentColor" d="M256 16C123.45 16 16 123.45 16 256s107.45 240 240 240s240-107.45 240-240S388.55 16 256 16m0 60c99.41 0 180 80.59 180 180s-80.59 180-180 180S76 355.41 76 256S156.59 76 256 76m0 30c-66.274 0-120 40.294-120 90c0 30 60 30 60 0c0-16.57 26.862-30 60-30s60 13.43 60 30s-30 15-60 30a19.6 19.6 0 0 0-4.688 3.28C226.53 244.986 226 271.926 226 286v15c0 16.62 13.38 30 30 30s30-13.38 30-30v-15c0-45 90-40.294 90-90s-53.726-90-120-90m0 240a30 30 0 0 0-30 30a30 30 0 0 0 30 30a30 30 0 0 0 30-30a30 30 0 0 0-30-30"/></symbol><symbol id="gi-flame" viewBox="0 0 512 512"><path fill="currentColor" d="M245.05 15.514c34.29 48.815-23.535 320.54-90.302 136.72C106.796 325.11 38.956 332.518 38.876 252.55c-71.6 79.31 43.824 220.767 87.376 243.935h52.127c-45.92-40.016-76.784-78-82.176-135.968c47.312 9.423 71.855 20.96 81.263-62.048c60.736 86.59 100.944-49.376 137.184-107.12c-1.647 40.32-3.343 93.456 22.848 129.888c8.736 12.143 33.232 16.11 54.736 15.807c-9.92 16.08-44.848 69.376-17.008 89.2s33.072-.384 25.856 16.176c-13.264 20.88-22.992 39.375-59.072 54.063h56.064c59.44-18.72 111.807-91.663 94.607-135.535c-22.015 18.657-43.774 30.897-61.294 29.537c49.12-72.08 37.84-145.903 14.752-221.342c-20.224 72.383-33.488 82.495-54.576 99.52c29.104-68.657-85.44-214.448-146.51-253.15z"/></symbol><symbol id="gi-satellite" viewBox="0 0 512 512"><path fill="currentColor" d="m314.125 45.125l-36.28 10.97l26.717 38.874L340.844 84zM260.47 61.313l-43 13l26.686 38.874l41.625-12.562l-26.467-38.5zm91.467 38.874l-36.28 10.938l27.374 39.813L379.314 140l-27.375-39.813zM120.47 107.78l-36.282 10.94l26.718 38.905l36.28-10.97l-26.717-38.874zm176.405 9.032l-41.594 12.563l12.814 18.656c13.59 1.764 26.138 6.878 36.844 14.44l19.312-5.845l-27.375-39.813zm-230.03 7.157l-43 12.968l26.718 38.906l41.562-12.563l-26.47-38.5l1.19-.81zm323.56 32.155l-36.28 10.97l25.97 37.81l36.28-10.936l-25.97-37.845zm-232.092 6.72L122 173.78l27.375 39.814l36.28-10.938zm99.125 3.186c-22.736 0-42.626 11.753-53.97 29.532l66.782 97.188a63.5 63.5 0 0 0 18.72-6.97l7.874 11.69l15.78-10.033l-8.874-13.187c10.95-11.475 17.656-27.028 17.656-44.22c0-35.446-28.52-64-63.97-64zm77.906 6.75l-14.438 4.345a82.46 82.46 0 0 1 18.188 40.188l22.22-6.72l-25.97-37.812zm-232.125 6.69L61.655 192l27.313 39.813l41.593-12.563l-27.344-39.78zm324.28 30.655l-36.28 10.97l27.342 39.81l36.313-10.967zm-230.72 8.688l-36.31 10.968l25.968 37.782l36.312-10.968l-25.97-37.78zm175.657 7.937l-32.625 9.875c-.35 4.407-1.012 8.72-2.03 12.906l20.312 29.595l41.687-12.563l-27.342-39.812zm-230.75 8.688L100.094 248l25.97 37.813l41.592-12.594l-25.97-37.783zm324.282 30.656l-36.314 10.97l26 37.81l36.313-10.937l-26-37.843zm-232.095 6.687l-36.313 10.97l27.344 39.78l36.313-10.967l-27.345-39.782zm177.03 9.94L369.22 295.31l25.967 37.813l41.688-12.563l-25.97-37.843zm-232.124 6.686l-41.624 12.563l27.313 39.81l41.655-12.592zm152.314 8.47L301.78 316.5l37.314 58.656l29.312-18.625l-37.312-58.655zm-58.75 30.874l-36.313 10.97l26 37.81l36.314-10.936zm-55.094 16.625l-41.656 12.594l25.97 37.81l41.655-12.56l-25.97-37.845zm178.313 20.875c-36.29.507-64.44 29.054-70.375 64.844L368.5 404l9.72 14.406a18.4 18.4 0 0 0-1.908 8.188c0 10.222 8.278 18.53 18.5 18.53c10.223 0 18.5-8.308 18.5-18.53s-8.277-18.5-18.5-18.5c-.335 0-.67.045-1 .062l-9.437-14.062l37.438-23.438c-9.068-3.125-17.876-4.523-26.25-4.406z"/></symbol><symbol id="gi-ruler" viewBox="0 0 512 512"><path fill="currentColor" d="M429.5 30.83h-.8c-2.2.15-3.4.65-4.6 1.89L396.5 60.3l55.2 55.2l27.5-27.63c1.3-1.23 1.8-2.36 1.9-4.57c.2-2.21-.3-5.43-1.8-9.16c-2.8-7.46-9.2-16.67-17-24.45s-17-14.18-24.5-17.05c-3.2-1.26-6.1-1.81-8.3-1.83zm-320.6.32L31.15 108.9l9.91 9.9l27.56-27.55L81.36 104l-27.53 27.6l21.16 21.1l27.51-27.5l12.8 12.8l-27.55 27.5l21.15 21.1l50.2-50.1l12.8 12.8l-50.2 50.1l21.2 21.2l27.5-27.6l12.8 12.8l-27.5 27.6l21.1 21.1l27.5-27.5l12.8 12.8l-27.5 27.5l21.1 21.2l50.2-50.2l12.8 12.8l-50.2 50.2l21.2 21.1l27.5-27.5l12.8 12.8l-27.5 27.5l21.1 21.2l27.6-27.6l12.8 12.8l-27.6 27.6l21.1 21.1l50.2-50.2l12.8 12.8l-50.2 50.2l21.2 21.1l27.5-27.5l12.8 12.8l-27.5 27.5l21.1 21.2l27.6-27.6l12.8 12.8l-27.6 27.6l9.9 9.9l77.8-77.8zm274.9 41.89l-9.9 9.91l9.9 9.9l12.7 12.75l9.9 9.9l12.7 12.7l9.9 9.9l9.9-9.9zm-22.6 22.63L280 176.8l9.9 9.9l81.2-81.1zm22.6 22.63l-81.1 81.2l9.9 9.8l81.1-81.1zm22.6 22.6l-81.1 81.2l9.9 9.9l81.1-81.2zM176.8 280L72.68 384.2l9.9 9.9L186.7 289.9zm22.6 22.7L95.31 406.8l9.89 9.9l104.1-104.1zm22.7 22.6L117.9 429.4l9.9 9.9L232 335.2zM63.24 400.2l-12.12 30.3l30.4 30.4l30.28-12.1l-6.6-6.6l-12.72-12.8l-9.9-9.9l-12.73-12.7zm-19.39 48.5l-13 32.5l32.48-13z"/></symbol><symbol id="gi-rocket" viewBox="0 0 512 512"><path fill="currentColor" d="M18.78 25.97c2.6 25.278 9.316 48.72 19.532 69.75l-21.218-6.564L73.53 146.72l-.843-1.782c16.737 17.29 36.797 31.38 59.438 41.593c-13.546-18.845-21.597-41.677-22.03-69.28c27.78.35 50.396 8.56 69.25 22.063c-.627-1.4-1.284-2.776-1.94-4.157l.72.625l-34.438-86.655l-2.53 33.844c-30.974-31.598-73.044-52.084-122.376-57zm236.814 31.593L217.22 160.875c-30.663 7.52-54.566 32.236-60.907 63.344l-.188-.22l-106.563 39.563l74.313 74.312l93-8.03l.375-.033l23.03-2.468l-77.31-94.938l13.28-13.28l89.406 109.75l10.875 13.374l-17.124 1.844l-28.22 3.03a449 449 0 0 0 47.22 47.907c7.62-56.784 52.175-102.042 108.625-110.717a443.6 443.6 0 0 0-47.75-46.688l-2.78 26.125l-1.844 17.125L321.28 270l-107.874-88.03l13.25-13.25l93.094 75.905l2.22-20.844l7.905-91.936l-74.28-74.28zM401.53 301.625a111 111 0 0 0-25.436 4.156l13.875 16.908a116 116 0 0 1 23.467-5.282c-3.863-5.344-7.814-10.62-11.906-15.78zm-43.78 11.25a111 111 0 0 0-20.5 13.063l15.75 17.28a117 117 0 0 1 19.125-12.812zm68 22.438a99 99 0 0 0-22.97 3.03l14.5 17.688c6.108-1.82 12.447-3.043 18.97-3.686c-3.387-5.76-6.865-11.445-10.5-17.03zm-102.03 3.53a110.8 110.8 0 0 0-16.845 25.344l17.313 14.125c4.28-7.958 9.44-15.37 15.375-22.093l-15.844-17.376zm60.467 6.282c-6.67 3.247-12.908 7.267-18.625 11.875l16.282 17.875a102.3 102.3 0 0 1 17.312-11.47l-14.97-18.28zm-32 24.938a98.2 98.2 0 0 0-13.25 20.28l17.97 14.688a102.3 102.3 0 0 1 11.624-17l-16.342-17.967zm94.094.218v.22c-45.915 0-82.96 36.985-83.06 82.875a444.7 444.7 0 0 0 127.06 44.313c-8.284-45.328-23.3-88.145-44-127.407zM299.94 382.626a111 111 0 0 0-3.97 26.938a448 448 0 0 0 15.063 11.343c.93-8.58 2.75-16.88 5.44-24.812l-16.533-13.47zm32.156 26.25a99 99 0 0 0-3.094 24.375a448 448 0 0 0 16.063 9.938a101.5 101.5 0 0 1 4.218-20.282l-17.186-14.03z"/></symbol><symbol id="gi-car" viewBox="0 0 512 512"><path fill="currentColor" d="M355.975 292.25a24.82 24.82 0 1 0 24.82-24.81a24.84 24.84 0 0 0-24.82 24.81m-253-24.81a24.81 24.81 0 1 1-24.82 24.81a24.84 24.84 0 0 1 24.81-24.81zm-76.67-71.52h67.25l-13.61 49.28l92-50.28h57.36l1.26 34.68l32 14.76l11.74-14.44h15.62l3.16 16c137.56-13 192.61 29.17 192.61 29.17s-7.52 5-25.93 8.39c-3.88 3.31-3.66 14.44-3.66 14.44h24.2v16h-52v-27.48c-1.84.07-4.45.41-7.06.47a40.81 40.81 0 1 0-77.25 23h-204.24a40.81 40.81 0 1 0-77.61-17.67c0 1.24.06 2.46.17 3.67h-36z"/></symbol><symbol id="gi-run" viewBox="0 0 512 512"><path fill="currentColor" d="M372.97 24.938c-8.67.168-17.816 3.644-26.69 10.28c-12.618 9.44-24.074 25.203-30.5 44.844c-6.424 19.642-6.48 39.12-1.874 54.157c4.608 15.036 13.375 25.225 24.97 29c11.593 3.772 24.724.72 37.343-8.72c12.618-9.44 24.074-25.234 30.5-44.875c6.424-19.642 6.512-39.12 1.905-54.156c-4.607-15.038-13.404-25.196-25-28.97a32 32 0 0 0-8.938-1.563c-.573-.018-1.14-.01-1.718 0zm-155.69 69.78c-21.696.024-43.394 2.203-65.093 7.094c-24.91 29.824-43.848 60.255-52.875 98.47l37.376 17.812c8.273-30.735 21.485-53.817 43.375-77c22.706-7.844 45.418-6.237 68.125 1.5c-74.24 65.137-51.17 120.676-80.344 226.47c-42.653 17.867-85.098 20.53-123.25-.002L23 415.625c59.418 27.09 125.736 29.818 190.844 0c20.368-43.443 27.214-88.603 25-132.906C295.31 354.663 323.11 398.2 338.78 498.56h57.94c-3.12-14.706-6.21-28.394-9.345-41.218c-22.522-92.133-47.263-139.63-100.22-198.406c9.695-36.13 22.143-59.665 52.44-74.282c11.167 19.767 29.982 36.682 51.092 48.906l97.375 1.563l.47-41.03L402 191.968c-8.05-5.556-14.925-11.73-20.75-18.314c-14.886 9.08-32.024 12.563-48.156 7.313c-18.422-5.997-31.143-21.962-37.063-41.282c-3.482-11.37-4.742-24.05-3.686-37.25c-25.017-4.884-50.047-7.746-75.063-7.72z"/></symbol><symbol id="gi-truck" viewBox="0 0 512 512"><path fill="currentColor" d="M33 120v127.648c5.023 1.863 9.31 5.103 12.68 8.682c5.238 5.562 9.034 12.113 12.498 18.242s6.61 11.886 9.343 15.446C70.257 293.578 71.618 294 72 294c.59 0 .78.075 2.36-1.8c1.583-1.877 3.57-5.405 5.683-9.405s4.356-8.472 7.832-12.596s9.172-8.2 16.125-8.2s12.65 4.075 16.125 8.2c3.476 4.123 5.718 8.595 7.832 12.595s4.1 7.528 5.682 9.404c1.58 1.875 1.77 1.8 2.36 1.8s.78.075 2.36-1.8c1.583-1.877 3.57-5.405 5.683-9.405s4.356-8.472 7.832-12.596s9.172-8.2 16.125-8.2s12.65 4.075 16.125 8.2c3.476 4.123 5.718 8.595 7.832 12.595s4.1 7.528 5.682 9.404c1.58 1.875 1.77 1.8 2.36 1.8s.78.075 2.36-1.8c1.583-1.877 3.57-5.405 5.683-9.405s4.356-8.472 7.832-12.596s9.172-8.2 16.125-8.2s12.65 4.075 16.125 8.2c3.476 4.123 5.718 8.595 7.832 12.595s4.1 7.528 5.682 9.404c1.58 1.875 1.77 1.8 2.36 1.8s.78.075 2.36-1.8c1.583-1.877 3.57-5.405 5.683-9.405s4.356-8.472 7.832-12.596s9.172-8.2 16.125-8.2s12.448 3.3 17.025 7.004c2.142 1.733 4.125 3.638 5.975 5.617V120zm320 49.377v140.27l8-.026V326h3.81c9.298-18.914 28.774-32 51.19-32c19.463 0 36.707 9.867 47 24.846V262h16v-36.275l-28.256-42.385zm18.543 19.516l64.77 7.726l24.8 35.51v16.735h-89.57v-59.972zM33 269.148V294h15.537c-2.12-3.493-4.065-7.096-6.03-10.572c-3.173-5.617-6.4-10.827-9.507-14.28M104 280c-.59 0-.78-.075-2.36 1.8c-1.583 1.877-3.57 5.405-5.683 9.405c-.48.91-.972 1.847-1.478 2.795h19.04c-.505-.948-.997-1.886-1.477-2.795c-2.114-4-4.1-7.528-5.682-9.404c-1.58-1.875-1.77-1.8-2.36-1.8zm64 0c-.59 0-.78-.075-2.36 1.8c-1.583 1.877-3.57 5.405-5.683 9.405c-.48.91-.972 1.847-1.478 2.795h19.04c-.505-.948-.997-1.886-1.477-2.795c-2.114-4-4.1-7.528-5.682-9.404c-1.58-1.875-1.77-1.8-2.36-1.8zm64 0c-.59 0-.78-.075-2.36 1.8c-1.583 1.877-3.57 5.405-5.683 9.405c-.48.91-.972 1.847-1.478 2.795h19.04c-.505-.948-.997-1.886-1.477-2.795c-2.114-4-4.1-7.528-5.682-9.404c-1.58-1.875-1.77-1.8-2.36-1.8zm64 0c-.59 0-.78-.075-2.36 1.8c-1.583 1.877-3.57 5.405-5.683 9.405c-.48.91-.972 1.847-1.478 2.795h25.157c-.376-.512-.74-1.022-1.13-1.535c-2.787-3.646-5.967-7.173-8.804-9.47C298.866 280.7 296.59 280 296 280m185 0v46h14v-46zM72 310c-22.537 0-41 18.463-41 41s18.463 41 41 41s41-18.463 41-41s-18.463-41-41-41m104 0c-22.537 0-41 18.463-41 41s18.463 41 41 41s41-18.463 41-41s-18.463-41-41-41m240 0c-22.537 0-41 18.463-41 41s18.463 41 41 41s41-18.463 41-41s-18.463-41-41-41m-399 2v14h3.81a57.4 57.4 0 0 1 9.72-14zm96.47 0a57.4 57.4 0 0 1 9.72 14h1.62a57.4 57.4 0 0 1 9.72-14zm104 0a57.4 57.4 0 0 1 9.72 14H231v-14zM343 327.678l-94 .295v30l94-.295zM72 328c12.81 0 23 10.19 23 23s-10.19 23-23 23s-23-10.19-23-23s10.19-23 23-23m104 0c12.81 0 23 10.19 23 23s-10.19 23-23 23s-23-10.19-23-23s10.19-23 23-23m240 0c12.81 0 23 10.19 23 23s-10.19 23-23 23s-23-10.19-23-23s10.19-23 23-23"/></symbol><symbol id="gi-cycle" viewBox="0 0 512 512"><path fill="currentColor" d="M252.314 19.957c-72.036.363-142.99 33.534-189.18 95.97c-69.83 94.39-59.125 223.32 19.85 304.993l-37.238 50.332l151.22-22.613L174.35 297.42l-43.137 58.308c-44.08-54.382-47.723-133.646-4.16-192.53c30.676-41.466 77.863-63.504 125.758-63.753a156.8 156.8 0 0 1 48.645 7.467l-6.963-46.55a191 191 0 0 0-71.017-.997c-59.232 7.322-113.994 39.918-148.157 91.215c35.65-65.89 103.774-105.918 176.043-107.744a204.3 204.3 0 0 1 49.62 4.84l48.608-7.268c-31.14-13.906-64.32-20.62-97.274-20.453zm212.93 22.055l-151.217 22.61l22.614 151.22l41.126-55.588c42.204 54.29 45.092 132.048 2.187 190.043c-40.22 54.367-108.82 75.32-170.19 57.566l6.522 43.598a192.7 192.7 0 0 0 86.203-3.07c37.448-5.957 73.34-22.05 103.16-47.728c-49.196 54.65-122.615 77.514-191.744 64.34l-55.8 8.344c99.03 43.7 218.402 14.77 285.51-75.938c69.13-93.445 59.34-220.743-17.483-302.53l39.114-52.866z"/></symbol><symbol id="gi-download" viewBox="0 0 512 512"><path fill="currentColor" d="M200.1 31.2A130.1 132.4 0 0 0 70.03 163.6a130.1 132.4 0 0 0 .55 11.3a80.98 73.47 0 0 0-52.21 68.6A80.98 73.47 0 0 0 99.35 317a80.98 73.47 0 0 0 37.25-8.3a189.3 80.97 0 0 0 78.4 16.5v-49.9h82v50.1a189.3 80.97 0 0 0 39.5-5.7a91.09 67.8 0 0 0 66 21.1a91.09 67.8 0 0 0 91.1-67.8a91.09 67.8 0 0 0-58-63.1a70.1 81.72 20.61 0 0 2.6-6.2a70.1 81.72 20.61 0 0-36.8-101.2a70.1 81.72 20.61 0 0-76.9 22.8a130.1 132.4 0 0 0-124.4-94.1M233 293.3v112h-51.3l74.3 74.3l74.3-74.3H279v-112z"/></symbol><symbol id="gi-pencil" viewBox="0 0 512 512"><path fill="currentColor" d="M429.548 30.836q-.462-.005-.875.024c-2.212.147-3.34.653-4.576 1.89l-27.58 27.58l55.156 55.154l27.578-27.58c1.238-1.236 1.744-2.363 1.89-4.575c.15-2.21-.37-5.433-1.805-9.163c-2.87-7.46-9.277-16.667-17.055-24.445s-16.985-14.185-24.445-17.055c-3.264-1.255-6.138-1.81-8.287-1.83zm-45.758 42.22l-9.9 9.9l9.9 9.9l12.727 12.727l9.9 9.9l12.727 12.728l9.9 9.9l9.9-9.9l-55.155-55.155zm-22.627 22.626L72.665 384.186l9.898 9.897l288.5-288.5zm22.627 22.63L95.29 406.808l9.9 9.902l288.5-288.5l-9.9-9.9zm22.63 22.626l-288.502 288.5l9.897 9.9l288.503-288.5l-9.9-9.9zM63.223 400.198l-12.12 30.306l30.393 30.394l30.305-12.12l-6.61-6.612L92.46 429.44l-9.9-9.9l-12.73-12.728l-6.61-6.612zm-19.395 48.488l-12.993 32.478l32.478-12.992z"/></symbol><symbol id="gi-position" viewBox="0 0 512 512"><path fill="currentColor" d="M256 17.108c-75.73 0-137.122 61.392-137.122 137.122c.055 23.25 6.022 46.107 11.58 56.262L256 494.892l119.982-274.244h-.063a137.1 137.1 0 0 0 17.202-66.418C393.122 78.5 331.73 17.108 256 17.108m0 68.56a68.56 68.56 0 0 1 68.56 68.562A68.56 68.56 0 0 1 256 222.79a68.56 68.56 0 0 1-68.56-68.56A68.56 68.56 0 0 1 256 85.67z"/></symbol><symbol id="gi-gps" viewBox="0 0 512 512"><path fill="currentColor" d="M25 57v398h462V57zm30 30h306v338H55zm78.94 18l10.042 27.62L73 122.48v18.182l77.957 11.14l36.223 99.612l24.806-37.12L172.244 105zM278 105v18h64v-18zm0 32v18h64v-18zm106 39h82v48h-82zm18 18v12h46v-12zm-59 28.666l-115.803 22.107l7.26 16.936L343 240.99zM384 240h82v48h-82zm-174.178 9.928l-57.314 85.77l48.18-11.243l49.18 18.893zM402 258v12h46v-12zm23.646 53c22.538 0 41 18.463 41 41s-18.462 41-41 41c-22.537 0-41-18.463-41-41s18.463-41 41-41m0 18c-12.81 0-23 10.19-23 23s10.19 23 23 23s23-10.19 23-23s-10.19-23-23-23m-226.25 14.24l-17.777 4.15L176.2 407h36.146l5.164-56.8l-18.115-6.96z"/></symbol><symbol id="gi-castle" viewBox="0 0 512 512"><path fill="currentColor" d="M254.25 15.344c-132.537 0-240.188 107.62-240.188 240.156c0 132.537 107.65 240.188 240.188 240.188S494.406 388.038 494.406 255.5S386.786 15.344 254.25 15.344m0 18.687c122.436 0 221.47 99.034 221.47 221.47c0 65.65-28.465 124.583-73.75 165.125V238.75l14-22.78h-7.595L364 101.5l-43.813 114.47h-8.156l14.595 22.78v33.875h-36.813v-88.188l14.625-22.78h-7.593l-44.406-114.47l-44.375 114.47h-7.594l14.03 22.78v123.22h-37.375v-18.094l14.594-22.782h-8.19l-43.78-114.467L95.344 266.78H87.75l14.03 22.783V416.25C59.25 375.9 32.75 318.83 32.75 255.5c0-122.436 99.064-221.47 221.5-221.47zm1.094 160.532h18.687v36.344h-18.686v-36.344zm110.156 87.97h18.688v36.312H365.5V282.53zm-246.656 22.03h18.687v36.344h-18.686v-36.344zm50.875 29.407h18.686v36.342H169.72V333.97zm170.81 30.5h18.69v36.342h-18.69z"/></symbol><symbol id="gi-globe" viewBox="0 0 512 512"><path fill="currentColor" d="m322.02 20.184l-17.13 42.273a157.4 157.4 0 0 1 20.372 9.758l8.62-13.274l15.675 10.18l-8.637 13.296q1.274.943 2.53 1.91l-19.745 22.735a129.5 129.5 0 0 1 14.1 12.268l19.754-22.746c.187.184.38.366.567.55l11.795-10.618l12.504 13.89l-11.79 10.614a158 158 0 0 1 12.846 18.648l14.114-7.19l8.482 16.653l-14.092 7.177a157 157 0 0 1 7.578 21.353l15.452-3.283l3.884 18.28l-15.445 3.282a159.5 159.5 0 0 1 1.77 22.59l15.777.827l-.98 18.664l-15.74-.825a157.5 157.5 0 0 1-4.09 22.303l14.947 4.857l-5.777 17.774l-14.922-4.85a157 157 0 0 1-9.75 20.486l13.142 8.537l-10.18 15.674l-13.13-8.528a159 159 0 0 1-14.725 17.273l10.46 11.617l-13.89 12.506l-10.437-11.594a158.6 158.6 0 0 1-18.69 12.924l7.05 13.838l-16.65 8.484l-7.033-13.803a157 157 0 0 1-21.427 7.62l3.213 15.123l-18.28 3.884l-3.21-15.107a159.5 159.5 0 0 1-22.682 1.797l-.808 15.41l-18.662-.98l.807-15.368a157.6 157.6 0 0 1-22.405-4.092l-4.738 14.58l-17.773-5.777l4.73-14.55a157.4 157.4 0 0 1-20.575-9.803l-8.328 12.822l-15.672-10.18l8.33-12.824c-.93-.685-1.848-1.384-2.762-2.088l19.848-22.853a129.5 129.5 0 0 1-14.108-12.257l-19.848 22.853l-.47-.455l-11.356 10.226l-12.504-13.89l11.347-10.216a159 159 0 0 1-12.49-18.03l-38.9 23.71a204.7 204.7 0 0 0 34.472 43.088l-20.575 23.69l14.112 12.255l20.575-23.693c34.76 27.522 78.7 43.96 126.482 43.96c1.365 0 2.726-.023 4.084-.05v19.473c-34.134 15.356-59.115 36.682-79.753 59.906h197.54c-19.674-24.32-44.835-43.993-80.784-59.712V409.41c92.908-19.004 162.8-101.184 162.8-199.68c0-58.094-24.315-110.51-63.323-147.636l20.58-23.698l-14.11-12.253l-20.584 23.7a203.8 203.8 0 0 0-51.376-29.66zm-75.108 82.664c-59.132 0-106.838 47.692-106.838 106.8c0 59.11 47.706 106.8 106.838 106.8s106.838-47.69 106.838-106.8c0-9.375-1.203-18.462-3.46-27.12c-10.244 25.087-23.08 45.15-45.905 66.95c-16.887-1.487-29.712-8.08-40.643-19.966c6.048-8.86 13.09-17.22 27.096-22.102c-12.564-28.283-18.19-56.568-21.393-84.85c12.464 4.59 20.16 11.93 29.235 24.954c7.712 1.697 16.863-6.856 23.27-13.975c-19.274-18.99-45.752-30.692-75.038-30.692zm-32.48 65.03c17.62 12.56 32.407 31.486 38.03 52.517c-11.065 9.256-16.907 21.124-19.92 34.406c16.957 8.23 30.048 21.297 41.65 36.22v.007c-34.438-3.405-68.245-9.135-98.696-27.164c-12.172-20.824-19.107-41.65-19.92-62.474c16.6-7.525 33.2-4.936 49.8.895c9.222-11.47 9.186-20.922 9.056-34.408z"/></symbol><symbol id="gi-film" viewBox="0 0 512 512"><path fill="currentColor" d="M208 25C106.8 25 25 106.8 25 208s81.8 183 183 183s183-81.8 183-183S309.2 25 208 25m121.3 22.81c21 15.91 38.7 35.83 52.1 58.59c49.1 41.7 93.4 100.9 92 179.2c-.6 34.4-7.3 75.5-19.4 110.3c-12.1 34.7-30.1 62.3-49.7 71.7c-3.9 1.8-9.5 2.2-17.5.6c-8-1.7-17.9-5.3-29.3-10c-22.7-9.5-51.2-23.4-84.2-33.5c-66.2-20.1-152-24.2-247.5 50.6l11.1 14.2c91.2-71.5 168.3-66.7 231.1-47.6c31.4 9.6 59 23 82.6 32.9c11.7 4.9 22.5 8.9 32.6 11s20 2.3 28.9-2c27.5-13.1 46-45.1 58.9-82s19.8-79.5 20.4-115.8c1.6-92.8-55.5-160.5-111.9-204.3c-17.2-13.42-34.5-24.69-50.2-33.89M208 52q24 0 48 12c0 32-32 80-48 80s-48-48-48-80q24-12 48-12M107.4 94.4c27.7 16 53.3 67.7 45.4 81.6c-8.1 13.8-65.6 17.5-93.33 1.5c2.14-35.7 18.13-63.4 47.93-83.1m201.2 0q44.85 29.55 48 83.1c-27.7 16-85.3 12.3-93.3-1.5c-8-13.9 17.6-65.6 45.3-81.6M208 167c22.5 0 41 18.5 41 41s-18.5 41-41 41s-41-18.5-41-41s18.5-41 41-41m0 18c-12.8 0-23 10.2-23 23s10.2 23 23 23s23-10.2 23-23s-10.2-23-23-23m-98.1 42.8c20.3.2 38.6 4.7 42.9 12c7.9 13.9-17.7 65.6-45.4 81.6c-29.83-19.7-45.83-47.4-47.97-83.1c12.97-7.5 32.52-10.7 50.47-10.5m196.2 0c18-.2 37.5 3 50.5 10.5q-3.15 53.55-48 83.1c-27.7-16-53.3-67.7-45.3-81.6c4.3-7.3 22.5-11.8 42.8-12M208 271.7c16 .1 48 48.1 48 80.1q-48 24-96 0c0-32 32-80 48-80.1"/></symbol><symbol id="gi-forest" viewBox="0 0 512 512"><path fill="currentColor" d="M169.92 15.654c-10.512 16.697-22.392 34.058-41.688 50.473c.46.447.934.89 1.405 1.336l23.55-.406l-3.527 10.7a71 71 0 0 1-1.678 4.552a184 184 0 0 0 8.872 5.788l25.052 15.33l-45.99-.793c-1.954 2.474-4.06 4.9-6.305 7.275c8.8 11.517 22.045 22.713 38.675 32.766l-12.404-.365l-.015 5.678c8.53 6.358 17.792 12.448 27.162 18.48c18.11-11.568 31.79-24.5 39.51-37.616l-57.353.99l25.052-15.33c8.97-5.488 16.525-11.115 23.082-16.867c-5.098-5.475-9-10.962-11.257-16.69l-4.04-10.246l29.742-1.823C205.14 54.41 182.44 38.31 169.92 15.654m234.605 13.223c-6.537 12.962-13.88 29.016-23.638 44.63c10.927 18.367 24.428 35.997 46.773 52.382l20.42 14.97l-52.51-1.085c6.956 12.136 19.936 26.858 34.58 40.047c19.157 17.254 40.928 32.572 53.907 39.914l11.34 6.418c.005-4.163.005-7.382.015-13.972c-17.176-11.938-29.767-25.795-36.322-42.2l-4.115-10.298l43.605-2.58c-9.91-4.358-19.747-8.263-28.723-12.06c-8.498-3.594-16.23-7.107-22.724-11.23c-6.495-4.122-12.102-8.954-14.858-15.943l-3.93-9.962l36.077-3.178c-24.245-20.226-47.223-48.756-59.897-75.853M84.027 34.62c-12.52 22.656-35.22 38.757-57.843 53.234l29.738 1.822l-4.037 10.244c-4.78 12.125-16.815 24.123-33.182 36.873c-.295 11.21-.392 17.66-.385 17.732c.005.044.19 1.1.29 3.127c.038.742.04 3.177.066 4.358l13.828.797l-.004.55l-3.906 9.778c-1.18 2.955-2.498 6.07-4.16 9.232l11.525-7.05c37.376-22.87 50.42-48.163 67.395-74.833l-.233 6.366l4.474-1.875a72 72 0 0 1-3.305-8.25l-3.528-10.7l26.582.46C107.068 69.66 94.836 51.787 84.027 34.618zM263.06 61.036c-10.808 17.168-23.04 35.04-43.314 51.86l26.582-.458l-3.527 10.7c-6.657 20.188-22.772 37.89-44.874 52.91l17.916 11.573l18.273.688l-4.127 10.5c-2.305 5.863-5.355 11.4-9.01 16.65l8.737-6.19c17.913-12.69 36.773-27.495 51.8-41.534c10.026-9.365 17.978-18.52 23.083-25.914l-50.537-3.715l16.5-12.773c10.37-8.027 20.973-15.94 30.924-24.178c-15.49-11.246-29.53-24.024-38.423-40.12zm93.07.68c-12.76 26.805-36.227 46.04-59.107 63.506l31.39 2.306l-3.194 9.726c-4.503 13.7-16.983 27.412-32.783 42.172c-10.794 10.083-23.245 20.29-35.896 29.926l49.278 3.494l-3.26 9.78c-11.273 33.806-46.845 56.924-75.816 75.597c13.487 8.056 27.67 15.44 39.813 22.318c68.34 18.82 147.594 6.972 200.924-16c-13.19-7.554-23.844-14.547-32.748-23.006c-11.805-11.216-20.072-24.96-26.377-44.183l-3.017-9.208l51.97-6.61c-11.874-8.21-25.17-18.39-37.867-29.825c-20.11-18.113-38.394-38.003-43.915-58.067L372.68 123.3l27.867.577c-21.174-20.07-33.542-41.485-44.416-62.16zM108.78 121.84c-10.807 17.168-23.038 35.04-43.313 51.86l26.582-.456l-3.53 10.697c-8.24 24.998-30.974 46.186-61.625 63.185l52.94 1.99l-4.126 10.496c-11.05 28.11-29.76 52.974-61.038 71.585c73.885 22.566 156.574 31.568 231.582-3.572c-12.908-7.055-26.9-14.653-40.33-23.56c-18.07-11.983-34.525-25.736-41.71-43.713l-4.116-10.3l48.4-2.864c-11.828-7.625-23.415-15.48-33.557-23.383c-16.245-12.663-29.06-24.086-34.02-36.664l-4.04-10.247l29.74-1.823c-22.62-14.476-45.318-30.576-57.84-53.23zm-40.52 67.816l-49.016.844c-.168.218-.32.44-.492.656c-.276 21.495-.448 33.384-.502 42.38c23.342-13.057 40.863-28.338 50.01-43.88m105.674 12.403a190 190 0 0 0 10.845 9.124a360 360 0 0 0 11.14 8.308c5.44-5.177 10.084-10.545 13.692-16.092zm41.658 20.428a113 113 0 0 1-5.947 6.504a619 619 0 0 0 28.38 17.815l17.645 10.427c11.34-9.42 21.135-19.423 27.215-29.972zm279.803 10.334l-68.383 8.698c4.666 11.45 10.047 19.75 17.28 26.992l-.21-9.6c17.455-.996 34.705-3.352 51.4-7.363c-.065-7.174-.084-12.42-.087-18.728zm-243.96 27.852l-66.394 3.928c3.903 5.187 9.085 10.326 15.097 15.314a496 496 0 0 0 19.455 3.504c10.638-7.12 21.72-14.713 31.842-22.746m-219.744 2.64l-.29 35.618c10.746-10.37 18.692-21.97 24.83-34.694zm413.947 66.534c-14.262 4.577-29.517 8.396-45.38 11.222l5.55 132.18h42.978zm-181.83 7.002a257 257 0 0 1-8.018 4.046l-.927 114.274h52.682l-2.426-110.496c-13.946-1.552-27.788-4.123-41.313-7.824zm120.54 6.693c-15.085 1.992-30.582 3.05-46.18 2.994l-.386 139.742h52.56zm-353.38 9.146l-.924 114.04H74.31l1.975-104.398c-15.362-2.464-30.51-5.75-45.318-9.643zm174.72 6.27c-11.576 2.87-23.226 4.935-34.898 6.284l3.915 66.195h33.065l-2.084-72.48zm-113.443 5.628l-2.508 132.56l72.692-2.287l-7.584-128.21c-20.97 1.29-41.936.424-62.6-2.062z"/></symbol><symbol id="gi-shopping" viewBox="0 0 512 512"><path fill="currentColor" d="M256 23c-35 0-62 17.92-79.3 41.71c-11.9 16.38-19.6 35.49-23.2 54.29H172c3.4-15.2 9.9-30.77 19.3-43.71C206 55.08 227 41 256 41s50 14.08 64.7 34.29c9.4 12.94 15.9 28.51 19.3 43.71h18.5c-3.6-18.8-11.3-37.91-23.2-54.29C318 40.92 291 23 256 23M88.25 137L57.81 487H454.2l-30.4-350zM160 160a16 16 0 0 1 16 16a16 16 0 0 1-16 16a16 16 0 0 1-16-16a16 16 0 0 1 16-16m192 0a16 16 0 0 1 16 16a16 16 0 0 1-16 16a16 16 0 0 1-16-16a16 16 0 0 1 16-16"/></symbol><symbol id="gi-factory" viewBox="0 0 512 512"><path fill="currentColor" d="m384 64l.387 256H368l-96-128l-16 128l-96-128l-16 128l-96-128l-16 128v160h448V64h-32v256h-32V64zM64 352h48v32H64zm80 0h48v32h-48zm80 0h48v32h-48zm80 0h48v32h-48zM64 416h48v32H64zm80 0h48v32h-48zm80 0h48v32h-48zm80 0h48v32h-48z"/></symbol><symbol id="gi-seed" viewBox="0 0 512 512"><path fill="currentColor" d="M57.64 30.11c8.97 28.45 9.68 57.4 11.44 84.59c2.25 34.6 6.13 66 28.32 93.2c1.8 2.3 8.9 6.6 19.3 9.4s23.9 4.5 38.5 4.5c25.2-.2 53.7-5.8 76.9-18.8C159 175.6 109.3 130.3 97 71.94C134.8 119.8 168 157 239.5 178.3c1.5-12.6 4.3-24.4 6.2-35.1c3.2-18 3.6-31-4.6-40.6c-14.2-16.4-50-29.59-89.9-41.47c-31.5-9.35-65.2-18.39-93.56-31.02M369.4 136.3c-34.3 15.2-74.2 36.9-112 44.6c-.7 6.3-1.1 12.5-.8 18.5c43.5-7.4 85.9-31.5 120.1-46.6c14.9-5.3 43-19.5 57.1-10c14.1 11.7 9.6 34.9 6.4 48.5c-3.8 16.4-10.3 35.2-17.3 53c4.6 6.1 8.5 14.2 11 20.6c9.2-22.6 18.6-47.6 23.8-69.5c4.8-25.4 8.3-51.6-13.6-67.4c-23.6-14.2-54.7-.4-74.7 8.3m-19 120.9c-34.7 26.8-58.4 55-89.6 73c-31.1 18-69.6 24-128.7 6.4c-35.3-10.5-56.57-6.9-67.89 1.5c-11.31 8.4-15.37 23.2-12.73 41.8c5.29 37.3 39.82 83.9 82.22 92c68.2 12.9 125.8 13.7 171.9.2s80.9-40.7 105.4-85.9c15.4-28.5 18.4-79 7.3-111.5c-11.5-34.9-40-38.2-67.9-17.5"/></symbol><symbol id="gi-folder" viewBox="0 0 512 512"><path fill="currentColor" d="M402.488 45.148c-29.932 24.474-69.636 42.525-111.175 57.262c43.727-8.11 84.957-18.594 121.822-32.43c-2.7-8.61-6.116-16.916-10.647-24.83zM41 73v135.764c11.918-41.227 23.38-84.748 34.512-126.996L77.82 73zm41.518 0l5.884 3.096c27.917 14.682 57.075 28.881 87.127 42.482q4.438-.46 8.866-.937L169.514 73zm366.652 1.447c-88.208 40.349-203.608 56.834-319.715 66.387L131.451 151h327.77c-4.066-30.318-7.69-57.252-10.051-76.553M90.059 97.143C71.419 167.87 51.807 241.39 30.283 302.035l24.479 10.158L88.697 151h24.412l-3.246-16.525l-1.947-9.91l10.07-.795q11.092-.876 22.164-1.84a1639 1639 0 0 1-50.09-24.787zM103.303 169L43.092 455h381.605l60.211-286zM477.27 292.654l-19.336 91.852c9.008-.398 17.683-.77 27.27-1.213c-.725-21.697-3.794-54.23-7.934-90.639M41 325.97v51.594l9.99-47.45z"/></symbol><symbol id="gi-stopwatch" viewBox="0 0 512 512"><path fill="currentColor" d="M256 91.088c-111.453 0-202.122 88.575-202.122 197.456C53.878 397.426 144.548 486 256 486s202.122-88.574 202.122-197.456S367.452 91.089 256 91.089zm0 375.866c-100.942 0-183.076-80.039-183.076-178.41S155.058 110.134 256 110.134s183.076 80.04 183.076 178.41c0 98.371-82.134 178.41-183.076 178.41m-.571-178.41l128.558 99.55A165.6 165.6 0 0 1 256 447.908c-90.467 0-164.03-71.493-164.03-159.365c0-87.681 73.266-159.055 163.447-159.352v159.352zM256 72.043a224.7 224.7 0 0 0-66.66 10.07V56.354A30.354 30.354 0 0 1 219.694 26h72.695a30.354 30.354 0 0 1 30.27 30.354v25.76A224.7 224.7 0 0 0 256 72.042zm128.939 40.71l20.117-19.748c8.797-8.642 21.272-10.463 27.878-4.083l20.403 19.748c6.594 6.38 4.761 18.558-3.988 27.2l-19.438 19.045a221 221 0 0 0-44.96-42.162zM82.435 154.51l-19.391-19.046c-8.797-8.642-10.713-20.724-4.202-26.985L78.875 89.1c6.476-6.26 18.856-4.344 27.652 4.298l20.046 19.688a221 221 0 0 0-44.138 41.425z"/></symbol><symbol id="gi-clock" viewBox="0 0 512 512"><path fill="currentColor" d="M247 62.107v9.94h-28.88v18H247v39.074c-84.367 4.682-151.514 74.737-151.514 160.26c0 41.037 15.468 78.508 40.873 106.91l-36.237 36.237l12.727 12.73l36.238-36.24C177.49 434.425 214.96 449.894 256 449.894s78.51-15.47 106.912-40.875l36.238 36.238l12.727-12.73l-36.236-36.235c25.406-28.402 40.874-65.873 40.874-106.91c0-85.523-67.147-155.578-151.514-160.26V90.048h28.88v-18H265v-9.94h-18zm-104.906 26.58a112 112 0 0 0-11.014.465C111.1 90.97 89.15 98 73.637 113.514c-15.514 15.513-22.545 37.462-24.362 57.443c-.908 9.99-.488 19.54 1.184 27.898c1.67 8.36 4.315 15.662 9.784 21.13l6.363 6.365l6.366-6.364l113.5-113.502l-6.366-6.363c-5.47-5.468-12.77-8.112-21.128-9.784c-4.18-.836-8.658-1.358-13.34-1.56q-1.755-.076-3.546-.088zm227.812 0q-1.79.015-3.547.09c-4.682.2-9.16.723-13.34 1.56c-8.357 1.67-15.658 4.315-21.127 9.784l-6.366 6.364l6.366 6.366l113.5 113.5l6.363-6.364c5.47-5.47 8.113-12.772 9.785-21.13c1.673-8.36 2.093-17.908 1.185-27.9c-1.817-19.98-8.848-41.93-24.362-57.442C422.85 98 400.9 90.97 380.92 89.152c-3.747-.34-7.43-.494-11.014-.465M256 146.865c78.815 0 142.514 63.7 142.514 142.514S334.814 431.892 256 431.892s-142.514-63.7-142.514-142.514c0-78.816 63.7-142.515 142.514-142.515zm-88.332 41.453l-12.73 12.73l82.92 82.917a19 19 0 0 0-.797 5.414A18.94 18.94 0 0 0 256 308.317a18.94 18.94 0 0 0 16.66-9.94h59.096v-18h-59.11A18.94 18.94 0 0 0 256 270.44a19 19 0 0 0-5.408.804l-82.924-82.924z"/></symbol><symbol id="gi-bullseye" viewBox="0 0 512 512"><path fill="currentColor" d="M311.133 257.95a15.074 15.074 0 0 1-12.845 17.026l-147.248 20.61l-21.33 32.522l-82.637 11.57l21.33-32.568l-24.547 3.44c-.278 0-.555.12-.843.165a15 15 0 0 1-2.108.144a15.074 15.074 0 0 1-2.074-30.016c.29 0 .567 0 .855-.078l24.547-3.438l-29.45-25.512l82.582-11.547l29.45 25.51l147.26-20.608a15 15 0 0 1 2.107-.145a15.085 15.085 0 0 1 14.953 12.923zm-36.704-38.546a32.4 32.4 0 0 1 10.847-10.326a23.43 23.43 0 0 1 13.422-3.04a27.9 27.9 0 0 1 13.542 5.047a44.6 44.6 0 0 1 11.924 12.59a66.3 66.3 0 0 1 8.386 19.134a77.5 77.5 0 0 1 2.562 21.995a67.9 67.9 0 0 1-3.494 19.966a46.1 46.1 0 0 1-8.54 15.352a28.16 28.16 0 0 1-12.402 8.552a23.4 23.4 0 0 1-13.765.255a32 32 0 0 1-12.512-7.122a45.5 45.5 0 0 1-5.734-6.2l-32.278 4.514a131 131 0 0 0 7.1 15.973a104.6 104.6 0 0 0 18.656 25.512a69 69 0 0 0 23.893 15.806a48.37 48.37 0 0 0 27.597 2.22a53.43 53.43 0 0 0 26.31-14.876c7.898-7.853 14.42-18.258 19.112-30.514a132 132 0 0 0 8.32-41.995a153.3 153.3 0 0 0-5.48-46.92a128.8 128.8 0 0 0-18.49-39.932c-7.6-10.726-16.417-18.946-25.78-24.403a53 53 0 0 0-27.962-7.62a48.57 48.57 0 0 0-26.278 8.718a69.9 69.9 0 0 0-20.165 21.897a107.5 107.5 0 0 0-11.99 29.516A137 137 0 0 0 237.68 235l31.192-4.37a49.2 49.2 0 0 1 5.557-11.226m198.305-34.984c-10.926-35.274-27.287-64.757-46.842-87.374c-18.557-21.518-39.544-36.26-61.118-44.213c-20.155-7.41-40.564-8.74-59.953-4.248c-18.058 4.204-35.196 13.466-50.603 27.62c-14.42 13.21-26.09 29.626-35.185 47.807a233.2 233.2 0 0 0-19.29 56.57a286 286 0 0 0-5.856 60.674l22.582-3.16a209 209 0 0 1 5.047-40.344a166.3 166.3 0 0 1 15.972-42.926c7.365-13.4 16.716-25.124 27.997-34.087c11.89-9.44 24.88-14.986 38.3-16.64c14.165-1.774 28.773.744 42.938 7.51c14.863 7.1 29.084 18.78 41.485 34.774c12.856 16.572 23.515 37.46 30.66 61.917a225.5 225.5 0 0 1 8.74 74.65c-1.254 24.05-6.4 46.422-14.72 65.656c-8.042 18.58-18.857 33.887-31.824 44.88c-12.313 10.47-26.345 16.915-41.463 18.656a76.23 76.23 0 0 1-41.163-7.1c-12.313-5.722-23.826-14.485-34.03-25.51a162.2 162.2 0 0 1-25.724-37.637a204.6 204.6 0 0 1-14.542-38.578l-22.484 3.106a281 281 0 0 0 19.966 57.823a228.2 228.2 0 0 0 32.168 50.092c12.99 15.186 27.82 27.83 43.914 36.793c17.18 9.574 36.027 15.064 55.705 14.865c21.263-.21 41.44-7.022 59.52-19.778c19.356-13.654 36.005-33.897 48.617-59.432c13.244-26.82 21.697-58.788 24.048-93.64a300.7 300.7 0 0 0-12.856-108.76z"/></symbol><symbol id="gi-circle" viewBox="0 0 512 512"><path fill="currentColor" d="M256 23.05C127.5 23.05 23.05 127.5 23.05 256S127.5 488.9 256 488.9S488.9 384.5 488.9 256S384.5 23.05 256 23.05m0 17.9c118.9 0 215.1 96.15 215.1 215.05S374.9 471.1 256 471.1S40.95 374.9 40.95 256S137.1 40.95 256 40.95"/></symbol><symbol id="gi-hourglass" viewBox="0 0 512 512"><path fill="currentColor" d="M92.656 19.188v41.5h331.72v-41.5zM119.5 79.374V433.53h22.28V79.376H119.5zm46.594 0c3.212 43.324 13.312 82.022 27.78 110.906c17.685 35.304 40.845 54.75 64.064 54.75s46.346-19.446 64.03-54.75c14.47-28.883 24.57-67.58 27.782-110.905H166.094zm209.156 0V433.53h22.28V79.376h-22.28zm-117.313 185.22c-23.218 0-46.378 19.415-64.062 54.717c-14.835 29.614-25.098 69.562-28.03 114.22H350c-2.933-44.658-13.197-84.606-28.03-114.22c-17.686-35.302-40.814-54.718-64.033-54.718zM92.657 452.218v41.467h331.718V452.22H92.655z"/></symbol><symbol id="gi-recycle" viewBox="0 0 512 512"><path fill="currentColor" d="M255.875 19.75c-15.54.336-30.444 4.193-44.97 10.5l22.47 42c7.42-2.652 15.023-4.442 22.5-4.5c25.633 0 52.754 13.658 73.47 49.47l16.624 28.874l20.217-11.656l43.407 75.187l-53.5 30.844l107.812 62.186V178.312l-33.062 19.063l-59.5-104.156c-27.476-47.506-71.07-73.848-115.47-73.47zm-75.22 57.844L72.813 139.78l32.282 18.595l-56.156 98.313c-25.153 43.508-26.934 92.827-6 131.968c8.675 16.22 21.838 29.508 37.5 40.47l24-40.47c-8.042-6.36-14.9-13.912-19.5-22.5c-12.51-23.402-13.322-54.64 4.5-85.47l13.624-23.436l-19.687-11.375l43.313-75.03l53.968 31.093V77.594zm255.157 267.562c-1.564 7.687-3.835 14.362-7.5 21c-12.83 23.206-38 40.5-77.968 40.5h-24.438v22.906h-86.78V367.5L131.31 429.656l107.813 62.188v-37.22h111.22c54.52 0 98.506-25.648 119.967-64.468c7.566-13.695 11.738-29.242 13.5-45h-48z"/></symbol><symbol id="gi-warning" viewBox="0 0 512 512"><path fill="currentColor" d="M256.594 22.78c-30.506.17-59.178 22.692-80.125 62.97c-.06.11-.13.2-.19.313L45.563 314.813l-.218.375C21.012 353.466 15.88 389.552 30.97 416.063c15.08 26.496 48.627 40.125 93.5 37.906h265.124c44.872 2.218 78.418-11.41 93.5-37.908c15.092-26.516 9.97-62.618-14.375-100.906l-.22-.344L336.375 85.345c-.075-.147-.175-.26-.25-.406c-20.513-40.036-49.052-62.324-79.53-62.157zm-.28 18.845c13.573-.132 26.512 6.475 38.155 17.78c11.558 11.227 22.392 27.263 33.092 48.19L444.25 310.218l.22.342c12.067 18.934 20.57 36.022 24.905 51.563c4.362 15.64 4.54 30.2-1.5 42.438s-17.97 20.85-33.75 25.687c-15.675 4.806-35.56 6.383-60.406 5.125H139.905c-22.9 1.157-41.964-.16-57.406-4.5c-15.56-4.373-27.874-12.147-34.78-23.906c-6.91-11.76-7.756-26.4-3.845-42.408c3.886-15.904 12.366-33.698 25.28-53.968l115.47-201.938c.063-.12.125-.222.188-.344c10.85-20.89 21.77-36.997 33.375-48.343c11.673-11.413 24.55-18.213 38.125-18.345zm.186 18.688c-7.503.072-15.765 3.758-25.25 13.03c-9.485 9.274-19.69 23.944-30.03 43.907l-.095.188l-.094.156l-70.718 123.687l61.625.19l-31.25-18.032l9.344-16.188l51.064 29.5a48 48 0 0 1 4.125-4.03l-29.532-51.126l16.187-9.344l29.563 51.188c1.785-.586 3.63-1.04 5.5-1.407v-59.186h18.687v59.375c1.824.393 3.603.896 5.344 1.5l29.717-51.47l16.157 9.344l-29.813 51.625a47 47 0 0 1 3.814 3.843l51.687-29.813l9.345 16.188l-51.688 29.843a46 46 0 0 1 1.407 5.22h59.656v18.688h-59.656a46 46 0 0 1-1.406 5.25l51.687 29.812l-9.344 16.156l-51.624-29.78a48 48 0 0 1-3.844 3.874l29.782 51.594l-16.156 9.344L271 312.03a46 46 0 0 1-5.375 1.5v59.283h-18.688v-59.094a46 46 0 0 1-5.53-1.47l-29.532 51.188l-16.188-9.344l29.563-51.188a47 47 0 0 1-4.03-4.062l-51.19 29.562l-9.343-16.156L191 294.75H99.75l-14.375 25.125l-.125.22l-.125.186C72.762 339.63 65.197 356.044 62.03 369c-3.164 12.957-2.05 21.92 1.814 28.5c3.865 6.58 11.153 11.875 23.718 15.406c12.567 3.532 30.025 4.95 51.876 3.813l.25-.033h234.28l.25.032c23.734 1.233 41.893-.468 54.436-4.314c12.544-3.845 19.08-9.322 22.438-16.125c3.358-6.802 3.88-16.225.28-29.124c-3.597-12.898-11.31-28.78-22.874-46.875l-.094-.186l-.125-.188l-116.936-202.97l-.094-.217l-.125-.22c-10.195-20.01-20.283-34.584-29.656-43.688c-9.375-9.103-17.467-12.572-24.97-12.5zm-.656 179.53c-10.807 0-20.39 6.403-25 15.376l-2.594 5.093l-5.75-.032l-102.438-.342l-9.25 16.125H228.72l2.655 4.937c4.736 8.86 13.8 14.906 24.47 14.906c15.47 0 28.03-12.56 28.03-28.03c0-15.473-12.56-28.032-28.03-28.032z"/></symbol><symbol id="gi-scroll" viewBox="0 0 512 512"><path fill="currentColor" d="M373.563 18.406c-15.616-.167-27.91 4.622-32.563 14.75c-22.778 49.605-48.743 87.14-79.094 117.28a63 63 0 0 1 8.938 3.783c12.987 6.708 25.268 17.78 35.312 30.843s17.85 28.114 20.78 43.5c.746 3.908 1.16 7.885 1.158 11.843c38.97-24.36 85.058-41.223 140.875-51.312c14.91-2.697 23.652-28.632 21.405-58.656l-35.156-1l30.56-24.813c-4.148-14.507-11.013-28.754-21.155-40.72c-15.528-18.314-36.43-31.376-56.72-38.686L381.94 40.812l2.812-21.5c-3.875-.55-7.61-.87-11.188-.907zM246.938 166.562c-1.063.052-2.06.226-3 .47c-11.976 10.254-24.61 19.597-37.938 28.28c.842.33 1.67.667 2.5 1.032c14.123 6.192 27.438 17.145 38.47 30.625c13.356 16.322 23.62 36.94 25.624 57.75c10.334-10.367 21.24-19.943 32.844-28.72c4.096-6.555 4.93-14.468 3.125-23.938c-2.184-11.46-8.642-24.43-17.25-35.625c-8.61-11.194-19.38-20.622-29.063-25.625c-6.052-3.126-11.154-4.45-15.313-4.25zm-61.907 43.282c-1.385.053-2.69.27-3.968.562c-37 20.762-79.088 37.985-127.312 56c.574.042 1.14.093 1.72.156c10.627 1.156 21.076 5.008 31.155 10.875L124.313 261L108.5 293.72c5.995 5.432 11.803 11.477 17.344 18c20.76 24.434 37.964 55.865 47.094 88.092c.002.01-.003.022 0 .032c2.98 10.508 5.11 20.916 6.312 31c20.99-48.438 44.38-89.26 72.344-123c7.3-21.48-2.186-48.408-19.063-69.03c-9.44-11.538-20.976-20.718-31.53-25.345c-5.936-2.604-11.27-3.808-15.97-3.626zm141.626 54.844c-7.31 5.05-14.462 10.51-21.437 16.312c39.16 9.26 60.953 35.722 80.655 62.156c10.464 14.04 20.598 28.11 33.125 40.688c24.19 9.147 43.17 6.38 63.906-14.938c-92.165-27.78-96.11-92.61-156.25-104.22zM48.594 284.906c-10.873.225-18.26 5.755-23.344 16.594c-5.81 12.387-7.114 32.47.438 57.063c5.75 18.73 16.52 37.718 28.75 51.625s25.9 22.076 35.374 22.406h.032c3.717.13 6.553-.682 8.812-2.75l-.187-.188l2.093-2.094c.793-1.168 1.52-2.548 2.187-4.187c2.81-6.9 3.28-18.552-1.844-33c-6.885-19.417-19.12-31.932-33.375-34.78l-22.968-4.564l19.813-12.5l38.47-24.186c-16.65-16.822-34.55-27.607-49.376-29.22c-1.7-.184-3.323-.25-4.876-.218zm236.25 5.406l-24.53 25.375c100.442 17.878 55.45 141.005 159.31 176.188l-24.78-57.28c32.766 16.15 67.39 22.623 97.72 12.03c-135.77-41.948-96.32-126.983-207.72-156.313m-169.47 38.22l-25.968 16.343c13.18 8.5 23.21 22.565 29.125 39.25c2.57 7.244 4.133 14.205 4.75 20.78l23.44-23.374c-8.08-19.19-19.035-37.566-31.345-53zm38.376 72.374l-42.063 42l-.156-.156c-4.255 3.942-9.456 6.765-15.186 7.938c23.268 14.873 44.644 19.346 56.812 9.562c4.26-3.426 7.043-8.36 8.47-14.406c-.41-12.684-2.602-26.615-6.657-40.906c-.382-1.346-.806-2.686-1.22-4.032"/></symbol><symbol id="gi-play" viewBox="0 0 512 512"><path fill="currentColor" d="M106.854 106.002a26.003 26.003 0 0 0-25.64 29.326c16 124 16 117.344 0 241.344a26.003 26.003 0 0 0 35.776 27.332l298-124a26.003 26.003 0 0 0 0-48.008l-298-124a26 26 0 0 0-10.136-1.994"/></symbol>
</svg>
</body>
</html>