13bf61d856
Adds scripts/extract_asset_location_data.py which: - Extracts floor, building_number, room, trailer_number, building_name from names - Detects and removes duplicated name segments - Updates both assets (1848) and locations (364) tables - Dry-run mode by default, --apply to write changes
4850 lines
226 KiB
HTML
4850 lines
226 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||
<title>Canteen Asset Tracker</title>
|
||
<script src="https://cdn.jsdelivr.net/npm/@zxing/library@0.20.0/umd/index.min.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js"></script>
|
||
<!-- Leaflet Map -->
|
||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||
<link rel="stylesheet" href="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.css" />
|
||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||
<script src="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.js"></script>
|
||
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/exifr@7/dist/lite.umd.js"></script>
|
||
<style>
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
DESIGN TOKENS
|
||
═══════════════════════════════════════════════════════════════════════ */
|
||
:root {
|
||
--bg: #0d0e12;
|
||
--card: #161820;
|
||
--card2: #1c1e28;
|
||
--border: #252830;
|
||
--border2: #2e3140;
|
||
--text: #e4e4e7;
|
||
--text2: #8b8fa3;
|
||
--text3: #5b5f73;
|
||
--accent: #5b6ef7;
|
||
--accent2: #7c8cf8;
|
||
--accent-bg: #141a3a;
|
||
--green: #4ade80;
|
||
--green-bg: #1a2e1a;
|
||
--red: #f87171;
|
||
--red-bg: #2e1a1a;
|
||
--amber: #fbbf24;
|
||
--amber-bg: #2a2510;
|
||
--radius: 14px;
|
||
--radius-sm: 10px;
|
||
--radius-xs: 6px;
|
||
--tab-height: 64px;
|
||
--header-height: 52px;
|
||
--drawer-width: 280px;
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
RESET & BASE
|
||
═══════════════════════════════════════════════════════════════════════ */
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
html { overflow-x: hidden; -webkit-text-size-adjust: 100%; }
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
max-width: 480px;
|
||
margin: 0 auto;
|
||
min-height: 100dvh;
|
||
overflow-x: hidden;
|
||
padding-top: calc(var(--header-height) + 8px);
|
||
padding-bottom: calc(var(--tab-height) + 20px);
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
HEADER
|
||
═══════════════════════════════════════════════════════════════════════ */
|
||
.header {
|
||
position: fixed; top: 0; left: 50%; transform: translateX(-50%);
|
||
width: 100%; max-width: 480px; height: var(--header-height);
|
||
display: flex; align-items: center; gap: 10px;
|
||
padding: 0 12px;
|
||
background: var(--card);
|
||
border-bottom: 1px solid var(--border);
|
||
z-index: 200;
|
||
}
|
||
.header .hamburger {
|
||
width: 36px; height: 36px; border: none; background: transparent;
|
||
color: var(--text); font-size: 22px; cursor: pointer;
|
||
display: flex; align-items: center; justify-content: center;
|
||
border-radius: var(--radius-sm);
|
||
-webkit-tap-highlight-color: transparent;
|
||
flex-shrink: 0;
|
||
}
|
||
.header .hamburger:active { background: var(--border); }
|
||
.header h1 {
|
||
font-size: 16px; font-weight: 700; flex: 1;
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
}
|
||
.header .header-badges {
|
||
display: flex; align-items: center; gap: 6px; flex-shrink: 0;
|
||
}
|
||
.gps-badge {
|
||
display: inline-flex; align-items: center; gap: 3px;
|
||
font-size: 10px; font-weight: 600; padding: 4px 8px; border-radius: 20px;
|
||
white-space: nowrap; transition: 0.2s;
|
||
}
|
||
.gps-badge.ok { background: var(--green-bg); color: var(--green); }
|
||
.gps-badge.waiting{ background: var(--amber-bg); color: var(--amber); }
|
||
.gps-badge.err { background: var(--red-bg); color: var(--red); }
|
||
.gps-badge.waiting:hover,
|
||
.gps-badge.err:hover { filter: brightness(1.15); }
|
||
.gps-badge.waiting { animation: pulse-gps 2s ease-in-out infinite; }
|
||
@keyframes pulse-gps {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.65; }
|
||
}
|
||
.user-badge {
|
||
width: 30px; height: 30px; border-radius: 50%;
|
||
background: var(--accent-bg); color: var(--accent2);
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 13px; font-weight: 700; cursor: pointer;
|
||
border: 1.5px solid var(--border);
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
DRAWER OVERLAY & PANEL
|
||
═══════════════════════════════════════════════════════════════════════ */
|
||
.drawer-overlay {
|
||
position: fixed; inset: 0; background: rgba(0,0,0,0.55);
|
||
z-index: 2000; opacity: 0; pointer-events: none;
|
||
transition: opacity 0.25s;
|
||
}
|
||
.drawer-overlay.open { opacity: 1; pointer-events: auto; }
|
||
.drawer {
|
||
position: fixed; top: 0; left: 0; bottom: 0;
|
||
width: var(--drawer-width); max-width: 85vw;
|
||
background: var(--card); z-index: 2001;
|
||
transform: translateX(-100%);
|
||
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||
display: flex; flex-direction: column;
|
||
border-right: 1px solid var(--border);
|
||
box-shadow: 4px 0 20px rgba(0,0,0,0.4);
|
||
}
|
||
.drawer.open { transform: translateX(0); }
|
||
.drawer-header {
|
||
display: flex; align-items: center; gap: 10px;
|
||
padding: 14px 16px; border-bottom: 1px solid var(--border);
|
||
min-height: var(--header-height);
|
||
}
|
||
.drawer-header .dh-title {
|
||
font-size: 17px; font-weight: 700; flex: 1;
|
||
}
|
||
.drawer-header .close-drawer {
|
||
width: 32px; height: 32px; border: none; background: transparent;
|
||
color: var(--text2); font-size: 20px; cursor: pointer;
|
||
display: flex; align-items: center; justify-content: center;
|
||
border-radius: var(--radius-xs);
|
||
}
|
||
.drawer-header .close-drawer:active { background: var(--border); }
|
||
.drawer-user {
|
||
display: flex; align-items: center; gap: 12px;
|
||
padding: 14px 16px; border-bottom: 1px solid var(--border);
|
||
}
|
||
.drawer-user .du-avatar {
|
||
width: 40px; height: 40px; border-radius: 50%;
|
||
background: var(--accent-bg); color: var(--accent2);
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 18px; font-weight: 700; flex-shrink: 0;
|
||
}
|
||
.drawer-user .du-info { flex: 1; min-width: 0; }
|
||
.drawer-user .du-name { font-weight: 600; font-size: 14px; }
|
||
.drawer-user .du-role {
|
||
font-size: 11px; color: var(--text2); text-transform: capitalize;
|
||
}
|
||
.drawer-nav { flex: 1; overflow-y: auto; padding: 8px 0; }
|
||
.drawer-nav .dn-item {
|
||
display: flex; align-items: center; gap: 12px;
|
||
padding: 13px 16px; color: var(--text); font-size: 15px;
|
||
cursor: pointer; transition: background 0.1s;
|
||
border: none; background: transparent; width: 100%;
|
||
text-align: left; font-weight: 500;
|
||
-webkit-tap-highlight-color: transparent;
|
||
}
|
||
.drawer-nav .dn-item:active, .drawer-nav .dn-item.active {
|
||
background: rgba(255,255,255,0.04);
|
||
}
|
||
.drawer-nav .dn-item .dn-icon { font-size: 20px; width: 28px; text-align: center; flex-shrink: 0; }
|
||
.drawer-nav .dn-item .dn-badge {
|
||
margin-left: auto; font-size: 10px; font-weight: 700;
|
||
padding: 2px 8px; border-radius: 10px;
|
||
background: var(--accent-bg); color: var(--accent2);
|
||
}
|
||
.drawer-footer {
|
||
padding: 12px 16px; border-top: 1px solid var(--border);
|
||
font-size: 12px; color: var(--text3);
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
TAB PANELS
|
||
═══════════════════════════════════════════════════════════════════════ */
|
||
.tab-panel { display: none; padding: 0 12px; }
|
||
.tab-panel.active { display: block; }
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
BOTTOM TABS
|
||
═══════════════════════════════════════════════════════════════════════ */
|
||
.tabs {
|
||
position: fixed; bottom: 0; left: 50%; transform: translateX(-50%);
|
||
width: 100%; max-width: 480px;
|
||
display: flex; background: var(--card); border-top: 1px solid var(--border);
|
||
z-index: 100;
|
||
}
|
||
.tab-btn {
|
||
flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||
gap: 2px; padding: 8px 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: center; gap: 12px; padding: 12px;
|
||
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: 42px; height: 42px; border-radius: var(--radius-sm); display: flex;
|
||
align-items: center; justify-content: center; font-size: 20px; flex-shrink: 0;
|
||
}
|
||
.asset-item .ai-icon.ai-icon-wide {
|
||
width: 64px; padding: 0 4px;
|
||
}
|
||
.asset-item .ai-icon .cat-icon-img { width: 28px; height: 28px; 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; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.asset-item .ai-meta { font-size: 12px; color: var(--text2); margin-top: 2px; }
|
||
.asset-item .ai-arrow { color: var(--text2); font-size: 18px; }
|
||
|
||
/* 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 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 & Access</div>
|
||
<input id="manAddress" type="text" class="input-field" placeholder="Address / Trailer Number">
|
||
<div class="form-row">
|
||
<input id="manBuildingName" type="text" class="input-field" placeholder="Building Name">
|
||
<input id="manBuildingNumber" type="text" class="input-field" placeholder="Building #" oninput="document.getElementById('manAddress').value = this.value">
|
||
</div>
|
||
<div class="form-row">
|
||
<input id="manFloor" type="text" class="input-field" placeholder="Floor">
|
||
<input id="manRoom" type="text" class="input-field" placeholder="Room">
|
||
</div>
|
||
<textarea id="manWalkingDirections" class="input-field" placeholder="Walking directions..." rows="2"></textarea>
|
||
<div class="form-row">
|
||
<input id="manMapLink" type="url" class="input-field" placeholder="Map link (URL)">
|
||
<button class="btn btn-outline btn-sm" onclick="openMapPin()" style="flex-shrink:0;">📍 Pin</button>
|
||
</div>
|
||
<div class="form-row">
|
||
<input id="manParkingLocation" type="text" class="input-field" placeholder="Parking location">
|
||
<button class="btn btn-outline btn-sm" onclick="fillGpsParking('manParkingLocation')" style="flex-shrink:0;">📍 GPS</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Keys -->
|
||
<div class="form-section">
|
||
<div class="form-section-title">
|
||
🔑 Keys
|
||
<button class="btn btn-outline btn-sm" onclick="addKeyEntry()">+ Add Key</button>
|
||
</div>
|
||
<div id="manKeysList"></div>
|
||
</div>
|
||
|
||
<!-- Security Badges -->
|
||
<div class="form-section">
|
||
<div class="form-section-title">🪪 Security Badges</div>
|
||
<div class="badge-checklist" id="manBadgesList"></div>
|
||
</div>
|
||
|
||
<!-- Customer & Location -->
|
||
<div class="form-section">
|
||
<div class="form-section-title">🏢 Customer & Location</div>
|
||
<select id="manCustomer" class="input-field" onchange="loadManualLocations()">
|
||
<option value="">Customer</option>
|
||
</select>
|
||
<select id="manLocation" class="input-field" style="margin-bottom:0;">
|
||
<option value="">Location</option>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- Photo -->
|
||
<div class="form-section">
|
||
<div class="form-section-title">📸 Photo (optional)</div>
|
||
<div id="manPhotoArea" class="camera-area" style="aspect-ratio:4/3;margin-bottom:8px;overflow:visible;">
|
||
<div id="manPhotoPlaceholder" class="camera-placeholder">
|
||
<div style="display:flex;flex-direction:column;gap:10px;align-items:center;">
|
||
<button class="btn btn-outline" onclick="document.getElementById('manPhotoInput').click()" style="width:100%;padding:14px;font-size:15px;">📁 Choose from Gallery</button>
|
||
<div style="font-size:11px;color:var(--text2);">(preserves EXIF/GPS)</div>
|
||
<div style="width:80%;height:1px;background:var(--border);margin:4px 0;"></div>
|
||
<button class="btn btn-outline" onclick="openManualCamera()" style="width:100%;padding:14px;font-size:15px;">📸 Take Photo</button>
|
||
<div style="font-size:11px;color:var(--text2);">(quick capture, no EXIF)</div>
|
||
</div>
|
||
</div>
|
||
<input type="file" id="manPhotoInput" accept="image/*" style="display:none;" onchange="handleManualPhotoFile(event)">
|
||
</div>
|
||
<div id="manPhotoCaptureRow" style="display:none;justify-content:center;margin-bottom:8px;">
|
||
<button class="ocr-capture-btn" onclick="openManualCamera()" title="Capture" style="position:static;width:48px;height:48px;font-size:18px;">📸</button>
|
||
</div>
|
||
<img id="manPhotoPreview" style="display:none;width:100%;border-radius:var(--radius-sm);">
|
||
<button id="manPhotoRetake" class="btn btn-outline btn-sm" style="display:none;margin-top:6px;width:100%;" onclick="retakeManualPhoto()">🔄 Retake</button>
|
||
</div>
|
||
|
||
<!-- Submit -->
|
||
<div class="form-row" style="margin-bottom:10px;">
|
||
<button class="btn btn-primary" onclick="submitManualAsset(false)" style="flex:2;">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 & 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 & 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>
|
||
<!-- Auto-visit status indicator -->
|
||
<div id="visitTracker" style="display:none;margin-top:6px;padding:8px 12px;background:var(--green-bg);border-radius:var(--radius-sm);font-size:12px;color:var(--green);">
|
||
📍 Near <span id="vtAssetName">—</span> · <span id="vtTimer">0 min</span>
|
||
</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 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
|
||
|| '© <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
|
||
startVisitTracking();
|
||
} else {
|
||
stopVisitTracking();
|
||
}
|
||
|
||
// Dispatch event so child tab implementations can hook in
|
||
document.dispatchEvent(new CustomEvent('tabChange', { detail: { tabId } }));
|
||
}
|
||
|
||
// =========================================================================
|
||
// EVENT DELEGATION
|
||
// =========================================================================
|
||
// Global click handler for dynamic content
|
||
document.addEventListener('click', function(e) {
|
||
const el = e.target.closest('[data-action]');
|
||
if (!el) return;
|
||
const action = el.dataset.action;
|
||
// Child tabs register handlers via AppState.actions or direct listeners
|
||
const handler = AppState._actions && AppState._actions[action];
|
||
if (handler) {
|
||
e.preventDefault();
|
||
handler(el, e);
|
||
}
|
||
});
|
||
|
||
// Register an action handler for event delegation
|
||
function registerAction(name, handler) {
|
||
if (!AppState._actions) AppState._actions = {};
|
||
AppState._actions[name] = handler;
|
||
}
|
||
|
||
// =========================================================================
|
||
// Helpers
|
||
// =========================================================================
|
||
function esc(s) {
|
||
if (!s) return '';
|
||
const div = document.createElement('div');
|
||
div.textContent = s;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
function cssSafe(s) {
|
||
if (!s) return 'Other';
|
||
return s.replace(/&/g, '&').replace(/[^a-zA-Z0-9]/g, '-');
|
||
}
|
||
|
||
function formatDate(iso) {
|
||
if (!iso) return '—';
|
||
try {
|
||
const d = new Date(iso);
|
||
if (isNaN(d.getTime())) return iso;
|
||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||
+ ' ' + d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
|
||
} catch (e) { return iso; }
|
||
}
|
||
|
||
function timeAgo(iso) {
|
||
if (!iso) return '';
|
||
try {
|
||
const d = new Date(iso);
|
||
const now = new Date();
|
||
const diff = Math.floor((now - d) / 1000);
|
||
if (diff < 60) return 'just now';
|
||
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
|
||
return Math.floor(diff / 86400) + 'd ago';
|
||
} catch (e) { return iso; }
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// ADD ASSET TAB — 3 Modes: Barcode, OCR, Manual
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
let addAssetMode = 'barcode';
|
||
let stream = null;
|
||
let ocrStream = null;
|
||
let codeReader = null;
|
||
let scanningActive = false;
|
||
let currentScannedMachineId = null;
|
||
let barcodeDebounce = null;
|
||
let manualPhotoBlob = null;
|
||
let manKeysCounter = 0;
|
||
let settingsCache = {};
|
||
let map = null;
|
||
let heatLayer = null;
|
||
let heatVisible = false;
|
||
|
||
// ── Mode Switching ──────────────────────────────────────────────────────
|
||
function setAddAssetMode(mode) {
|
||
addAssetMode = mode;
|
||
document.querySelectorAll('.mode-toggle').forEach(b =>
|
||
b.classList.toggle('active', b.dataset.mode === mode));
|
||
document.querySelectorAll('.add-mode').forEach(p =>
|
||
p.classList.toggle('active', p.id === 'add' + mode.charAt(0).toUpperCase() + mode.slice(1) + 'Mode'));
|
||
|
||
// Stop any running scanners
|
||
stopScanning();
|
||
stopOcrCamera();
|
||
if (typeof stopManualPhoto === 'function') stopManualPhoto();
|
||
|
||
if (mode === 'barcode') {
|
||
startScanning();
|
||
}
|
||
}
|
||
|
||
// ── Photo Picker / Gallery ────────────────────────────────────────────────
|
||
let pickedPhotoFile = null;
|
||
|
||
// Wire the hidden file input
|
||
(function() {
|
||
const pp = document.getElementById('photoPicker');
|
||
if (pp) {
|
||
pp.addEventListener('change', function(e) {
|
||
if (e.target.files && e.target.files[0]) {
|
||
handlePickedPhoto(e.target.files[0]);
|
||
}
|
||
});
|
||
}
|
||
})();
|
||
|
||
async function handlePickedPhoto(file) {
|
||
pickedPhotoFile = file;
|
||
const reader = new FileReader();
|
||
reader.onload = async function(e) {
|
||
document.getElementById('pickedPhotoPreview').src = e.target.result;
|
||
document.getElementById('photoPreviewCard').style.display = 'block';
|
||
|
||
// Extract EXIF / file metadata
|
||
await extractExifData(file);
|
||
|
||
// Auto-extract GPS from the picked photo
|
||
tryExtractGpsFromPicked();
|
||
|
||
// Scroll to preview
|
||
document.getElementById('photoPreviewCard').scrollIntoView({ behavior: 'smooth' });
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
|
||
async function extractExifData(file) {
|
||
const exifDiv = document.getElementById('pickedExifData');
|
||
const rows = [];
|
||
rows.push({ label: '📄 File name', value: file.name });
|
||
rows.push({ label: '📦 File size', value: formatFileSize(file.size) });
|
||
rows.push({ label: '🏷️ Type', value: file.type || 'unknown' });
|
||
rows.push({ label: '🕐 Modified', value: formatDate(new Date(file.lastModified).toISOString()) });
|
||
|
||
// Try to read actual EXIF tags from the file
|
||
try {
|
||
const exifData = await exifr.parse(file);
|
||
if (exifData) {
|
||
if (exifData.Make || exifData.Model) {
|
||
rows.push({ label: '📷 Camera', value: [exifData.Make, exifData.Model].filter(Boolean).join(' ') });
|
||
}
|
||
if (exifData.DateTimeOriginal) {
|
||
const d = new Date(exifData.DateTimeOriginal);
|
||
rows.push({ label: '📅 Date taken', value: d.toLocaleString() });
|
||
}
|
||
if (exifData.ISO) {
|
||
rows.push({ label: '🎯 ISO', value: String(exifData.ISO) });
|
||
}
|
||
if (exifData.FNumber) {
|
||
rows.push({ label: '🔆 Aperture', value: 'f/' + exifData.FNumber });
|
||
}
|
||
if (exifData.ExposureTime) {
|
||
rows.push({ label: '⏱️ Shutter', value: exifData.ExposureTime + 's' });
|
||
}
|
||
if (exifData.FocalLength) {
|
||
rows.push({ label: '🔭 Focal', value: exifData.FocalLength + 'mm' });
|
||
}
|
||
if (exifData.GPSLatitude && exifData.GPSLongitude) {
|
||
const lat = exifData.latitude || exifData.GPSLatitude;
|
||
const lng = exifData.longitude || exifData.GPSLongitude;
|
||
rows.push({ label: '📍 GPS', value: typeof lat === 'number' ? `${lat.toFixed(6)}, ${lng.toFixed(6)}` : `${exifData.GPSLatitude.join(' ')} ${exifData.GPSLatitudeRef}, ${exifData.GPSLongitude.join(' ')} ${exifData.GPSLongitudeRef}` });
|
||
}
|
||
}
|
||
} catch (_) { /* EXIF display is best-effort */ }
|
||
|
||
exifDiv.innerHTML = rows.map(r =>
|
||
`<div class="exif-row"><span class="exif-label">${esc(r.label)}</span><span class="exif-value">${esc(r.value)}</span></div>`
|
||
).join('');
|
||
exifDiv.style.display = 'block';
|
||
}
|
||
|
||
function formatFileSize(bytes) {
|
||
if (bytes < 1024) return bytes + ' B';
|
||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||
return (bytes / 1048576).toFixed(1) + ' MB';
|
||
}
|
||
|
||
async function ocrPickedPhoto() {
|
||
if (!pickedPhotoFile) return;
|
||
// Switch to OCR mode
|
||
setAddAssetMode('ocr');
|
||
|
||
// Read EXIF data from the picked photo (defense against transport stripping)
|
||
let exifDataStr = null;
|
||
try {
|
||
const exifData = await exifr.parse(pickedPhotoFile);
|
||
if (exifData && Object.keys(exifData).length > 0) {
|
||
exifDataStr = JSON.stringify(exifData);
|
||
}
|
||
} catch (_) { /* EXIF is best-effort */ }
|
||
|
||
// Build form data with file + EXIF
|
||
const fd = new FormData();
|
||
fd.append('file', pickedPhotoFile, pickedPhotoFile.name || 'photo.jpg');
|
||
if (exifDataStr) {
|
||
fd.append('exif_data', exifDataStr);
|
||
}
|
||
|
||
setOcrStatus('Processing OCR...', 'working');
|
||
document.getElementById('ocrResult').style.display = 'none';
|
||
document.getElementById('ocrCreateCard').style.display = 'none';
|
||
|
||
let data = null;
|
||
try {
|
||
const res = await fetch('/api/ocr', {
|
||
method: 'POST',
|
||
body: fd,
|
||
headers: AppState.authToken
|
||
? { 'Authorization': 'Bearer ' + AppState.authToken }
|
||
: {},
|
||
});
|
||
if (res.ok) {
|
||
data = await res.json();
|
||
data.method = 'server';
|
||
} else {
|
||
throw new Error('Server returned ' + res.status);
|
||
}
|
||
} catch (e) {
|
||
setOcrStatus('Server OCR failed: ' + e.message, 'error');
|
||
return;
|
||
}
|
||
|
||
displayOcrResult(data);
|
||
}
|
||
|
||
async function createAssetFromPicked() {
|
||
if (!pickedPhotoFile) return;
|
||
// Switch to Manual mode and pass the original file (preserves EXIF)
|
||
setAddAssetMode('manual');
|
||
|
||
// Pass the original File directly — preserves EXIF for server re-embed
|
||
manualPhotoFile = pickedPhotoFile;
|
||
manualPhotoBlob = pickedPhotoFile; // Use original file (preserves EXIF)
|
||
|
||
// Show the photo in the manual preview using FileReader
|
||
const reader = new FileReader();
|
||
reader.onload = function(e) {
|
||
const img = document.getElementById('manPhotoPreview');
|
||
img.src = e.target.result;
|
||
img.style.display = 'block';
|
||
const ph = document.getElementById('manPhotoPlaceholder');
|
||
if (ph) ph.style.display = 'none';
|
||
const retake = document.getElementById('manPhotoRetake');
|
||
if (retake) retake.style.display = 'block';
|
||
};
|
||
reader.readAsDataURL(pickedPhotoFile);
|
||
|
||
// Try EXIF GPS from the original file
|
||
manualPhotoGps = await extractGpsFromPhoto(pickedPhotoFile);
|
||
if (manualPhotoGps) {
|
||
const coords = manualPhotoGps.lat.toFixed(6) + ', ' + manualPhotoGps.lng.toFixed(6);
|
||
const parkingEl = document.getElementById('manParkingLocation');
|
||
if (parkingEl && !parkingEl.value.trim()) {
|
||
parkingEl.value = coords;
|
||
}
|
||
const mapLinkEl = document.getElementById('manMapLink');
|
||
if (mapLinkEl && !mapLinkEl.value.trim()) {
|
||
mapLinkEl.value = `https://www.openstreetmap.org/?mlat=${manualPhotoGps.lat}&mlon=${manualPhotoGps.lng}&zoom=17`;
|
||
}
|
||
}
|
||
|
||
showToast('Photo loaded into Manual mode');
|
||
}
|
||
|
||
// ── Shared: Load settings dropdowns (hardcoded defaults — managed via admin app) ──
|
||
async function loadSettingsCache() {
|
||
if (settingsCache._loaded) return;
|
||
// Hardcoded seed data matches the admin app's defaults
|
||
settingsCache.categories = [
|
||
{name:'Furniture',icon:'🪑'}, {name:'Appliances',icon:'🔌'},
|
||
{name:'Utensils & Serveware',icon:'🍽️'}, {name:'Equipment',icon:'⚙️'},
|
||
{name:'Other',icon:'📦'},
|
||
];
|
||
settingsCache.makes = [
|
||
{id:1,name:'Canteen'}, {id:2,name:'Hobart'}, {id:3,name:'Vollrath'},
|
||
{id:4,name:'Metro'}, {id:5,name:'Rubbermaid'}, {id:6,name:'Cambro'},
|
||
{id:7,name:'Other'},
|
||
];
|
||
settingsCache.models = [];
|
||
settingsCache.keyNames = [
|
||
{name:'MK500'}, {name:'Green Dot'}, {name:'Red Key'}, {name:'Blue Key'},
|
||
{name:'Master Key'}, {name:'Padlock Key'},
|
||
];
|
||
settingsCache.keyTypes = [
|
||
{name:'Round Short'}, {name:'Barrel'}, {name:'Standard'},
|
||
{name:'Flat'}, {name:'Tubular'},
|
||
];
|
||
settingsCache.badgeTypes = [
|
||
{name:'Disney Contractor Base'}, {name:'Visitor Badge'}, {name:'Employee Badge'},
|
||
{name:'Contractor Badge'}, {name:'Temporary Pass'},
|
||
];
|
||
settingsCache.customers = [];
|
||
settingsCache._loaded = true;
|
||
}
|
||
|
||
function populateCategorySelect(selectId) {
|
||
const sel = document.getElementById(selectId);
|
||
if (!sel) return;
|
||
const cats = settingsCache.categories || [];
|
||
sel.innerHTML = '<option value="">Category</option>' +
|
||
cats.map(c => `<option value="${esc(c.name)}">${esc(c.name)}</option>`).join('');
|
||
}
|
||
|
||
function populateMakeSelect() {
|
||
const sel = document.getElementById('manMake');
|
||
if (!sel) return;
|
||
const makes = settingsCache.makes || [];
|
||
sel.innerHTML = '<option value="">Make</option>' +
|
||
makes.map(m => `<option value="${esc(m.id)}">${esc(m.name)}</option>`).join('');
|
||
}
|
||
|
||
async function loadModels() {
|
||
const makeId = document.getElementById('manMake').value;
|
||
const sel = document.getElementById('manModel');
|
||
sel.innerHTML = '<option value="">Model</option>';
|
||
if (!makeId) return;
|
||
const models = (settingsCache.models || []).filter(m => m.make_id == makeId);
|
||
sel.innerHTML += models.map(m => `<option value="${esc(m.id)}">${esc(m.name)}</option>`).join('');
|
||
}
|
||
|
||
function populateCustomerSelect() {
|
||
const sel = document.getElementById('manCustomer');
|
||
if (!sel) return;
|
||
const customers = settingsCache.customers || [];
|
||
sel.innerHTML = '<option value="">Customer</option>' +
|
||
customers.map(c => `<option value="${c.id}">${esc(c.name)}</option>`).join('');
|
||
}
|
||
|
||
async function loadManualLocations() {
|
||
const custId = document.getElementById('manCustomer').value;
|
||
const sel = document.getElementById('manLocation');
|
||
sel.innerHTML = '<option value="">Location</option>';
|
||
if (!custId) return;
|
||
// Locations managed via admin app
|
||
try {
|
||
sel.innerHTML += '<option value="">No locations (manage in admin app)</option>';
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
function loadBadgeChecklist() {
|
||
const container = document.getElementById('manBadgesList');
|
||
if (!container) return;
|
||
const badges = settingsCache.badgeTypes || [];
|
||
container.innerHTML = badges.map(b =>
|
||
`<label class="badge-check-item" onclick="toggleBadge(this)">
|
||
<input type="checkbox" value="${esc(b.name)}">${esc(b.name)}
|
||
</label>`
|
||
).join('');
|
||
}
|
||
|
||
function toggleBadge(el) {
|
||
el.classList.toggle('checked');
|
||
el.querySelector('input').checked = el.classList.contains('checked');
|
||
}
|
||
|
||
function getCheckedBadges() {
|
||
const checks = document.querySelectorAll('#manBadgesList .badge-check-item.checked input');
|
||
return Array.from(checks).map(c => c.value);
|
||
}
|
||
|
||
// ── Key Management ──────────────────────────────────────────────────────
|
||
function addKeyEntry(keyName = '', keyType = '') {
|
||
const list = document.getElementById('manKeysList');
|
||
const idx = manKeysCounter++;
|
||
const knOpts = (settingsCache.keyNames || []).map(k =>
|
||
`<option value="${esc(k.name)}" ${k.name === keyName ? 'selected' : ''}>${esc(k.name)}</option>`
|
||
).join('');
|
||
const ktOpts = (settingsCache.keyTypes || []).map(k =>
|
||
`<option value="${esc(k.name)}" ${k.name === keyType ? 'selected' : ''}>${esc(k.name)}</option>`
|
||
).join('');
|
||
|
||
const div = document.createElement('div');
|
||
div.className = 'key-entry';
|
||
div.id = 'keyEntry' + idx;
|
||
div.innerHTML = `
|
||
<select class="input-field" style="margin-bottom:0;">
|
||
<option value="">Key Name</option>${knOpts}
|
||
</select>
|
||
<select class="input-field" style="margin-bottom:0;">
|
||
<option value="">Type</option>${ktOpts}
|
||
</select>
|
||
<button class="key-remove" onclick="this.closest('.key-entry').remove()">✕</button>`;
|
||
list.appendChild(div);
|
||
}
|
||
|
||
function getKeysData() {
|
||
const entries = document.querySelectorAll('#manKeysList .key-entry');
|
||
const keys = [];
|
||
entries.forEach(e => {
|
||
const sels = e.querySelectorAll('select');
|
||
if (sels[0] && sels[0].value) {
|
||
keys.push({ key_name: sels[0].value, key_type: sels[1] ? sels[1].value : '' });
|
||
}
|
||
});
|
||
return keys;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// BARCODE MODE
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
function _barcodeHints() {
|
||
// Restrict to 1D barcode formats only — much faster and more accurate
|
||
// than scanning all formats (QR, DataMatrix, PDF417, Aztec, etc.)
|
||
const hints = new Map();
|
||
const fmts = [
|
||
ZXing.BarcodeFormat.CODE_128,
|
||
ZXing.BarcodeFormat.CODE_39,
|
||
ZXing.BarcodeFormat.EAN_13,
|
||
ZXing.BarcodeFormat.EAN_8,
|
||
ZXing.BarcodeFormat.UPC_A,
|
||
ZXing.BarcodeFormat.UPC_E,
|
||
ZXing.BarcodeFormat.CODE_93,
|
||
ZXing.BarcodeFormat.ITF,
|
||
ZXing.BarcodeFormat.CODABAR,
|
||
];
|
||
hints.set(ZXing.DecodeHintType.POSSIBLE_FORMATS, fmts);
|
||
hints.set(ZXing.DecodeHintType.TRY_HARDER, true);
|
||
return hints;
|
||
}
|
||
|
||
function _barcodeOptions() {
|
||
return {
|
||
delayBetweenScanAttempts: 50, // scan every 50ms (was default ~500ms)
|
||
delayBetweenScanSuccess: 2000, // 2s cooldown after a successful scan
|
||
};
|
||
}
|
||
|
||
async function startScanning() {
|
||
if (addAssetMode !== 'barcode') return;
|
||
if (scanningActive) return;
|
||
try {
|
||
setScanStatus('Starting camera...', 'working');
|
||
|
||
// Stop any previous reader
|
||
if (codeReader) {
|
||
try { codeReader.reset(); } catch (e) { /* ignore */ }
|
||
codeReader = null;
|
||
}
|
||
|
||
const video = document.getElementById('cameraVideo');
|
||
if (!video) return;
|
||
|
||
// Create reader with 1D-only hints for fast, accurate scanning
|
||
codeReader = new ZXing.BrowserMultiFormatReader(_barcodeHints(), _barcodeOptions());
|
||
|
||
// Pick the rear-facing camera (environment-facing on phones)
|
||
let deviceId = null;
|
||
try {
|
||
const devices = await codeReader.listVideoInputDevices();
|
||
if (devices && devices.length > 0) {
|
||
const rear = devices.find(d =>
|
||
d.label && /back|rear|environment/i.test(d.label)
|
||
);
|
||
deviceId = rear ? rear.deviceId : devices[0].deviceId;
|
||
}
|
||
} catch (e) {
|
||
// Fallback: pass null to let decodeFromVideoDevice pick default
|
||
console.warn('listVideoInputDevices failed, using default camera:', e);
|
||
}
|
||
|
||
document.getElementById('cameraPlaceholder').style.display = 'none';
|
||
video.style.display = 'block';
|
||
scanningActive = true;
|
||
|
||
setScanStatus('Point camera at a barcode', 'success');
|
||
|
||
// decodeFromVideoDevice handles camera + scanning in one call
|
||
codeReader.decodeFromVideoDevice(deviceId, video, (result, err) => {
|
||
if (result && scanningActive) {
|
||
const val = result.getText();
|
||
if (val && val !== currentScannedMachineId) {
|
||
handleBarcode(val.trim());
|
||
}
|
||
}
|
||
});
|
||
} catch (e) {
|
||
console.error('Camera error:', e);
|
||
setScanStatus('Camera failed: ' + e.message, 'error');
|
||
const ph = document.getElementById('cameraPlaceholder');
|
||
if (ph) ph.innerHTML = `<span class="cam-icon">📷</span><div class="cam-hint" style="margin-top:4px;">Camera: ${e.message}</div>`;
|
||
}
|
||
}
|
||
|
||
function stopScanning() {
|
||
scanningActive = false;
|
||
if (codeReader) {
|
||
try { codeReader.reset(); } catch (e) { /* ignore */ }
|
||
codeReader = null;
|
||
}
|
||
const video = document.getElementById('cameraVideo');
|
||
if (video) video.style.display = 'none';
|
||
const ph = document.getElementById('cameraPlaceholder');
|
||
if (ph) {
|
||
ph.style.display = '';
|
||
ph.innerHTML = '<span class="cam-icon">📷</span><div class="cam-hint" style="margin-top:4px;">Tap to start camera</div>';
|
||
}
|
||
}
|
||
|
||
if (document.getElementById('cameraPlaceholder')) {
|
||
document.getElementById('cameraPlaceholder').addEventListener('click', startScanning);
|
||
}
|
||
|
||
function setScanStatus(msg, cls = '') {
|
||
const el = document.getElementById('scanStatus');
|
||
if (el) { el.textContent = msg; el.className = 'status-bar ' + cls; }
|
||
}
|
||
|
||
async function handleBarcode(machineId) {
|
||
if (barcodeDebounce) return;
|
||
barcodeDebounce = machineId;
|
||
currentScannedMachineId = machineId;
|
||
|
||
setScanStatus(`Scanned: ${machineId} — looking up...`, 'working');
|
||
document.getElementById('scanResult').style.display = 'none';
|
||
document.getElementById('newAssetCard').style.display = 'none';
|
||
document.getElementById('checkinCard').style.display = 'none';
|
||
|
||
try {
|
||
const asset = await api(`/api/assets/search?machine_id=${encodeURIComponent(machineId)}`);
|
||
showScannedAsset(asset);
|
||
} catch (e) {
|
||
if (e.message === 'Asset not found' || e.message.includes('404')) {
|
||
showNewAssetForm(machineId);
|
||
} else {
|
||
setScanStatus('Lookup error: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
setTimeout(() => { barcodeDebounce = null; }, 2000);
|
||
}
|
||
|
||
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;
|
||
setScanStatus('Machine ID not in database — create asset below', 'error');
|
||
|
||
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">
|
||
<div class="ai-name">${esc(a.name)}</div>
|
||
<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 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> ';
|
||
html += '<span class="ir-warn">⊘ Skipped (duplicate): ' + skipped + '</span> ';
|
||
html += '<span class="ir-err">✗ Errors: ' + errors.length + '</span>';
|
||
if (errors.length) {
|
||
html += '<div style="margin-top:6px;font-size:11px;max-height:120px;overflow-y:auto;">' +
|
||
errors.map(e => '<div class="ir-err">' + esc(e) + '</div>').join('') + '</div>';
|
||
}
|
||
html += '</div>';
|
||
resEl.innerHTML = html;
|
||
|
||
btn.textContent = 'Done';
|
||
btn.className = 'btn btn-outline btn-sm';
|
||
btn.onclick = () => showAssetList();
|
||
showToast('Import complete: ' + created + ' created, ' + skipped + ' skipped, ' + errors.length + ' errors');
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// MAP PINS
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
async function loadAssetPins() {
|
||
// Store markers on a namespace so we can clear them
|
||
if (!window._assetPins) window._assetPins = [];
|
||
// Clear existing markers
|
||
window._assetPins.forEach(function(m) { if (map) map.removeLayer(m); });
|
||
window._assetPins = [];
|
||
|
||
try {
|
||
const assets = await api('/api/assets?limit=1000');
|
||
assets.forEach(function(a) {
|
||
if (a.latitude != null && a.longitude != null) {
|
||
var catEntry = CATEGORY_MAP[a.category];
|
||
var icon = catEntry ? catEntry.icon : (a.category ? a.category.charAt(0).toUpperCase() : '📦');
|
||
// Map pins can't render <img> tags, fall back to emoji
|
||
if (icon.indexOf('<img') !== -1) icon = '🍿';
|
||
var popupHtml = '<b>' + esc(a.name) + '</b><br>' + esc(a.machine_id || '') +
|
||
'<br><button class="btn btn-outline btn-sm" style="margin-top:6px;" ' +
|
||
'onclick="switchTab(\'tabAssets\');viewAsset(' + a.id + ');">View Details</button>';
|
||
var catColor = catEntry ? catEntry.color : '#6b7280';
|
||
var pinIcon = L.divIcon({
|
||
className: 'cat-pin-icon',
|
||
html: '<div style="width:28px;height:28px;border-radius:50%;background:' + catColor +
|
||
';display:flex;align-items:center;justify-content:center;font-size:15px;' +
|
||
'box-shadow:0 2px 5px rgba(0,0,0,0.5);border:2px solid #fff;">' + icon + '</div>',
|
||
iconSize: [32, 32],
|
||
iconAnchor: [16, 16],
|
||
popupAnchor: [0, -18],
|
||
});
|
||
var marker = L.marker([a.latitude, a.longitude], { icon: pinIcon })
|
||
.addTo(map)
|
||
.bindPopup(popupHtml);
|
||
window._assetPins.push(marker);
|
||
}
|
||
});
|
||
// If we have assets with coords, zoom to fit them
|
||
if (window._assetPins.length > 0) {
|
||
var group = L.featureGroup(window._assetPins);
|
||
map.fitBounds(group.getBounds().pad(0.1));
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to load asset pins:', e);
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// HEATMAP
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
async function toggleHeatmap() {
|
||
if (!map) return;
|
||
const chip = document.getElementById('chipHeat');
|
||
|
||
if (heatVisible) {
|
||
heatVisible = false;
|
||
chip.classList.remove('heat-on');
|
||
chip.textContent = '🔥 Heatmap';
|
||
if (heatLayer) map.removeLayer(heatLayer);
|
||
heatLayer = null;
|
||
return;
|
||
}
|
||
|
||
heatVisible = true;
|
||
chip.classList.add('heat-on');
|
||
chip.textContent = '🔥 Heatmap ON';
|
||
await loadHeatmapData();
|
||
}
|
||
|
||
async function loadHeatmapData() {
|
||
try {
|
||
// Get visit stats (visit counts per asset)
|
||
const stats = await api('/api/visits/stats');
|
||
const visitMap = {};
|
||
(stats.visits_per_asset || []).forEach(v => {
|
||
visitMap[v.name] = v.count;
|
||
});
|
||
|
||
// Get all assets with coordinates
|
||
const assets = await api('/api/assets?limit=1000');
|
||
const heatData = [];
|
||
assets.forEach(a => {
|
||
if (a.latitude != null && a.longitude != null) {
|
||
const count = visitMap[a.name] || 0;
|
||
// Higher intensity for more visits; minimum 0.1 so all assets show faintly
|
||
const intensity = Math.max(0.1, Math.min(1, count / 10));
|
||
heatData.push([a.latitude, a.longitude, intensity]);
|
||
}
|
||
});
|
||
|
||
if (heatLayer) map.removeLayer(heatLayer);
|
||
|
||
if (typeof L.heatLayer === 'function') {
|
||
heatLayer = L.heatLayer(heatData, {
|
||
radius: 30,
|
||
blur: 20,
|
||
maxZoom: 17,
|
||
max: 1.0,
|
||
gradient: { 0.1: '#4ade80', 0.4: '#fbbf24', 0.7: '#f87171', 1.0: '#ef4444' },
|
||
}).addTo(map);
|
||
} else {
|
||
// Fallback: circle markers with opacity
|
||
heatLayer = L.layerGroup();
|
||
heatData.forEach(([lat, lng, intensity]) => {
|
||
L.circleMarker([lat, lng], {
|
||
radius: 12 + intensity * 20,
|
||
fillColor: '#f87171',
|
||
color: 'transparent',
|
||
fillOpacity: intensity * 0.6,
|
||
}).addTo(heatLayer);
|
||
});
|
||
heatLayer.addTo(map);
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to load heatmap:', e);
|
||
showToast('Failed to load heatmap data', true);
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// AUTO-VISIT LOGGING (Client-side GPS tracking)
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
let geoWatchId = null;
|
||
let visitTimers = {};
|
||
|
||
function startVisitTracking() {
|
||
if (!navigator.geolocation) return;
|
||
if (geoWatchId !== null) return; // already tracking
|
||
|
||
geoWatchId = navigator.geolocation.watchPosition(
|
||
pos => {
|
||
const lat = pos.coords.latitude;
|
||
const lng = pos.coords.longitude;
|
||
AppState.gpsLat = lat;
|
||
AppState.gpsLng = lng;
|
||
// Show GPS center chip on map
|
||
const chip = document.getElementById('chipGeoGPS');
|
||
if (chip) chip.style.display = '';
|
||
checkProximityToAssets(lat, lng);
|
||
},
|
||
err => { /* silently fail — GPS permission may change */ },
|
||
{ enableHighAccuracy: true, maximumAge: 30000, timeout: 20000 }
|
||
);
|
||
}
|
||
|
||
function stopVisitTracking() {
|
||
if (geoWatchId !== null) {
|
||
navigator.geolocation.clearWatch(geoWatchId);
|
||
geoWatchId = null;
|
||
}
|
||
// Clear all timers
|
||
Object.keys(visitTimers).forEach(k => clearTimeout(visitTimers[k]?._timer));
|
||
visitTimers = {};
|
||
document.getElementById('visitTracker').style.display = 'none';
|
||
}
|
||
|
||
// Haversine distance in meters
|
||
function haversineM(lat1, lng1, lat2, lng2) {
|
||
const R = 6371000;
|
||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||
const dLng = (lng2 - lng1) * Math.PI / 180;
|
||
const a = Math.sin(dLat / 2) ** 2 +
|
||
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||
Math.sin(dLng / 2) ** 2;
|
||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||
}
|
||
|
||
function checkProximityToAssets(userLat, userLng) {
|
||
let nearestAsset = null;
|
||
let nearestDist = Infinity;
|
||
|
||
assetMarkers.forEach(({ asset }) => {
|
||
if (asset.latitude == null || asset.longitude == null) return;
|
||
const dist = haversineM(userLat, userLng, asset.latitude, asset.longitude);
|
||
if (dist < nearestDist) {
|
||
nearestDist = dist;
|
||
nearestAsset = asset;
|
||
}
|
||
|
||
// Check if within threshold
|
||
if (dist <= VISIT_THRESHOLD_M) {
|
||
if (!visitTimers[asset.id]) {
|
||
visitTimers[asset.id] = {
|
||
startTime: Date.now(),
|
||
lastSeen: Date.now(),
|
||
asset: asset,
|
||
};
|
||
} else {
|
||
visitTimers[asset.id].lastSeen = Date.now();
|
||
}
|
||
} else {
|
||
// Asset no longer in range — clear timer
|
||
if (visitTimers[asset.id]) {
|
||
clearTimeout(visitTimers[asset.id]._timer);
|
||
delete visitTimers[asset.id];
|
||
}
|
||
}
|
||
});
|
||
|
||
// Check if any timer has reached threshold
|
||
const now = Date.now();
|
||
const tracker = document.getElementById('visitTracker');
|
||
|
||
let trackingAsset = null;
|
||
for (const [id, timer] of Object.entries(visitTimers)) {
|
||
const elapsed = Math.floor((now - timer.startTime) / 60000);
|
||
timer._elapsed = elapsed;
|
||
|
||
if (timer.asset === nearestAsset) {
|
||
trackingAsset = timer;
|
||
}
|
||
|
||
// If 10+ minutes, auto-log a visit via checkin
|
||
if (elapsed >= VISIT_DURATION_MIN && !timer._logged) {
|
||
timer._logged = true;
|
||
logAutoVisit(timer.asset);
|
||
}
|
||
|
||
// Cleanup stale timers (not seen in 2 minutes)
|
||
if (now - timer.lastSeen > 120000) {
|
||
clearTimeout(timer._timer);
|
||
delete visitTimers[id];
|
||
}
|
||
}
|
||
|
||
// Update visit tracker UI
|
||
if (trackingAsset && trackingAsset._elapsed >= 0) {
|
||
tracker.style.display = 'block';
|
||
document.getElementById('vtAssetName').textContent = trackingAsset.asset.name;
|
||
document.getElementById('vtTimer').textContent = trackingAsset._elapsed + ' min';
|
||
if (trackingAsset._elapsed >= VISIT_DURATION_MIN) {
|
||
tracker.style.background = 'var(--accent-bg)';
|
||
tracker.style.color = 'var(--accent2)';
|
||
}
|
||
} else {
|
||
tracker.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
async function logAutoVisit(asset) {
|
||
try {
|
||
// Log a visit record (auto-detected stay)
|
||
await api('/api/visits', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
asset_id: asset.id,
|
||
user_id: AppState.currentUser?.id || null,
|
||
latitude: AppState.gpsLat,
|
||
longitude: AppState.gpsLng,
|
||
duration_minutes: VISIT_DURATION_MIN,
|
||
}),
|
||
});
|
||
showToast('📍 Visit auto-logged for ' + asset.name);
|
||
// Refresh heatmap if visible
|
||
if (heatVisible) await loadHeatmapData();
|
||
} catch (e) {
|
||
console.error('Auto-visit log failed:', e);
|
||
}
|
||
}
|
||
|
||
// =========================================================================
|
||
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>
|