Files
canteen-asset-tracker/static/index.html
T
shawn faacf0bce9 Rename 'Add Asset' tab to 'Add / Find Asset' across nav + tabs
- Drawer nav: 'Add Asset' → 'Add / Find Asset'
- Bottom tab bar: 'Add Asset' → 'Add / Find Asset'
- Updated e2e_browser_test.py assertions to match
2026-05-25 23:14:52 -04:00

4736 lines
223 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Canteen Asset Tracker</title>
<script src="https://cdn.jsdelivr.net/npm/@zxing/library@0.20.0/umd/index.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js"></script>
<!-- Leaflet Map -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<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 2px 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); } }
/* ═══════════════════════════════════════════════════════════════════════
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; }
</style>
</head>
<body>
<!-- ═══════════════════════════════════════════════════════════════════════
HEADER
═══════════════════════════════════════════════════════════════════════ -->
<div class="header">
<button class="hamburger" onclick="openDrawer()" aria-label="Menu"></button>
<h1>📦 Canteen Assets</h1>
<div class="header-badges">
<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;">📍 Tap GPS</span>
<span id="queueIndicator" class="queue-indicator" onclick="processOfflineQueue()" title="Offline queue">⬇️ <span id="queueIndicatorCount">0</span></span>
<div class="user-badge" id="userBadge" onclick="openDrawer()" title="User">?</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> Asset List
</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> Navigate
</button>
<button class="dn-item" id="logoutBtn" onclick="doLogout()" style="display:none;">
<span class="dn-icon">🚪</span> Logout
</button>
</nav>
<div class="drawer-footer">Canteen Asset Tracker v3.0<a href="https://admin.canteen.ourpad.casa" target="_blank" rel="noopener" style="display:block;margin-top:4px;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 &amp; 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 &amp; 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;">Create Asset</button>
<button class="btn btn-outline" onclick="submitManualAsset(true)" style="flex:1;">+ 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()"></button>
</div>
<div style="display:flex;gap:6px;align-items:flex-start;margin-bottom:8px;">
<button class="filter-toggle" id="filterToggle" onclick="toggleFilterPanel()">
🎯 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;">📥 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()" disabled>
<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()"></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 &amp; Access</div>
<div id="detailDirFields"></div>
</div>
<!-- Keys & Badges -->
<div class="card" id="detailAccess" style="display:none;">
<div class="card-title">🔑 Access</div>
<div id="detailAccessFields"></div>
</div>
<!-- Service Entrances -->
<div class="card" id="detailServiceEntrances" style="display:none;">
<div class="card-title">🚚 Service Entrances</div>
<div id="detailSEFields"></div>
</div>
<!-- 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()"></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 &amp; 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()"></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>
<!-- ═══════════════════════════════════════════════════════════════════════
BOTTOM TABS
═══════════════════════════════════════════════════════════════════════ -->
<div class="tabs">
<button class="tab-btn active" data-tab="tabAddAsset" onclick="switchTab('tabAddAsset')">
<span class="tab-icon">📷</span> Add / Find Asset
</button>
<button class="tab-btn" data-tab="tabAssets" onclick="switchTab('tabAssets')">
<span class="tab-icon">📦</span> Assets
</button>
<button class="tab-btn" data-tab="tabMap" onclick="switchTab('tabMap')">
<span class="tab-icon">🗺️</span> Map
</button>
<button class="tab-btn" data-tab="tabNavigate" onclick="switchTab('tabNavigate')">
<span class="tab-icon">🧭</span> Nav
</button>
<button class="tab-btn" data-tab="tabMore" onclick="openDrawer()">
<span class="tab-icon"></span> More
</button>
</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" 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');
if (!username || !password) {
errEl.textContent = 'Please enter username and password';
errEl.classList.add('show');
return;
}
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');
}
}
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();
}
}
// Allow Enter key to submit login
document.addEventListener('keydown', function(e) {
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'), 2000);
}
// =========================================================================
// 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;
}
}
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',
};
const bottomTab = tabMap[tabId] || 'tabMore';
document.querySelectorAll('.tab-btn').forEach(b => {
b.classList.toggle('active', b.dataset.tab === bottomTab);
});
// Update drawer highlight
document.querySelectorAll('.drawer-nav .dn-item').forEach(el => {
el.classList.toggle('active', el.dataset.tab === tabId);
});
// Tab lifecycle hooks
if (tabId === 'tabAddAsset') startScanning();
else stopScanning();
if (tabId === 'tabAssets') { loadAssets(); }
// Map tab lifecycle
if (tabId === 'tabMap') {
if (!map) {
const tileUrl = document.querySelector('meta[name="map-tile-url"]')?.content
|| 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
const tileAttr = document.querySelector('meta[name="map-tile-attribution"]')?.content
|| '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>';
map = L.map('mapContainer', {
center: [39.8283, -98.5795],
zoom: 4,
zoomControl: true,
});
L.tileLayer(tileUrl, { attribution: tileAttr, maxZoom: 19 }).addTo(map);
// 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
}
// Dispatch event so child tab implementations can hook in
document.dispatchEvent(new CustomEvent('tabChange', { detail: { tabId } }));
}
// =========================================================================
// EVENT DELEGATION
// =========================================================================
// Global click handler for dynamic content
document.addEventListener('click', function(e) {
const el = e.target.closest('[data-action]');
if (!el) return;
const action = el.dataset.action;
// Child tabs register handlers via AppState.actions or direct listeners
const handler = AppState._actions && AppState._actions[action];
if (handler) {
e.preventDefault();
handler(el, e);
}
});
// Register an action handler for event delegation
function registerAction(name, handler) {
if (!AppState._actions) AppState._actions = {};
AppState._actions[name] = handler;
}
// =========================================================================
// Helpers
// =========================================================================
function esc(s) {
if (!s) return '';
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
function cssSafe(s) {
if (!s) return 'Other';
return s.replace(/&amp;/g, '&').replace(/[^a-zA-Z0-9]/g, '-');
}
function formatDate(iso) {
if (!iso) return '—';
try {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
+ ' ' + d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
} catch (e) { return iso; }
}
function timeAgo(iso) {
if (!iso) return '';
try {
const d = new Date(iso);
const now = new Date();
const diff = Math.floor((now - d) / 1000);
if (diff < 60) return 'just now';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
return Math.floor(diff / 86400) + 'd ago';
} catch (e) { return iso; }
}
// ═══════════════════════════════════════════════════════════════════════
// ADD ASSET TAB — 3 Modes: Barcode, OCR, Manual
// ═══════════════════════════════════════════════════════════════════════
let addAssetMode = 'barcode';
let stream = null;
let ocrStream = null;
let codeReader = null;
let scanningActive = false;
let currentScannedMachineId = null;
let barcodeDebounce = null;
let manualPhotoBlob = null;
let manKeysCounter = 0;
let settingsCache = {};
let map = null;
let heatLayer = null;
let heatVisible = false;
// ── Mode Switching ──────────────────────────────────────────────────────
function setAddAssetMode(mode) {
addAssetMode = mode;
document.querySelectorAll('.mode-toggle').forEach(b =>
b.classList.toggle('active', b.dataset.mode === mode));
document.querySelectorAll('.add-mode').forEach(p =>
p.classList.toggle('active', p.id === 'add' + mode.charAt(0).toUpperCase() + mode.slice(1) + 'Mode'));
// Stop any running scanners
stopScanning();
stopOcrCamera();
if (typeof stopManualPhoto === 'function') stopManualPhoto();
if (mode === 'barcode') {
startScanning();
}
}
// ── 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) {
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);
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);
}
}
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;
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) {
// silent fail — dropdowns just show defaults
}
}
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) {}
}
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 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();
var countEl = document.getElementById('resultCount');
countEl.textContent = assetTotal + ' asset' + (assetTotal !== 1 ? 's' : '') + ' found';
} catch (e) {
document.getElementById('assetList').innerHTML =
'<div class="empty-state">Failed to load assets</div>';
}
};
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</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;
try {
const a = await api('/api/assets/' + id);
showDetailView();
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> &nbsp; ';
html += '<span class="ir-warn">⊘ Skipped (duplicate): ' + skipped + '</span> &nbsp; ';
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
document.addEventListener('DOMContentLoaded', () => {
initAuth();
initGPS();
});
</script>
</body>
</html>