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