874e15c01e
- 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
7219 lines
413 KiB
HTML
7219 lines
413 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||
<title>Canteen Asset Tracker</title>
|
||
<!-- 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 & 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 & 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 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
|
||
|| '© <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(/&/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 & 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 & 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>
|