e66bf2638b
Root cause: rpLoadTechs() and all other route planner API calls used
raw fetch() instead of the api() wrapper, so no Authorization header
was attached. Auth middleware returned 401, technician list never
loaded, users saw 'Select at least one technician'.
Fixed by replacing all 6 fetch('/api/...') calls in the route planner
with api('/api/...') and removing redundant .json() parsing (api()
returns parsed data directly).
Tested in browser: technician checkboxes populate, Load Today's Route
returns 3 optimized stops with 12.5km route, map renders correctly,
search tab queries return Disney WO results via auth.
Closes: #issue-to-be-created
5633 lines
265 KiB
HTML
5633 lines
265 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" />
|
||
<link rel="stylesheet" href="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.css" />
|
||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||
<script src="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.js"></script>
|
||
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/exifr@7/dist/lite.umd.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 {
|
||
height: calc(100dvh - var(--header-height) - var(--tab-height) - 60px);
|
||
min-height: 300px;
|
||
width: 100%;
|
||
border-radius: var(--radius);
|
||
overflow: hidden;
|
||
border: 1px solid var(--border);
|
||
}
|
||
#mapContainer .leaflet-container {
|
||
background: var(--bg);
|
||
}
|
||
/* Geofence panel */
|
||
.geofence-panel {
|
||
margin-top: 8px;
|
||
background: var(--card);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
/* 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; }
|
||
.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 — add text labels to buttons */
|
||
.leaflet-draw-toolbar a {
|
||
width: auto !important; min-width: 30px; padding: 0 10px 0 30px !important;
|
||
display: flex; align-items: center;
|
||
font-size: 11px; font-weight: 600; color: var(--text) !important;
|
||
text-decoration: none !important;
|
||
white-space: nowrap;
|
||
background-position: 8px center !important;
|
||
}
|
||
.leaflet-draw-toolbar a::after { content: attr(title); }
|
||
.leaflet-draw-toolbar a[title="Draw a polygon"]::after { content: "Area"; }
|
||
.leaflet-draw-toolbar a[title="Draw a rectangle"]::after { content: "Box"; }
|
||
.leaflet-draw-toolbar a[title="Draw a circle"]::after { content: "Circle"; }
|
||
.leaflet-draw-toolbar a[title="Edit layers"]::after { content: "Edit"; }
|
||
.leaflet-draw-toolbar a[title="Delete layers"]::after { content: "Delete"; }
|
||
.leaflet-draw-actions a { width: auto !important; padding: 0 10px !important; font-size: 10px; color: var(--text) !important; }
|
||
|
||
/* ── 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> Add / 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.0</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">
|
||
<!-- Hidden photo picker input -->
|
||
<input type="file" id="photoPicker" accept="image/*" style="display:none;">
|
||
<!-- Mode Toggles -->
|
||
<div class="mode-toggles">
|
||
<button class="mode-toggle active" data-mode="barcode" onclick="setAddAssetMode('barcode')">📷 Barcode</button>
|
||
<button class="mode-toggle" data-mode="ocr" onclick="setAddAssetMode('ocr')">🔍 OCR</button>
|
||
<button class="mode-toggle" data-mode="manual" onclick="setAddAssetMode('manual')">✏️ Manual</button>
|
||
<button class="photo-picker-btn" onclick="document.getElementById('photoPicker').click()">📱 Pick from Gallery</button>
|
||
</div>
|
||
|
||
<!-- ── BARCODE MODE ──────────────────────────────────────────────── -->
|
||
<div id="addBarcodeMode" class="add-mode active">
|
||
<div class="card">
|
||
<div class="card-title">Barcode Scanner</div>
|
||
<div id="cameraArea" class="camera-area">
|
||
<div id="cameraPlaceholder" class="camera-placeholder">
|
||
<span class="cam-icon">📷</span>
|
||
<div class="cam-hint">Tap to start camera</div>
|
||
</div>
|
||
<video id="cameraVideo" autoplay playsinline muted></video>
|
||
</div>
|
||
<div id="scanStatus" class="status-bar">Point camera at a barcode</div>
|
||
<div id="scanResult" class="scan-result" style="display:none;"></div>
|
||
</div>
|
||
|
||
<!-- New Asset form (shown when machine_id not found) -->
|
||
<div id="newAssetCard" class="card" style="display:none;">
|
||
<div class="card-title">Create New Asset</div>
|
||
<input id="newMachineId" type="text" class="input-field" placeholder="Machine ID" readonly>
|
||
<input id="newName" type="text" class="input-field" placeholder="Asset name *" required>
|
||
<textarea id="newDesc" class="input-field" placeholder="Telemetry ID (optional)" rows="2"></textarea>
|
||
<div class="form-row" style="margin-bottom:8px;">
|
||
<select id="newCatSelect" class="input-field">
|
||
<option value="">Category</option>
|
||
</select>
|
||
<select id="newStatus" class="input-field">
|
||
<option value="active">Active</option>
|
||
<option value="maintenance">Maintenance</option>
|
||
<option value="retired">Retired</option>
|
||
</select>
|
||
</div>
|
||
<button class="btn btn-primary" onclick="createScannedAsset()">Create Asset</button>
|
||
</div>
|
||
|
||
<!-- Check-in form (shown when barcode found) -->
|
||
<div id="checkinCard" class="card" style="display:none;">
|
||
<div class="card-title">Check In</div>
|
||
<div id="checkinGpsRow" style="display:flex;align-items:center;gap:8px;margin-bottom:8px;font-size:13px;">
|
||
<span id="checkinGpsStatus" class="gps-badge waiting" style="margin:0;">📍 Tap for GPS</span>
|
||
<button class="btn btn-sm btn-outline" onclick="captureCheckinGps()" style="white-space:nowrap;">Capture GPS</button>
|
||
<span id="checkinGpsCoords" style="color:var(--text3);font-family:monospace;font-size:11px;display:none;"></span>
|
||
</div>
|
||
<textarea id="checkinNotes" class="input-field" placeholder="Notes (optional)" rows="2"></textarea>
|
||
<div class="form-row">
|
||
<button class="btn btn-green" onclick="submitCheckin()" style="flex:1;">✓ Check In</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── OCR MODE ──────────────────────────────────────────────────── -->
|
||
<div id="addOcrMode" class="add-mode">
|
||
<div class="card">
|
||
<div class="card-title">OCR Scanner — Photograph Sticker</div>
|
||
<div id="ocrCameraArea" class="camera-area">
|
||
<div id="ocrPlaceholder" class="camera-placeholder" onclick="startOcrCamera()">
|
||
<span class="cam-icon">📸</span>
|
||
<div class="cam-hint" style="margin-top:4px;">Tap to start camera</div>
|
||
</div>
|
||
<video id="ocrVideo" autoplay playsinline muted style="display:none;"></video>
|
||
<div class="ocr-capture-overlay" id="ocrCaptureOverlay" style="display:none;">
|
||
<button class="ocr-capture-btn" onclick="captureOcr()" title="Capture">📸</button>
|
||
</div>
|
||
</div>
|
||
<div id="ocrStatus" class="status-bar">Point camera at the machine sticker and tap capture</div>
|
||
<div 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>
|
||
<!-- Quick create from OCR -->
|
||
<div id="ocrCreateCard" class="card" style="display:none;">
|
||
<div class="card-title">Create from OCR</div>
|
||
<input id="ocrMachineId" type="text" class="input-field" placeholder="Machine ID" readonly>
|
||
<input id="ocrName" type="text" class="input-field" placeholder="Asset name *" required>
|
||
<select id="ocrCatSelect" class="input-field">
|
||
<option value="">Category</option>
|
||
</select>
|
||
<select id="ocrStatus" class="input-field">
|
||
<option value="active">Active</option>
|
||
<option value="maintenance">Maintenance</option>
|
||
<option value="retired">Retired</option>
|
||
</select>
|
||
<button class="btn btn-primary" onclick="createOcrAsset()">Create Asset</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── MANUAL MODE ───────────────────────────────────────────────── -->
|
||
<div id="addManualMode" class="add-mode">
|
||
<div class="card">
|
||
<div class="card-title">Manual Entry</div>
|
||
|
||
<!-- Basic Info -->
|
||
<input id="manMachineId" type="text" class="input-field" placeholder="Machine ID *" required>
|
||
<input id="manSerialNumber" type="text" class="input-field" placeholder="Serial Number">
|
||
<input id="manName" type="text" class="input-field" placeholder="Asset Name *" required>
|
||
<textarea id="manDescription" class="input-field" placeholder="Telemetry ID" rows="2"></textarea>
|
||
|
||
<div class="form-row" style="margin-bottom:8px;">
|
||
<select id="manCatSelect" class="input-field">
|
||
<option value="">Category</option>
|
||
</select>
|
||
<select id="manMake" class="input-field" onchange="loadModels()">
|
||
<option value="">Make</option>
|
||
</select>
|
||
</div>
|
||
<select id="manModel" class="input-field" style="margin-bottom:8px;">
|
||
<option value="">Model</option>
|
||
</select>
|
||
<select id="manStatus" class="input-field" style="margin-bottom:0;">
|
||
<option value="active">Active</option>
|
||
<option value="inactive">Inactive</option>
|
||
<option value="maintenance">Maintenance</option>
|
||
<option value="retired">Retired</option>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- Directions -->
|
||
<div class="form-section">
|
||
<div class="form-section-title">📍 Directions & Access</div>
|
||
<input id="manAddress" type="text" class="input-field" placeholder="Address / Trailer Number">
|
||
<div class="form-row">
|
||
<input id="manBuildingName" type="text" class="input-field" placeholder="Building Name">
|
||
<input id="manBuildingNumber" type="text" class="input-field" placeholder="Building #" oninput="document.getElementById('manAddress').value = this.value">
|
||
</div>
|
||
<div class="form-row">
|
||
<input id="manFloor" type="text" class="input-field" placeholder="Floor">
|
||
<input id="manRoom" type="text" class="input-field" placeholder="Room">
|
||
</div>
|
||
<textarea id="manWalkingDirections" class="input-field" placeholder="Walking directions..." rows="2"></textarea>
|
||
<div class="form-row">
|
||
<input id="manMapLink" type="url" class="input-field" placeholder="Map link (URL)">
|
||
<button class="btn btn-outline btn-sm" onclick="openMapPin()" style="flex-shrink:0;">📍 Pin</button>
|
||
</div>
|
||
<div class="form-row">
|
||
<input id="manParkingLocation" type="text" class="input-field" placeholder="Parking location">
|
||
<button class="btn btn-outline btn-sm" onclick="fillGpsParking('manParkingLocation')" style="flex-shrink:0;">📍 GPS</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Keys -->
|
||
<div class="form-section">
|
||
<div class="form-section-title">
|
||
🔑 Keys
|
||
<button class="btn btn-outline btn-sm" onclick="addKeyEntry()">+ Add Key</button>
|
||
</div>
|
||
<div id="manKeysList"></div>
|
||
</div>
|
||
|
||
<!-- Security Badges -->
|
||
<div class="form-section">
|
||
<div class="form-section-title">🪪 Security Badges</div>
|
||
<div class="badge-checklist" id="manBadgesList"></div>
|
||
</div>
|
||
|
||
<!-- Customer & Location -->
|
||
<div class="form-section">
|
||
<div class="form-section-title">🏢 Customer & Location</div>
|
||
<select id="manCustomer" class="input-field" onchange="loadManualLocations()">
|
||
<option value="">Customer</option>
|
||
</select>
|
||
<select id="manLocation" class="input-field" style="margin-bottom:0;">
|
||
<option value="">Location</option>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- Photo -->
|
||
<div class="form-section">
|
||
<div class="form-section-title">📸 Photo (optional)</div>
|
||
<div id="manPhotoArea" class="camera-area" style="aspect-ratio:4/3;margin-bottom:8px;overflow:visible;">
|
||
<div id="manPhotoPlaceholder" class="camera-placeholder">
|
||
<div style="display:flex;flex-direction:column;gap:10px;align-items:center;">
|
||
<button class="btn btn-outline" onclick="document.getElementById('manPhotoInput').click()" style="width:100%;padding:14px;font-size:15px;">📁 Choose from Gallery</button>
|
||
<div style="font-size:11px;color:var(--text2);">(preserves EXIF/GPS)</div>
|
||
<div style="width:80%;height:1px;background:var(--border);margin:4px 0;"></div>
|
||
<button class="btn btn-outline" onclick="openManualCamera()" style="width:100%;padding:14px;font-size:15px;">📸 Take Photo</button>
|
||
<div style="font-size:11px;color:var(--text2);">(quick capture, no EXIF)</div>
|
||
</div>
|
||
</div>
|
||
<input type="file" id="manPhotoInput" accept="image/*" style="display:none;" onchange="handleManualPhotoFile(event)">
|
||
</div>
|
||
<div id="manPhotoCaptureRow" style="display:none;justify-content:center;margin-bottom:8px;">
|
||
<button class="ocr-capture-btn" onclick="openManualCamera()" title="Capture" style="position:static;width:48px;height:48px;font-size:18px;">📸</button>
|
||
</div>
|
||
<img id="manPhotoPreview" style="display:none;width:100%;border-radius:var(--radius-sm);">
|
||
<button id="manPhotoRetake" class="btn btn-outline btn-sm" style="display:none;margin-top:6px;width:100%;" onclick="retakeManualPhoto()">🔄 Retake</button>
|
||
</div>
|
||
|
||
<!-- Submit -->
|
||
<div class="form-row" style="margin-bottom:10px;">
|
||
<button class="btn btn-primary" onclick="submitManualAsset(false)" style="flex:2;" title="Save this asset to the database">Create Asset</button>
|
||
<button class="btn btn-outline" onclick="submitManualAsset(true)" style="flex:1;" title="Save and clear form to add another">+ Add Another</button>
|
||
</div>
|
||
|
||
<!-- Post-create check-in -->
|
||
<div id="manCheckinCard" class="card" style="display:none;">
|
||
<div class="card-title">Quick Check-in</div>
|
||
<textarea id="manCheckinNotes" class="input-field" placeholder="Notes (optional)" rows="2"></textarea>
|
||
<button class="btn btn-green" onclick="quickCheckinManual()" style="width:100%;">✓ Check In Now</button>
|
||
</div>
|
||
</div> </div>
|
||
|
||
<!-- ── Photo Preview Card (shown when a photo is picked from gallery) ── -->
|
||
<div id="photoPreviewCard" class="photo-preview-card" style="display:none;">
|
||
<img id="pickedPhotoPreview" src="" alt="Picked photo">
|
||
<div class="photo-preview-toolbar">
|
||
<button onclick="ocrPickedPhoto()">🔍 OCR this photo</button>
|
||
<button onclick="extractGpsFromPicked()">📍 Extract GPS</button>
|
||
<button onclick="createAssetFromPicked()">➕ Create Asset</button>
|
||
</div>
|
||
<div class="exif-data" id="pickedExifData" style="display:none;"></div>
|
||
<button class="btn btn-outline btn-sm" style="margin-top:8px;width:100%;" onclick="clearPickedPhoto()">✕ Clear</button>
|
||
</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>
|
||
<button class="btn btn-outline btn-sm import-btn" onclick="showImportView()" style="margin-left:auto;" title="Import assets from a CSV file">📥 Import</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">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>
|
||
|
||
<!-- 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>
|
||
|
||
<!-- Check-in History -->
|
||
<div class="card">
|
||
<div class="card-title">Check-in History <span id="checkinCount" style="color:var(--accent2);"></span></div>
|
||
<div id="checkinHistory"></div>
|
||
</div>
|
||
|
||
<!-- Actions -->
|
||
<div style="display:flex;gap:8px;margin-bottom:10px;flex-wrap:wrap;">
|
||
<button class="btn btn-green btn-sm" onclick="quickCheckin()" style="flex:1;min-width:100px;">✓ Check In</button>
|
||
<button class="btn btn-outline btn-sm" id="detailDirectionsBtn" onclick="openMapLink()" style="flex:1;min-width:100px;display:none;">🗺️ Directions</button>
|
||
<button class="btn btn-outline btn-sm" id="detailNavigateBtn" onclick="navigateToAsset()" style="flex:1;min-width:100px;display:none;">🧭 Navigate</button>
|
||
</div>
|
||
<div style="display:flex;gap:8px;margin-bottom:10px;">
|
||
<button class="btn btn-outline btn-sm" onclick="editAsset()" style="flex:1;">✏️ Edit</button>
|
||
<button class="btn btn-danger btn-sm" onclick="deleteAsset()" style="flex:1;">🗑️ Delete</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Edit View ────────────────────────────────────────────────────── -->
|
||
<div id="assetsEditView" style="display:none;">
|
||
<div class="detail-header">
|
||
<button class="back-btn" onclick="showAssetList()" title="Go back to asset list">←</button>
|
||
<div style="font-size:18px;font-weight:700;">Edit Asset</div>
|
||
</div>
|
||
<div class="card" id="editFormCard">
|
||
<input id="editBarcode" type="text" class="input-field" placeholder="Machine ID *">
|
||
<input id="editName" type="text" class="input-field" placeholder="Asset name *">
|
||
<textarea id="editDesc" class="input-field" placeholder="Telemetry ID" rows="2"></textarea>
|
||
<div class="form-row" style="margin-bottom:8px;">
|
||
<select id="editCategory" class="input-field">
|
||
<option value="">Category</option>
|
||
<option>Furniture</option>
|
||
<option>Appliances</option>
|
||
<option>Utensils & Serveware</option>
|
||
<option>Equipment</option>
|
||
<option>Other</option>
|
||
</select>
|
||
<select id="editStatus" class="input-field">
|
||
<option value="active">Active</option>
|
||
<option value="maintenance">Maintenance</option>
|
||
<option value="retired">Retired</option>
|
||
</select>
|
||
</div>
|
||
<select id="editAssignedTo" class="input-field">
|
||
<option value="">Assigned To (none)</option>
|
||
</select>
|
||
<button class="btn btn-primary" onclick="submitEditAsset()">Save Changes</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Import View ──────────────────────────────────────────────────── -->
|
||
<div id="assetsImportView" style="display:none;">
|
||
<div class="detail-header">
|
||
<button class="back-btn" onclick="showAssetList()" title="Go back to asset list">←</button>
|
||
<div style="font-size:18px;font-weight:700;">Import Assets</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-title">CSV File</div>
|
||
<p style="font-size:13px;color:var(--text2);margin-bottom:10px;">
|
||
Upload a CSV file with columns: machine_id, name, category, make, model, serial_number, customer, location
|
||
</p>
|
||
<input type="file" id="importFileInput" accept=".csv" class="input-field" style="margin-bottom:12px;">
|
||
<div id="importPreview" style="display:none;margin-bottom:12px;">
|
||
<div style="font-size:12px;color:var(--text2);margin-bottom:4px;" id="importRowCount"></div>
|
||
<div class="import-table-wrap">
|
||
<table class="import-table" id="importTable"><thead></thead><tbody></tbody></table>
|
||
</div>
|
||
</div>
|
||
<div id="importResults" style="display:none;"></div>
|
||
<div style="display:flex;gap:8px;">
|
||
<button class="btn btn-outline btn-sm" onclick="showAssetList()" style="flex:1;">Cancel</button>
|
||
<button class="btn btn-primary btn-sm" id="importRunBtn" onclick="runImport()" style="flex:2;" disabled>Start Import</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══════════════════════════════════════════════════════════════════════
|
||
TAB: MAP
|
||
═══════════════════════════════════════════════════════════════════════ -->
|
||
<div id="tabMap" class="tab-panel">
|
||
<div class="map-controls">
|
||
<span class="map-chip" 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>
|
||
<div id="mapContainer"></div>
|
||
</div>
|
||
|
||
<!-- ═══════════════════════════════════════════════════════════════════════
|
||
TAB: NAVIGATE
|
||
═══════════════════════════════════════════════════════════════════════ -->
|
||
<div id="tabNavigate" class="tab-panel">
|
||
<div class="detail-header">
|
||
<button class="back-btn" onclick="closeNavigate()">← Back</button>
|
||
<div class="dh-info">
|
||
<div id="navDestName" style="font-size:18px;font-weight:700;">Navigate</div>
|
||
<div id="navDestAddr" style="font-size:12px;color:var(--text2);"></div>
|
||
</div>
|
||
</div>
|
||
<div id="navMapContainer" style="height:280px;border-radius:12px;overflow:hidden;margin-bottom:12px;"></div>
|
||
<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>
|
||
</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 or manually add an asset">
|
||
<span class="tab-icon">📷</span> Add / 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 Assets, Add, Route, and Map views</li>
|
||
<li>Use the ☰ menu to access settings, profile, and admin panel</li>
|
||
</ul>
|
||
<p style="margin-bottom:10px;"><strong>Offline Mode</strong></p>
|
||
<ul style="margin:0 0 12px 20px;padding:0;list-style:disc;">
|
||
<li>When disconnected, changes are queued and sync automatically</li>
|
||
<li>Click the ⬇️ badge to process the queue immediately</li>
|
||
<li>Barcode scanning and manual entry work offline</li>
|
||
</ul>
|
||
<p><strong>Scanning</strong></p>
|
||
<ul style="margin:0 0 0 20px;padding:0;list-style:disc;">
|
||
<li>Use the Add 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,
|
||
};
|
||
|
||
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() {
|
||
// Check for stored token
|
||
const stored = localStorage.getItem('canteen_session');
|
||
if (stored) {
|
||
try {
|
||
const session = JSON.parse(stored);
|
||
AppState.authToken = session.token;
|
||
// Validate token by fetching current user
|
||
const user = await api('/api/auth/me', {
|
||
headers: { 'Authorization': 'Bearer ' + session.token }
|
||
});
|
||
AppState.currentUser = user;
|
||
// Load settings now that we're authenticated
|
||
loadSettingsCache().then(() => {
|
||
populateCategorySelect('newCatSelect');
|
||
populateCategorySelect('ocrCatSelect');
|
||
populateCategorySelect('manCatSelect');
|
||
populateMakeSelect();
|
||
populateCustomerSelect();
|
||
loadBadgeChecklist();
|
||
});
|
||
} catch (e) {
|
||
// Token expired or invalid — clear it
|
||
localStorage.removeItem('canteen_session');
|
||
AppState.authToken = null;
|
||
AppState.currentUser = null;
|
||
}
|
||
}
|
||
updateUserUI();
|
||
checkAuthGate();
|
||
}
|
||
|
||
// =========================================================================
|
||
// AUTH: Login, Logout, Auth Gate (Phase M)
|
||
// =========================================================================
|
||
async function doLogin() {
|
||
const username = document.getElementById('loginUsername').value.trim();
|
||
const password = document.getElementById('loginPassword').value;
|
||
const errEl = document.getElementById('loginError');
|
||
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(() => {
|
||
populateCategorySelect('newCatSelect');
|
||
populateCategorySelect('ocrCatSelect');
|
||
populateCategorySelect('manCatSelect');
|
||
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 = null;
|
||
updateUserUI();
|
||
showLogin();
|
||
showToast('Logged out');
|
||
}
|
||
|
||
function showLogin() {
|
||
document.getElementById('loginOverlay').classList.remove('hidden');
|
||
document.getElementById('loginUsername').value = '';
|
||
document.getElementById('loginPassword').value = '';
|
||
document.getElementById('loginError').classList.remove('show');
|
||
document.getElementById('loginUsername').focus();
|
||
}
|
||
|
||
function hideLogin() {
|
||
document.getElementById('loginOverlay').classList.add('hidden');
|
||
}
|
||
|
||
function checkAuthGate() {
|
||
if (isLoggedIn()) {
|
||
hideLogin();
|
||
} else {
|
||
showLogin();
|
||
}
|
||
}
|
||
|
||
// 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);
|
||
|
||
// Geofence drawing — only if L.Draw loaded
|
||
if (typeof L.Draw !== 'undefined') {
|
||
const drawnItems = new L.FeatureGroup();
|
||
map.addLayer(drawnItems);
|
||
const drawControl = new L.Control.Draw({
|
||
edit: { featureGroup: drawnItems },
|
||
draw: {
|
||
polygon: { shapeOptions: { color: '#c2410c' }, allowIntersection: false, showArea: false,
|
||
tooltips: { start: 'Tap to start drawing area', cont: 'Tap to continue', end: 'Tap first point to close' } },
|
||
polyline: false,
|
||
rectangle: { shapeOptions: { color: '#c2410c' },
|
||
tooltips: { start: 'Tap & drag to draw box', end: '' } },
|
||
circle: { shapeOptions: { color: '#c2410c' },
|
||
tooltips: { start: 'Tap & drag to draw circle', end: '' } },
|
||
marker: false,
|
||
circlemarker: false
|
||
}
|
||
});
|
||
map.addControl(drawControl);
|
||
map.on(L.Draw.Event.CREATED, function(e) {
|
||
drawnItems.addLayer(e.layer);
|
||
// Dispatch event for any external hooks
|
||
document.dispatchEvent(new CustomEvent('geofenceDrawn', { detail: { layer: e.layerType, coords: e.layer.toGeoJSON() } }));
|
||
});
|
||
}
|
||
// 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'));
|
||
}
|
||
|
||
// 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();
|
||
}
|
||
}
|
||
|
||
// ── Photo Picker / Gallery ────────────────────────────────────────────────
|
||
let pickedPhotoFile = null;
|
||
|
||
// Wire the hidden file input
|
||
(function() {
|
||
const pp = document.getElementById('photoPicker');
|
||
if (pp) {
|
||
pp.addEventListener('change', function(e) {
|
||
if (e.target.files && e.target.files[0]) {
|
||
handlePickedPhoto(e.target.files[0]);
|
||
}
|
||
});
|
||
}
|
||
})();
|
||
|
||
async function handlePickedPhoto(file) {
|
||
pickedPhotoFile = file;
|
||
const reader = new FileReader();
|
||
reader.onload = async function(e) {
|
||
document.getElementById('pickedPhotoPreview').src = e.target.result;
|
||
document.getElementById('photoPreviewCard').style.display = 'block';
|
||
|
||
// Extract EXIF / file metadata
|
||
await extractExifData(file);
|
||
|
||
// Auto-extract GPS from the picked photo
|
||
tryExtractGpsFromPicked();
|
||
|
||
// Scroll to preview
|
||
document.getElementById('photoPreviewCard').scrollIntoView({ behavior: 'smooth' });
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
|
||
async function extractExifData(file) {
|
||
const exifDiv = document.getElementById('pickedExifData');
|
||
const rows = [];
|
||
rows.push({ label: '📄 File name', value: file.name });
|
||
rows.push({ label: '📦 File size', value: formatFileSize(file.size) });
|
||
rows.push({ label: '🏷️ Type', value: file.type || 'unknown' });
|
||
rows.push({ label: '🕐 Modified', value: formatDate(new Date(file.lastModified).toISOString()) });
|
||
|
||
// Try to read actual EXIF tags from the file
|
||
try {
|
||
const exifData = await exifr.parse(file);
|
||
if (exifData) {
|
||
if (exifData.Make || exifData.Model) {
|
||
rows.push({ label: '📷 Camera', value: [exifData.Make, exifData.Model].filter(Boolean).join(' ') });
|
||
}
|
||
if (exifData.DateTimeOriginal) {
|
||
const d = new Date(exifData.DateTimeOriginal);
|
||
rows.push({ label: '📅 Date taken', value: d.toLocaleString() });
|
||
}
|
||
if (exifData.ISO) {
|
||
rows.push({ label: '🎯 ISO', value: String(exifData.ISO) });
|
||
}
|
||
if (exifData.FNumber) {
|
||
rows.push({ label: '🔆 Aperture', value: 'f/' + exifData.FNumber });
|
||
}
|
||
if (exifData.ExposureTime) {
|
||
rows.push({ label: '⏱️ Shutter', value: exifData.ExposureTime + 's' });
|
||
}
|
||
if (exifData.FocalLength) {
|
||
rows.push({ label: '🔭 Focal', value: exifData.FocalLength + 'mm' });
|
||
}
|
||
if (exifData.GPSLatitude && exifData.GPSLongitude) {
|
||
const lat = exifData.latitude || exifData.GPSLatitude;
|
||
const lng = exifData.longitude || exifData.GPSLongitude;
|
||
rows.push({ label: '📍 GPS', value: typeof lat === 'number' ? `${lat.toFixed(6)}, ${lng.toFixed(6)}` : `${exifData.GPSLatitude.join(' ')} ${exifData.GPSLatitudeRef}, ${exifData.GPSLongitude.join(' ')} ${exifData.GPSLongitudeRef}` });
|
||
}
|
||
}
|
||
} catch (_) { /* EXIF display is best-effort */ }
|
||
|
||
exifDiv.innerHTML = rows.map(r =>
|
||
`<div class="exif-row"><span class="exif-label">${esc(r.label)}</span><span class="exif-value">${esc(r.value)}</span></div>`
|
||
).join('');
|
||
exifDiv.style.display = 'block';
|
||
}
|
||
|
||
function formatFileSize(bytes) {
|
||
if (bytes < 1024) return bytes + ' B';
|
||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||
return (bytes / 1048576).toFixed(1) + ' MB';
|
||
}
|
||
|
||
async function ocrPickedPhoto() {
|
||
if (!pickedPhotoFile) return;
|
||
// Switch to OCR mode
|
||
setAddAssetMode('ocr');
|
||
|
||
// Read EXIF data from the picked photo (defense against transport stripping)
|
||
let exifDataStr = null;
|
||
try {
|
||
const exifData = await exifr.parse(pickedPhotoFile);
|
||
if (exifData && Object.keys(exifData).length > 0) {
|
||
exifDataStr = JSON.stringify(exifData);
|
||
}
|
||
} catch (_) { /* EXIF is best-effort */ }
|
||
|
||
// Build form data with file + EXIF
|
||
const fd = new FormData();
|
||
fd.append('file', pickedPhotoFile, pickedPhotoFile.name || 'photo.jpg');
|
||
if (exifDataStr) {
|
||
fd.append('exif_data', exifDataStr);
|
||
}
|
||
|
||
setOcrStatus('Processing OCR...', 'working');
|
||
document.getElementById('ocrResult').style.display = 'none';
|
||
document.getElementById('ocrCreateCard').style.display = 'none';
|
||
|
||
let data = null;
|
||
try {
|
||
const res = await fetch('/api/ocr', {
|
||
method: 'POST',
|
||
body: fd,
|
||
headers: AppState.authToken
|
||
? { 'Authorization': 'Bearer ' + AppState.authToken }
|
||
: {},
|
||
});
|
||
if (res.ok) {
|
||
data = await res.json();
|
||
data.method = 'server';
|
||
} else {
|
||
throw new Error('Server returned ' + res.status);
|
||
}
|
||
} catch (e) {
|
||
setOcrStatus('Server OCR failed: ' + e.message, 'error');
|
||
return;
|
||
}
|
||
|
||
displayOcrResult(data);
|
||
}
|
||
|
||
async function createAssetFromPicked() {
|
||
if (!pickedPhotoFile) return;
|
||
// Switch to Manual mode and pass the original file (preserves EXIF)
|
||
setAddAssetMode('manual');
|
||
|
||
// Pass the original File directly — preserves EXIF for server re-embed
|
||
manualPhotoFile = pickedPhotoFile;
|
||
manualPhotoBlob = pickedPhotoFile; // Use original file (preserves EXIF)
|
||
|
||
// Show the photo in the manual preview using FileReader
|
||
const reader = new FileReader();
|
||
reader.onload = function(e) {
|
||
const img = document.getElementById('manPhotoPreview');
|
||
img.src = e.target.result;
|
||
img.style.display = 'block';
|
||
const ph = document.getElementById('manPhotoPlaceholder');
|
||
if (ph) ph.style.display = 'none';
|
||
const retake = document.getElementById('manPhotoRetake');
|
||
if (retake) retake.style.display = 'block';
|
||
};
|
||
reader.readAsDataURL(pickedPhotoFile);
|
||
|
||
// Try EXIF GPS from the original file
|
||
manualPhotoGps = await extractGpsFromPhoto(pickedPhotoFile);
|
||
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('Photo loaded into Manual mode');
|
||
}
|
||
|
||
// ── 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() {
|
||
// Restrict to 1D barcode formats only — much faster and more accurate
|
||
// than scanning all formats (QR, DataMatrix, PDF417, Aztec, etc.)
|
||
const hints = new Map();
|
||
const fmts = [
|
||
ZXing.BarcodeFormat.CODE_128,
|
||
ZXing.BarcodeFormat.CODE_39,
|
||
ZXing.BarcodeFormat.EAN_13,
|
||
ZXing.BarcodeFormat.EAN_8,
|
||
ZXing.BarcodeFormat.UPC_A,
|
||
ZXing.BarcodeFormat.UPC_E,
|
||
ZXing.BarcodeFormat.CODE_93,
|
||
ZXing.BarcodeFormat.ITF,
|
||
ZXing.BarcodeFormat.CODABAR,
|
||
];
|
||
hints.set(ZXing.DecodeHintType.POSSIBLE_FORMATS, fmts);
|
||
hints.set(ZXing.DecodeHintType.TRY_HARDER, true);
|
||
return hints;
|
||
}
|
||
|
||
function _barcodeOptions() {
|
||
return {
|
||
delayBetweenScanAttempts: 50, // scan every 50ms (was default ~500ms)
|
||
delayBetweenScanSuccess: 2000, // 2s cooldown after a successful scan
|
||
};
|
||
}
|
||
|
||
async function startScanning() {
|
||
if (addAssetMode !== 'barcode') return;
|
||
if (scanningActive) return;
|
||
try {
|
||
setScanStatus('Starting camera...', 'working');
|
||
|
||
// Stop any previous reader
|
||
if (codeReader) {
|
||
try { codeReader.reset(); } catch (e) { /* ignore */ }
|
||
codeReader = null;
|
||
}
|
||
|
||
const video = document.getElementById('cameraVideo');
|
||
if (!video) return;
|
||
|
||
// Create reader with 1D-only hints for fast, accurate scanning
|
||
codeReader = new ZXing.BrowserMultiFormatReader(_barcodeHints(), _barcodeOptions());
|
||
|
||
// Pick the rear-facing camera (environment-facing on phones)
|
||
let deviceId = null;
|
||
try {
|
||
const devices = await codeReader.listVideoInputDevices();
|
||
if (devices && devices.length > 0) {
|
||
const rear = devices.find(d =>
|
||
d.label && /back|rear|environment/i.test(d.label)
|
||
);
|
||
deviceId = rear ? rear.deviceId : devices[0].deviceId;
|
||
}
|
||
} catch (e) {
|
||
// Fallback: pass null to let decodeFromVideoDevice pick default
|
||
console.warn('listVideoInputDevices failed, using default camera:', e);
|
||
}
|
||
|
||
document.getElementById('cameraPlaceholder').style.display = 'none';
|
||
video.style.display = 'block';
|
||
scanningActive = true;
|
||
|
||
setScanStatus('Point camera at a barcode', 'success');
|
||
|
||
// decodeFromVideoDevice handles camera + scanning in one call
|
||
codeReader.decodeFromVideoDevice(deviceId, video, (result, err) => {
|
||
if (result && scanningActive) {
|
||
const val = result.getText();
|
||
if (val && val !== currentScannedMachineId) {
|
||
handleBarcode(val.trim());
|
||
}
|
||
}
|
||
});
|
||
} catch (e) {
|
||
console.error('Camera error:', e);
|
||
setScanStatus('Camera failed: ' + e.message, 'error');
|
||
const ph = document.getElementById('cameraPlaceholder');
|
||
if (ph) ph.innerHTML = `<span class="cam-icon">📷</span><div class="cam-hint" style="margin-top:4px;">Camera: ${e.message}</div>`;
|
||
}
|
||
}
|
||
|
||
function stopScanning() {
|
||
scanningActive = false;
|
||
if (codeReader) {
|
||
try { codeReader.reset(); } catch (e) { /* ignore */ }
|
||
codeReader = null;
|
||
}
|
||
const video = document.getElementById('cameraVideo');
|
||
if (video) video.style.display = 'none';
|
||
const ph = document.getElementById('cameraPlaceholder');
|
||
if (ph) {
|
||
ph.style.display = '';
|
||
ph.innerHTML = '<span class="cam-icon">📷</span><div class="cam-hint" style="margin-top:4px;">Tap to start camera</div>';
|
||
}
|
||
}
|
||
|
||
if (document.getElementById('cameraPlaceholder')) {
|
||
document.getElementById('cameraPlaceholder').addEventListener('click', startScanning);
|
||
}
|
||
|
||
function setScanStatus(msg, cls = '') {
|
||
const el = document.getElementById('scanStatus');
|
||
if (el) { el.textContent = msg; el.className = 'status-bar ' + cls; }
|
||
}
|
||
|
||
async function handleBarcode(machineId) {
|
||
if (barcodeDebounce) return;
|
||
barcodeDebounce = machineId;
|
||
currentScannedMachineId = machineId;
|
||
|
||
setScanStatus(`Scanned: ${machineId} — looking up...`, 'working');
|
||
document.getElementById('scanResult').style.display = 'none';
|
||
document.getElementById('newAssetCard').style.display = 'none';
|
||
document.getElementById('checkinCard').style.display = 'none';
|
||
|
||
try {
|
||
const asset = await api(`/api/assets/search?machine_id=${encodeURIComponent(machineId)}`);
|
||
// 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="openCheckinForAsset(${asset.id});" style="flex:1;">
|
||
${hasGps ? 'Add Notes' : 'Check In'}
|
||
</button>
|
||
<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;
|
||
switchTab('tabAddAsset');
|
||
// Defer status update so it runs after async startScanning completes
|
||
setTimeout(() => {
|
||
setScanStatus('Machine ID not in database — create asset below', 'error');
|
||
}, 50);
|
||
|
||
|
||
document.getElementById('newMachineId').value = machineId;
|
||
document.getElementById('newName').value = '';
|
||
document.getElementById('newDesc').value = '';
|
||
populateCategorySelect('newCatSelect');
|
||
document.getElementById('newCatSelect').value = '';
|
||
document.getElementById('newStatus').value = 'active';
|
||
document.getElementById('newAssetCard').style.display = 'block';
|
||
document.getElementById('checkinCard').style.display = 'none';
|
||
document.getElementById('scanResult').style.display = 'none';
|
||
document.getElementById('newName').focus();
|
||
}
|
||
|
||
function showCheckinForm() {
|
||
document.getElementById('checkinNotes').value = '';
|
||
document.getElementById('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 createScannedAsset() {
|
||
const machineId = document.getElementById('newMachineId').value.trim();
|
||
const name = document.getElementById('newName').value.trim();
|
||
if (!machineId || !name) {
|
||
showToast('Machine ID and name are required', true);
|
||
return;
|
||
}
|
||
const payload = {
|
||
machine_id: machineId, name,
|
||
description: document.getElementById('newDesc').value.trim(),
|
||
category: document.getElementById('newCatSelect').value || 'Other',
|
||
status: document.getElementById('newStatus').value,
|
||
};
|
||
// F2: Auto-populate address from GPS
|
||
if (AppState.gpsLat) {
|
||
const geo = await reverseGeocode(AppState.gpsLat, AppState.gpsLng);
|
||
if (geo) {
|
||
payload.address = geo.address || geo.formatted || '';
|
||
payload.building_number = geo.building_number || '';
|
||
payload.building_name = geo.building_name || '';
|
||
payload.trailer_number = geo.building_number || ''; // F4
|
||
}
|
||
}
|
||
try {
|
||
const asset = await api('/api/assets', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
showToast('Asset created!');
|
||
document.getElementById('newAssetCard').style.display = 'none';
|
||
AppState.currentAssetId = asset.id;
|
||
showScannedAsset(asset);
|
||
} catch (e) {
|
||
showToast(e.message, true);
|
||
}
|
||
}
|
||
|
||
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 (!confirm('No GPS location captured. Check in without location data? Press Cancel to go back and tap "Capture GPS" first.')) {
|
||
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>` : ''}`;
|
||
setOcrStatus('Machine ID found!', 'success');
|
||
|
||
// Show quick create form
|
||
document.getElementById('ocrMachineId').value = data.machine_id;
|
||
document.getElementById('ocrName').value = '';
|
||
populateCategorySelect('ocrCatSelect');
|
||
document.getElementById('ocrStatus').value = 'active';
|
||
document.getElementById('ocrCreateCard').style.display = 'block';
|
||
document.getElementById('ocrName').focus();
|
||
} else {
|
||
el.innerHTML = `
|
||
<div style="font-size:16px;font-weight:600;color:var(--red);">No machine ID detected</div>
|
||
<div class="or-meta">Try again with better lighting and focus on the sticker.</div>
|
||
${data.raw_text ? `<div class="or-raw">OCR text: ${esc(data.raw_text)}</div>` : ''}`;
|
||
setOcrStatus('No machine ID found — try again', 'error');
|
||
}
|
||
|
||
// 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));
|
||
}
|
||
}
|
||
|
||
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));
|
||
|
||
setOcrStatus('Processing OCR...', 'working');
|
||
document.getElementById('ocrResult').style.display = 'none';
|
||
document.getElementById('ocrCreateCard').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(), 8000);
|
||
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');
|
||
}
|
||
|
||
displayOcrResult(data);
|
||
}
|
||
|
||
async function createOcrAsset() {
|
||
const machineId = document.getElementById('ocrMachineId').value.trim();
|
||
const name = document.getElementById('ocrName').value.trim();
|
||
if (!machineId || !name) {
|
||
showToast('Machine ID and name are required', true);
|
||
return;
|
||
}
|
||
const payload = {
|
||
machine_id: machineId, name,
|
||
category: document.getElementById('ocrCatSelect').value || 'Other',
|
||
status: document.getElementById('ocrStatus').value,
|
||
};
|
||
// F2: Auto-populate address from GPS
|
||
if (AppState.gpsLat) {
|
||
const geo = await reverseGeocode(AppState.gpsLat, AppState.gpsLng);
|
||
if (geo) {
|
||
payload.address = geo.address || geo.formatted || '';
|
||
payload.building_number = geo.building_number || '';
|
||
payload.building_name = geo.building_name || '';
|
||
payload.trailer_number = geo.building_number || '';
|
||
}
|
||
}
|
||
try {
|
||
const asset = await api('/api/assets', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
showToast('Asset created!');
|
||
document.getElementById('ocrCreateCard').style.display = 'none';
|
||
document.getElementById('ocrResult').style.display = 'none';
|
||
setOcrStatus('Ready — take another photo', 'success');
|
||
AppState.currentAssetId = asset.id;
|
||
doAutoCheckin(asset.id);
|
||
showCheckinForm();
|
||
} catch (e) {
|
||
showToast(e.message, true);
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// MANUAL MODE — Photo
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
let manualPhotoFile = null;
|
||
manualPhotoBlob = null; // reuse existing declaration
|
||
let manualPhotoGps = null;
|
||
|
||
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 submitManualAsset(addAnother) {
|
||
// Disable buttons to prevent double-click
|
||
var submitBtns = document.querySelectorAll('#assetsAddView .btn-primary, #assetsAddView .btn-outline');
|
||
submitBtns.forEach(function(b) { b.disabled = true; b.style.opacity = '0.6'; });
|
||
|
||
const machineId = document.getElementById('manMachineId').value.trim();
|
||
const name = document.getElementById('manName').value.trim();
|
||
if (!machineId || !name) {
|
||
showToast('Machine ID and Asset Name are required', true);
|
||
submitBtns.forEach(function(b) { b.disabled = false; b.style.opacity = ''; });
|
||
return;
|
||
}
|
||
|
||
let photoPath = null;
|
||
if (manualPhotoFile) {
|
||
try {
|
||
const fd = new FormData();
|
||
// Read EXIF before upload (defense against transport stripping)
|
||
try {
|
||
const exifData = await exifr.parse(manualPhotoFile);
|
||
if (exifData && Object.keys(exifData).length > 0) {
|
||
fd.append('exif_data', JSON.stringify(exifData));
|
||
}
|
||
} catch (_) { /* EXIF extraction is best-effort */ }
|
||
fd.append('file', manualPhotoFile, manualPhotoFile.name || 'photo.jpg');
|
||
const upData = await api('/api/upload/photo', { method: 'POST', body: fd });
|
||
photoPath = upData.path;
|
||
// Use server-extracted GPS from raw bytes (bypasses client-side EXIF stripping)
|
||
if (upData.exif_gps) {
|
||
const coords = upData.exif_gps.lat.toFixed(6) + ', ' + upData.exif_gps.lng.toFixed(6);
|
||
if (!document.getElementById('manParkingLocation').value.trim()) {
|
||
document.getElementById('manParkingLocation').value = coords;
|
||
}
|
||
if (!document.getElementById('manMapLink').value.trim()) {
|
||
document.getElementById('manMapLink').value = `https://www.openstreetmap.org/?mlat=${upData.exif_gps.lat}&mlon=${upData.exif_gps.lng}&zoom=17`;
|
||
}
|
||
showToast('📍 GPS extracted from photo!');
|
||
}
|
||
} catch (e) { /* photo upload failed, continue without */ }
|
||
}
|
||
|
||
const makeId = document.getElementById('manMake').value;
|
||
const modelId = document.getElementById('manModel').value;
|
||
const makeName = makeId ? (settingsCache.makes.find(m => m.id == makeId) || {}).name || '' : '';
|
||
const modelName = modelId ? (settingsCache.models.find(m => m.id == modelId) || {}).name || '' : '';
|
||
|
||
// F2: Auto-populate address from GPS (only if address field is empty)
|
||
if (AppState.gpsLat && !document.getElementById('manAddress').value.trim()) {
|
||
const geo = await reverseGeocode(AppState.gpsLat, AppState.gpsLng);
|
||
if (geo) {
|
||
if (!document.getElementById('manAddress').value.trim()) {
|
||
document.getElementById('manAddress').value = geo.address || geo.formatted || '';
|
||
document.getElementById('manBuildingNumber').value = geo.building_number || '';
|
||
document.getElementById('manBuildingName').value = geo.building_name || '';
|
||
}
|
||
}
|
||
}
|
||
|
||
const payload = {
|
||
machine_id: machineId,
|
||
name: name,
|
||
serial_number: document.getElementById('manSerialNumber').value.trim(),
|
||
description: document.getElementById('manDescription').value.trim(),
|
||
category: document.getElementById('manCatSelect').value || 'Other',
|
||
make: makeName,
|
||
model: modelName,
|
||
status: document.getElementById('manStatus').value,
|
||
address: document.getElementById('manAddress').value.trim(),
|
||
building_name: document.getElementById('manBuildingName').value.trim(),
|
||
building_number: document.getElementById('manBuildingNumber').value.trim(),
|
||
floor: document.getElementById('manFloor').value.trim(),
|
||
room: document.getElementById('manRoom').value.trim(),
|
||
trailer_number: document.getElementById('manAddress').value.trim(),
|
||
walking_directions: document.getElementById('manWalkingDirections').value.trim(),
|
||
map_link: document.getElementById('manMapLink').value.trim(),
|
||
parking_location: document.getElementById('manParkingLocation').value.trim(),
|
||
customer_id: parseInt(document.getElementById('manCustomer').value) || null,
|
||
location_id: parseInt(document.getElementById('manLocation').value) || null,
|
||
keys: getKeysData(),
|
||
badges: getCheckedBadges(),
|
||
};
|
||
if (photoPath) payload.photo_path = photoPath;
|
||
|
||
try {
|
||
const asset = await api('/api/assets', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
showToast('Asset created!');
|
||
AppState.currentAssetId = asset.id;
|
||
doAutoCheckin(asset.id);
|
||
|
||
if (addAnother) {
|
||
// Clear form
|
||
document.getElementById('manMachineId').value = '';
|
||
document.getElementById('manSerialNumber').value = '';
|
||
document.getElementById('manName').value = '';
|
||
document.getElementById('manDescription').value = '';
|
||
document.getElementById('manCatSelect').value = '';
|
||
document.getElementById('manMake').value = '';
|
||
document.getElementById('manModel').value = '';
|
||
document.getElementById('manStatus').value = 'active';
|
||
document.getElementById('manAddress').value = '';
|
||
document.getElementById('manBuildingName').value = '';
|
||
document.getElementById('manBuildingNumber').value = '';
|
||
document.getElementById('manFloor').value = '';
|
||
document.getElementById('manRoom').value = '';
|
||
document.getElementById('manWalkingDirections').value = '';
|
||
document.getElementById('manMapLink').value = '';
|
||
document.getElementById('manParkingLocation').value = '';
|
||
document.getElementById('manCustomer').value = '';
|
||
document.getElementById('manLocation').value = '';
|
||
// Reset keys
|
||
document.getElementById('manKeysList').innerHTML = '';
|
||
// Reset badges
|
||
document.querySelectorAll('#manBadgesList .badge-check-item').forEach(b => b.classList.remove('checked'));
|
||
// Reset photo
|
||
manualPhotoBlob = null;
|
||
document.getElementById('manPhotoPreview').style.display = 'none';
|
||
document.getElementById('manPhotoRetake').style.display = 'none';
|
||
document.getElementById('manMachineId').focus();
|
||
} else {
|
||
// Show check-in card
|
||
document.getElementById('manCheckinCard').style.display = 'block';
|
||
document.getElementById('manCheckinNotes').focus();
|
||
document.getElementById('manCheckinCard').scrollIntoView({ behavior: 'smooth' });
|
||
}
|
||
} catch (e) {
|
||
showToast(e.message, true);
|
||
} finally {
|
||
var submitBtns = document.querySelectorAll('#assetsAddView .btn-primary, #assetsAddView .btn-outline');
|
||
submitBtns.forEach(function(b) { b.disabled = false; b.style.opacity = ''; });
|
||
}
|
||
}
|
||
|
||
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 };
|
||
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('');
|
||
} 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');
|
||
|
||
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;
|
||
|
||
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 (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:'\uD83D\uDCCD ' + txt});
|
||
}
|
||
if (assetFilters.assigned_to) tags.push({key:'assigned_to', label:'\uD83D\uDC64 ' + assetFilters.assigned_to});
|
||
if (noDexFilterActive) tags.push({key:'no_dex_days', label:'\u26a0\ufe0f 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 === '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 };
|
||
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 = '';
|
||
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 (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_number) bldParts.push(`Bldg ${esc(a.building_number)}`);
|
||
if (a.trailer_number) bldParts.push(`Trailer ${esc(a.trailer_number)}`);
|
||
if (a.floor) bldParts.push(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','assetsEditView','assetsImportView'].forEach(id => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.style.display = 'none';
|
||
});
|
||
}
|
||
|
||
function showAssetList() {
|
||
hideAllAssetViews();
|
||
document.getElementById('assetsListView').style.display = 'block';
|
||
loadAssets();
|
||
}
|
||
|
||
function showDetailView() {
|
||
hideAllAssetViews();
|
||
document.getElementById('assetsDetailView').style.display = 'block';
|
||
}
|
||
|
||
function showEditView() {
|
||
hideAllAssetViews();
|
||
document.getElementById('assetsEditView').style.display = 'block';
|
||
}
|
||
|
||
function showImportView() {
|
||
hideAllAssetViews();
|
||
document.getElementById('assetsImportView').style.display = 'block';
|
||
// Reset import state
|
||
document.getElementById('importFileInput').value = '';
|
||
document.getElementById('importPreview').style.display = 'none';
|
||
document.getElementById('importResults').style.display = 'none';
|
||
document.getElementById('importRunBtn').disabled = true;
|
||
}
|
||
|
||
// ── Detail view ───────────────────────────────────────────────────────
|
||
function nvl(v, fallback) { return (v !== null && v !== undefined && v !== '') ? v : fallback; }
|
||
|
||
async function viewAsset(id) {
|
||
AppState.currentAssetId = id;
|
||
// 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),
|
||
|
||
].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));
|
||
|
||
// Directions
|
||
const hasDir = a.address || a.building_name || a.building_number || a.floor || a.room || a.walking_directions || a.map_link || a.parking_location;
|
||
const dirCard = document.getElementById('detailDirections');
|
||
const dirBtn = document.getElementById('detailDirectionsBtn');
|
||
if (hasDir) {
|
||
dirCard.style.display = 'block';
|
||
if (a.map_link) dirBtn.style.display = 'inline-flex';
|
||
else dirBtn.style.display = 'none';
|
||
document.getElementById('detailDirFields').innerHTML = [
|
||
df('Address', a.address),
|
||
df('Building', a.building_name),
|
||
df('Building #', a.building_number),
|
||
df('Floor', a.floor),
|
||
df('Room', a.room),
|
||
df('Walking Directions', a.walking_directions),
|
||
df('Map Link', a.map_link ? `<span class="df-link" onclick="openMapLink()">${esc(a.map_link)}</span>` : null),
|
||
df('Parking', a.parking_location),
|
||
].filter(Boolean).join('');
|
||
} else {
|
||
dirCard.style.display = 'none';
|
||
dirBtn.style.display = 'none';
|
||
}
|
||
|
||
// Navigate button — show when asset has GPS and user has GPS
|
||
const navBtn = document.getElementById('detailNavigateBtn');
|
||
if (a.latitude != null && a.longitude != null && AppState.gpsLat != null) {
|
||
navBtn.style.display = 'inline-flex';
|
||
} else {
|
||
navBtn.style.display = 'none';
|
||
}
|
||
|
||
// Keys + Badges
|
||
const keys = a.keys || [];
|
||
const badges = a.badges || (Array.isArray(a.badge_names) ? a.badge_names : []);
|
||
const accessCard = document.getElementById('detailAccess');
|
||
if (keys.length || (typeof badges === 'string' ? badges : (badges || [])).length) {
|
||
accessCard.style.display = 'block';
|
||
let accHtml = '';
|
||
if (keys.length) {
|
||
accHtml += '<div class="detail-field"><div class="df-label">Keys</div>';
|
||
accHtml += '<div class="key-badge-list">' +
|
||
keys.map(k => `<span class="key-tag">🔑 ${esc(k.key_name)}${k.key_type ? ' (' + esc(k.key_type) + ')' : ''}</span>`).join('') +
|
||
'</div></div>';
|
||
}
|
||
const badgeList = Array.isArray(badges) ? badges : (typeof badges === 'string' ? badges.split(',').map(s => s.trim()).filter(Boolean) : []);
|
||
if (badgeList.length) {
|
||
accHtml += '<div class="detail-field"><div class="df-label">Security Badges</div>';
|
||
accHtml += '<div class="key-badge-list">' +
|
||
badgeList.map(b => `<span class="badge-tag">🪪 ${esc(typeof b === 'string' ? b : b.badge_name || b)}</span>`).join('') +
|
||
'</div></div>';
|
||
}
|
||
document.getElementById('detailAccessFields').innerHTML = accHtml;
|
||
} else {
|
||
accessCard.style.display = 'none';
|
||
}
|
||
|
||
// Service Entrances
|
||
await loadServiceEntrances(a);
|
||
|
||
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(() => {});
|
||
}
|
||
|
||
async function navigateToAsset() {
|
||
if (!AppState.currentAssetId) return;
|
||
if (!AppState.gpsLat || !AppState.gpsLng) {
|
||
showToast('GPS location not available. Tap ◎ My Location first.', true);
|
||
return;
|
||
}
|
||
try {
|
||
const nav = await api('/api/assets/' + AppState.currentAssetId +
|
||
'/navigation?lat=' + AppState.gpsLat + '&lng=' + AppState.gpsLng);
|
||
|
||
// Store nav data for dedicated page
|
||
AppState._navData = nav;
|
||
|
||
// Switch to dedicated navigate tab
|
||
switchTab('tabNavigate');
|
||
renderNavPage();
|
||
} catch (e) {
|
||
showToast(e.message, true);
|
||
}
|
||
}
|
||
|
||
let navMap = null;
|
||
|
||
function initNavMap(lat, lng) {
|
||
if (navMap) { navMap.remove(); navMap = null; }
|
||
const container = document.getElementById('navMapContainer');
|
||
navMap = L.map(container, {
|
||
attributionControl: false,
|
||
zoomControl: false,
|
||
dragging: true,
|
||
tap: true,
|
||
}).setView([lat, lng], 15);
|
||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||
maxZoom: 19,
|
||
}).addTo(navMap);
|
||
// Invalidate size after container is visible
|
||
setTimeout(() => { if (navMap) navMap.invalidateSize(); }, 100);
|
||
}
|
||
|
||
function renderNavPage() {
|
||
const nav = AppState._navData;
|
||
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
|
||
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
|
||
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 + '°' : '—';
|
||
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 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</div>';
|
||
return;
|
||
}
|
||
el.innerHTML = checkins.map(c => `
|
||
<div class="checkin-item">
|
||
<div class="ci-time">🕐 ${formatDate(c.created_at)}</div>
|
||
${c.latitude ? '<div class="ci-coords">📍 ' + Number(c.latitude).toFixed(5) + ', ' + Number(c.longitude).toFixed(5) + ' ±' + Math.round(c.accuracy||0) + 'm</div>' : ''}
|
||
${c.notes ? '<div class="ci-notes">📝 ' + esc(c.notes) + '</div>' : ''}
|
||
</div>
|
||
`).join('');
|
||
} catch (e) {
|
||
document.getElementById('checkinHistory').innerHTML =
|
||
'<div class="empty-state">Failed to load check-ins</div>';
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
// ── Edit ──────────────────────────────────────────────────────────────
|
||
async function editAsset() {
|
||
if (!AppState.currentAssetId) return;
|
||
try {
|
||
const a = await api('/api/assets/' + AppState.currentAssetId);
|
||
showEditView();
|
||
document.getElementById('editFormCard').innerHTML = renderEditForm(a);
|
||
// Assigned To user dropdown (users managed via admin app)
|
||
setTimeout(function() {
|
||
var sel = document.getElementById('editAssignedTo');
|
||
if (sel && a.assigned_to) sel.value = a.assigned_to;
|
||
}, 0);
|
||
} catch (e) {
|
||
showToast(e.message, true);
|
||
}
|
||
}
|
||
|
||
function renderEditForm(a) {
|
||
return `
|
||
<div class="card-title">Edit Asset #${a.id}</div>
|
||
<input id="editMachineId" type="text" class="input-field" placeholder="Machine ID *" value="${esc(a.machine_id)}">
|
||
<input id="editName" type="text" class="input-field" placeholder="Asset name *" value="${esc(a.name)}">
|
||
<input id="editSerialNumber" type="text" class="input-field" placeholder="Serial number" value="${esc(a.serial_number || '')}">
|
||
<textarea id="editDesc" class="input-field" placeholder="Telemetry ID" rows="2">${esc(a.description || '')}</textarea>
|
||
<div class="form-row">
|
||
<input id="editCategory" type="text" class="input-field" placeholder="Category" value="${esc(a.category || '')}" list="catList">
|
||
<input id="editMake" type="text" class="input-field" placeholder="Make" value="${esc(a.make || '')}">
|
||
</div>
|
||
<div class="form-row">
|
||
<input id="editModel" type="text" class="input-field" placeholder="Model" value="${esc(a.model || '')}">
|
||
<select id="editStatus" class="input-field">
|
||
<option value="active" ${a.status === 'active' ? 'selected' : ''}>Active</option>
|
||
<option value="maintenance" ${a.status === 'maintenance' ? 'selected' : ''}>Maintenance</option>
|
||
|
||
</select>
|
||
</div>
|
||
<select id="editAssignedTo" class="input-field">
|
||
<option value="">Assigned To (none)</option>
|
||
</select>
|
||
<div class="card-title" style="margin-top:8px;">📍 Directions</div>
|
||
<input id="editAddress" type="text" class="input-field" placeholder="Address" value="${esc(a.address || '')}">
|
||
<div class="form-row">
|
||
<input id="editBuildingName" type="text" class="input-field" placeholder="Building name" value="${esc(a.building_name || '')}">
|
||
<input id="editBuildingNumber" type="text" class="input-field" placeholder="Building #" value="${esc(a.building_number || '')}">
|
||
</div>
|
||
<div class="form-row">
|
||
<input id="editFloor" type="text" class="input-field" placeholder="Floor" value="${esc(a.floor || '')}">
|
||
<input id="editRoom" type="text" class="input-field" placeholder="Room" value="${esc(a.room || '')}">
|
||
</div>
|
||
<input id="editWalkingDirections" type="text" class="input-field" placeholder="Walking directions" value="${esc(a.walking_directions || '')}">
|
||
<input id="editMapLink" type="text" class="input-field" placeholder="Map link (URL)" value="${esc(a.map_link || '')}">
|
||
<div class="form-row">
|
||
<input id="editParking" type="text" class="input-field" placeholder="Parking location" value="${esc(a.parking_location || '')}">
|
||
<button class="btn btn-outline btn-sm" onclick="fillGpsParking('editParking')" style="flex-shrink:0;">📍 GPS</button>
|
||
</div>
|
||
<datalist id="catList">
|
||
<option value="Coffee"><option value="Cold Beverage"><option value="Snacks">
|
||
<option value="Cold Food"><option value="Market"><option value="Smart Cooler">
|
||
</datalist>
|
||
<button class="btn btn-primary" onclick="submitEditAsset()">Save Changes</button>
|
||
`;
|
||
}
|
||
|
||
async function submitEditAsset() {
|
||
if (!AppState.currentAssetId) return;
|
||
const machine_id = document.getElementById('editMachineId').value.trim();
|
||
const name = document.getElementById('editName').value.trim();
|
||
if (!machine_id || !name) {
|
||
showToast('Machine ID and name are required', true);
|
||
return;
|
||
}
|
||
const payload = {
|
||
machine_id, name,
|
||
serial_number: document.getElementById('editSerialNumber').value.trim() || null,
|
||
description: document.getElementById('editDesc').value.trim() || null,
|
||
category: document.getElementById('editCategory').value.trim() || null,
|
||
make: document.getElementById('editMake').value.trim() || null,
|
||
model: document.getElementById('editModel').value.trim() || null,
|
||
status: document.getElementById('editStatus').value,
|
||
address: document.getElementById('editAddress').value.trim() || null,
|
||
building_name: document.getElementById('editBuildingName').value.trim() || null,
|
||
building_number: document.getElementById('editBuildingNumber').value.trim() || null,
|
||
floor: document.getElementById('editFloor').value.trim() || null,
|
||
room: document.getElementById('editRoom').value.trim() || null,
|
||
walking_directions: document.getElementById('editWalkingDirections').value.trim() || null,
|
||
map_link: document.getElementById('editMapLink').value.trim() || null,
|
||
parking_location: document.getElementById('editParking').value.trim() || null,
|
||
assigned_to: document.getElementById('editAssignedTo').value ? parseInt(document.getElementById('editAssignedTo').value) : null,
|
||
};
|
||
try {
|
||
await api('/api/assets/' + AppState.currentAssetId, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
showToast('Asset updated!');
|
||
viewAsset(AppState.currentAssetId);
|
||
} catch (e) {
|
||
showToast(e.message, true);
|
||
}
|
||
}
|
||
|
||
// ── Delete ────────────────────────────────────────────────────────────
|
||
async function deleteAsset() {
|
||
if (!AppState.currentAssetId) return;
|
||
const confirmed = await showModal(
|
||
'Delete Asset',
|
||
'Delete this asset and all its check-ins? This cannot be undone.',
|
||
'Delete'
|
||
);
|
||
if (!confirmed) return;
|
||
try {
|
||
await api('/api/assets/' + AppState.currentAssetId, { method: 'DELETE' });
|
||
showToast('Asset deleted');
|
||
showAssetList();
|
||
} catch (e) {
|
||
showToast(e.message, true);
|
||
}
|
||
}
|
||
|
||
// ── CSV Import ────────────────────────────────────────────────────────
|
||
let importRows = [];
|
||
|
||
document.getElementById('importFileInput').addEventListener('change', function(e) {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = function(ev) {
|
||
const text = ev.target.result;
|
||
const lines = text.split('\n').filter(l => l.trim());
|
||
if (lines.length < 2) {
|
||
showToast('CSV must have a header row and at least one data row', true);
|
||
return;
|
||
}
|
||
// Parse header
|
||
const header = lines[0].split(',').map(h => h.trim().toLowerCase());
|
||
const idx = {
|
||
machine_id: header.indexOf('machine_id'),
|
||
name: header.indexOf('name'),
|
||
category: header.indexOf('category'),
|
||
make: header.indexOf('make'),
|
||
model: header.indexOf('model'),
|
||
serial_number: header.indexOf('serial_number'),
|
||
customer: header.indexOf('customer'),
|
||
location: header.indexOf('location'),
|
||
};
|
||
if (idx.machine_id < 0 || idx.name < 0) {
|
||
showToast('CSV must have at least machine_id and name columns', true);
|
||
return;
|
||
}
|
||
|
||
importRows = [];
|
||
for (let i = 1; i < lines.length; i++) {
|
||
const cols = lines[i].split(',').map(c => c.trim());
|
||
const row = {
|
||
machine_id: cols[idx.machine_id] || '',
|
||
name: cols[idx.name] || '',
|
||
category: idx.category >= 0 ? cols[idx.category] || '' : '',
|
||
make: idx.make >= 0 ? cols[idx.make] || '' : '',
|
||
model: idx.model >= 0 ? cols[idx.model] || '' : '',
|
||
serial_number: idx.serial_number >= 0 ? cols[idx.serial_number] || '' : '',
|
||
customer_name: idx.customer >= 0 ? cols[idx.customer] || '' : '',
|
||
location_name: idx.location >= 0 ? cols[idx.location] || '' : '',
|
||
};
|
||
if (row.machine_id && row.name) importRows.push(row);
|
||
}
|
||
|
||
if (!importRows.length) {
|
||
showToast('No valid rows found (need machine_id + name)', true);
|
||
return;
|
||
}
|
||
|
||
// Show preview
|
||
document.getElementById('importRowCount').textContent = importRows.length + ' rows ready to import';
|
||
document.getElementById('importPreview').style.display = 'block';
|
||
document.getElementById('importRunBtn').disabled = false;
|
||
|
||
// Show preview table (first 10 rows)
|
||
const displayKeys = ['machine_id','name','category','make','model'];
|
||
const thead = '<tr>' + displayKeys.map(k => '<th>' + k + '</th>').join('') + '</tr>';
|
||
const tbody = importRows.slice(0, 10).map(r =>
|
||
'<tr>' + displayKeys.map(k => '<td>' + esc(r[k] || '') + '</td>').join('') + '</tr>'
|
||
).join('');
|
||
if (importRows.length > 10) {
|
||
document.getElementById('importTable').innerHTML =
|
||
thead + '<tfoot style="font-size:11px;color:var(--text2);"><tr><td colspan="' + displayKeys.length + '">... and ' + (importRows.length - 10) + ' more rows</td></tr></tfoot>' + tbody;
|
||
} else {
|
||
document.getElementById('importTable').innerHTML = thead + tbody;
|
||
}
|
||
};
|
||
reader.readAsText(file);
|
||
});
|
||
|
||
async function runImport() {
|
||
if (!importRows.length) return;
|
||
const btn = document.getElementById('importRunBtn');
|
||
btn.disabled = true;
|
||
btn.textContent = 'Importing...';
|
||
|
||
let created = 0, skipped = 0;
|
||
const errors = [];
|
||
|
||
for (let i = 0; i < importRows.length; i++) {
|
||
const r = importRows[i];
|
||
try {
|
||
await api('/api/assets', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
machine_id: r.machine_id,
|
||
name: r.name,
|
||
category: r.category || 'Other',
|
||
make: r.make || '',
|
||
model: r.model || '',
|
||
serial_number: r.serial_number || '',
|
||
}),
|
||
});
|
||
created++;
|
||
} catch (e) {
|
||
if (e.message && e.message.includes('UNIQUE constraint')) {
|
||
skipped++;
|
||
} else {
|
||
errors.push('Row ' + (i + 2) + ' (' + r.machine_id + '): ' + e.message);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Show results
|
||
const resEl = document.getElementById('importResults');
|
||
resEl.style.display = 'block';
|
||
let html = '<div class="import-results">';
|
||
html += '<span class="ir-ok">✓ Created: ' + created + '</span> ';
|
||
html += '<span class="ir-warn">⊘ Skipped (duplicate): ' + skipped + '</span> ';
|
||
html += '<span class="ir-err">✗ Errors: ' + errors.length + '</span>';
|
||
if (errors.length) {
|
||
html += '<div style="margin-top:6px;font-size:11px;max-height:120px;overflow-y:auto;">' +
|
||
errors.map(e => '<div class="ir-err">' + esc(e) + '</div>').join('') + '</div>';
|
||
}
|
||
html += '</div>';
|
||
resEl.innerHTML = html;
|
||
|
||
btn.textContent = 'Done';
|
||
btn.className = 'btn btn-outline btn-sm';
|
||
btn.onclick = () => showAssetList();
|
||
showToast('Import complete: ' + created + ' created, ' + skipped + ' skipped, ' + errors.length + ' errors');
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// MAP PINS
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
async function loadAssetPins() {
|
||
// Store markers on a namespace so we can clear them
|
||
if (!window._assetPins) window._assetPins = [];
|
||
// Clear existing markers
|
||
window._assetPins.forEach(function(m) { if (map) map.removeLayer(m); });
|
||
window._assetPins = [];
|
||
|
||
try {
|
||
const assets = await api('/api/assets?limit=1000');
|
||
assets.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() : '📦');
|
||
// Map pins can't render <img> tags, fall back to emoji
|
||
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);
|
||
}
|
||
});
|
||
// If we have assets with coords, zoom to fit them
|
||
if (window._assetPins.length > 0) {
|
||
var group = L.featureGroup(window._assetPins);
|
||
map.fitBounds(group.getBounds().pad(0.1));
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to load asset pins:', e);
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// 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?limit=1000');
|
||
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 extractGpsFromPicked() {
|
||
if (!pickedPhotoFile) {
|
||
showToast('No photo picked', true);
|
||
return;
|
||
}
|
||
await tryExtractGpsFromPicked();
|
||
}
|
||
|
||
// ▶ Internal: try to extract GPS from the currently picked photo
|
||
async function tryExtractGpsFromPicked() {
|
||
if (!pickedPhotoFile) return;
|
||
const exifEl = document.getElementById('pickedExifData');
|
||
|
||
exifEl.style.display = 'block';
|
||
exifEl.innerHTML = '<span style="color:var(--amber);">⏳ Reading EXIF GPS...</span>';
|
||
|
||
const gps = await extractGpsFromPhoto(pickedPhotoFile);
|
||
pickedPhotoGps = gps;
|
||
|
||
if (gps) {
|
||
exifEl.innerHTML = `
|
||
<div style="display:flex;align-items:center;gap:6px;margin-bottom:8px;">
|
||
<span class="gps-badge ok">📍 GPS from photo</span>
|
||
</div>
|
||
<div style="font-size:13px;color:var(--text2);margin-bottom:4px;">
|
||
${gps.lat.toFixed(6)}, ${gps.lng.toFixed(6)}
|
||
${gps.accuracy ? ` (±${gps.accuracy.toFixed(1)}m)` : ''}
|
||
</div>
|
||
<div id="gpsMiniMap"></div>
|
||
<div style="display:flex;gap:8px;margin-top:8px;">
|
||
<button class="btn btn-sm btn-outline" onclick="fillManualFromGps()" style="flex:1;">
|
||
📝 Fill Manual Form
|
||
</button>
|
||
<button class="btn btn-sm btn-outline" onclick="openInMaps()" style="flex:1;">
|
||
🗺️ Open in Maps
|
||
</button>
|
||
</div>
|
||
<div style="font-size:11px;color:var(--text3);margin-top:4px;">
|
||
Tap "Fill Manual Form" to populate parking & directions, then edit as needed
|
||
</div>
|
||
`;
|
||
// Render mini-map
|
||
const miniMapEl = document.getElementById('gpsMiniMap');
|
||
if (miniMapEl) {
|
||
setTimeout(() => renderGpsPreview(gps.lat, gps.lng, miniMapEl), 100);
|
||
}
|
||
} else {
|
||
exifEl.innerHTML = `
|
||
<span class="gps-badge err">📍 No GPS in photo</span>
|
||
<div style="font-size:12px;color:var(--text3);margin-top:4px;">
|
||
This photo has no GPS data. Phones often strip EXIF GPS when sharing screenshots or from some apps.
|
||
You can manually enter coordinates in the Manual form.
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
// ▶ Fill the manual form fields with extracted GPS
|
||
function fillManualFromGps() {
|
||
if (!pickedPhotoGps) {
|
||
showToast('No GPS data to fill', true);
|
||
return;
|
||
}
|
||
|
||
const coords = pickedPhotoGps.lat.toFixed(6) + ', ' + pickedPhotoGps.lng.toFixed(6);
|
||
|
||
// Switch to manual mode
|
||
setAddAssetMode('manual');
|
||
|
||
// Fill parking location
|
||
const parkingEl = document.getElementById('manParkingLocation');
|
||
if (parkingEl && !parkingEl.value.trim()) {
|
||
parkingEl.value = coords;
|
||
}
|
||
|
||
// Fill map link with OpenStreetMap URL
|
||
const mapLinkEl = document.getElementById('manMapLink');
|
||
if (mapLinkEl && !mapLinkEl.value.trim()) {
|
||
mapLinkEl.value = `https://www.openstreetmap.org/?mlat=${pickedPhotoGps.lat}&mlon=${pickedPhotoGps.lng}&zoom=17`;
|
||
}
|
||
|
||
showToast('📍 GPS coordinates filled! Edit if needed.');
|
||
}
|
||
|
||
// ▶ Open coordinates in Google Maps
|
||
function openInMaps() {
|
||
if (!pickedPhotoGps) return;
|
||
const url = `https://www.google.com/maps?q=${pickedPhotoGps.lat},${pickedPhotoGps.lng}`;
|
||
window.open(url, '_blank');
|
||
}
|
||
|
||
// ▶ Clear picked photo and reset state
|
||
function clearPickedPhoto() {
|
||
pickedPhotoFile = null;
|
||
pickedPhotoGps = null;
|
||
document.getElementById('pickedPhotoPreview').src = '';
|
||
document.getElementById('photoPreviewCard').style.display = 'none';
|
||
document.getElementById('pickedExifData').style.display = 'none';
|
||
document.getElementById('pickedExifData').innerHTML = '';
|
||
// Reset file input so same file can be re-picked
|
||
document.getElementById('photoPicker').value = '';
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// Extract GPS from a photo file using exifr library
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
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) { 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) return alert('Geolocation not available');
|
||
navigator.geolocation.getCurrentPosition(
|
||
pos => rpSetOrigin(pos.coords.latitude, pos.coords.longitude),
|
||
() => alert('Could not get location')
|
||
);
|
||
}
|
||
|
||
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) { return alert('Select at least one technician'); }
|
||
|
||
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();
|
||
return alert('No work orders found for today');
|
||
}
|
||
const woIds = data.workorders.map(wo => wo.name);
|
||
await rpOptimizeRoute(woIds, data.workorders);
|
||
} catch (e) {
|
||
rpHideLoading();
|
||
alert('Error loading today\u2019s route: ' + e.message);
|
||
}
|
||
}
|
||
|
||
// ── Plan from Paste ───────────────────────────────────────────────────
|
||
function rpPlanFromPaste() {
|
||
const raw = document.getElementById('rpWoInput').value.trim();
|
||
if (!raw) return alert('Paste some work order numbers first');
|
||
const ids = raw.split(/[,\n\r]+/).map(s => s.trim()).filter(Boolean);
|
||
if (ids.length === 0) return alert('No valid work order numbers found');
|
||
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();
|
||
return alert('Error looking up work orders: ' + e.message);
|
||
}
|
||
}
|
||
|
||
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();
|
||
alert('Error optimizing route: ' + e.message);
|
||
}
|
||
}
|
||
|
||
// ── 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', 2000); }
|
||
}).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;
|
||
}
|
||
}
|
||
|
||
// ── Helper ────────────────────────────────────────────────────────────
|
||
function esc(s) {
|
||
if (!s) return '';
|
||
const div = document.createElement('div');
|
||
div.textContent = s;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
initAuth();
|
||
initGPS();
|
||
});
|
||
|
||
</script>
|
||
</body>
|
||
</html>
|