5938 lines
275 KiB
HTML
5938 lines
275 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>
|
||
<!-- 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>
|
||
<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;
|
||
}
|
||
.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); }
|
||
.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;
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
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; }
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
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); }
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
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); }
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
ACTIVITY FEED (Phase M)
|
||
═══════════════════════════════════════════════════════════════════════ */
|
||
.activity-item {
|
||
display: flex; gap: 12px; padding: 12px 0;
|
||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||
}
|
||
.activity-item .act-icon {
|
||
width: 36px; height: 36px; border-radius: 50%; display: flex;
|
||
align-items: center; justify-content: center; font-size: 16px; flex-shrink: 0;
|
||
}
|
||
.activity-item .act-icon.created { background: #1a2e2a; }
|
||
.activity-item .act-icon.updated { background: #1a2a3e; }
|
||
.activity-item .act-icon.deleted { background: #2e1a1a; }
|
||
.activity-item .act-icon.checkin { background: #2a2510; }
|
||
.activity-item .act-icon.geofence { background: #2a1a2e; }
|
||
.activity-item .act-icon.customer { background: #1a2e2a; }
|
||
.activity-item .act-icon.user { background: #1e1a2e; }
|
||
.activity-item .act-info { flex: 1; min-width: 0; }
|
||
.activity-item .act-text { font-size: 14px; line-height: 1.3; }
|
||
.activity-item .act-time { font-size: 12px; color: var(--text2); margin-top: 2px; }
|
||
.activity-item .act-user { font-size: 11px; color: var(--accent2); font-weight: 600; }
|
||
.act-filter-row { display: flex; gap: 8px; margin-bottom: 8px; }
|
||
.act-filter-row select { flex: 1; margin-bottom: 0; font-size: 13px; }
|
||
.act-pagination { display: flex; gap: 8px; justify-content: center; padding: 14px 0; }
|
||
.act-pagination button { min-width: 80px; }
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
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.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); }
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
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: 16/12; display: flex; align-items: center; justify-content: center;
|
||
overflow: hidden; margin-bottom: 8px;
|
||
}
|
||
.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; }
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
MODE TOGGLES (Add Asset tab)
|
||
═══════════════════════════════════════════════════════════════════════ */
|
||
.mode-toggles {
|
||
display: flex; 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; }
|
||
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
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; }
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
STATS (Dashboard)
|
||
═══════════════════════════════════════════════════════════════════════ */
|
||
.stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||
.stats-grid.three { grid-template-columns: 1fr 1fr 1fr; }
|
||
.stat-card {
|
||
background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
|
||
padding: 16px; text-align: center;
|
||
}
|
||
.stat-card .stat-value { font-size: 28px; font-weight: 800; color: var(--accent2); }
|
||
.stat-card .stat-label {
|
||
font-size: 10px; font-weight: 600; text-transform: uppercase;
|
||
letter-spacing: 0.05em; color: var(--text2); margin-top: 4px;
|
||
line-height: 1.3;
|
||
}
|
||
.stat-bar { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
||
.stat-bar .sb-label { font-size: 12px; color: var(--text2); width: 80px; flex-shrink: 0; text-align: right; }
|
||
.stat-bar .sb-track { flex: 1; height: 8px; background: #0a0b0f; border-radius: 4px; overflow: hidden; }
|
||
.stat-bar .sb-fill { height: 100%; border-radius: 4px; background: var(--accent); transition: width 0.4s; }
|
||
.stat-bar .sb-count { font-size: 12px; font-weight: 600; width: 30px; }
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
DASHBOARD: ACTIVITY FEED
|
||
═══════════════════════════════════════════════════════════════════════ */
|
||
.activity-item {
|
||
display: flex; align-items: flex-start; gap: 8px; padding: 10px 0;
|
||
border-bottom: 1px solid rgba(255,255,255,0.03); font-size: 13px;
|
||
}
|
||
.activity-item:last-child { border-bottom: none; }
|
||
.activity-item .act-icon {
|
||
width: 28px; height: 28px; border-radius: 50%; display: flex;
|
||
align-items: center; justify-content: center; font-size: 14px;
|
||
flex-shrink: 0; margin-top: 1px;
|
||
}
|
||
.activity-item .act-icon.created { background: var(--green-bg); }
|
||
.activity-item .act-icon.updated { background: var(--accent-bg); }
|
||
.activity-item .act-icon.deleted { background: var(--red-bg); }
|
||
.activity-item .act-icon.checked { background: var(--amber-bg); }
|
||
.activity-item .act-text { flex: 1; min-width: 0; }
|
||
.activity-item .act-desc { color: var(--text); line-height: 1.4; }
|
||
.activity-item .act-time { font-size: 11px; color: var(--text2); margin-top: 2px; }
|
||
.activity-item .act-user { color: var(--accent2); font-weight: 600; }
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
DASHBOARD: HIGH-VISIT TABLE
|
||
═══════════════════════════════════════════════════════════════════════ */
|
||
.hv-row {
|
||
display: flex; align-items: center; gap: 8px; padding: 10px 0;
|
||
border-bottom: 1px solid rgba(255,255,255,0.03); cursor: pointer;
|
||
transition: background 0.1s;
|
||
}
|
||
.hv-row:last-child { border-bottom: none; }
|
||
.hv-row:active { background: rgba(255,255,255,0.02); }
|
||
.hv-rank { width: 22px; height: 22px; border-radius: 50%; display: flex;
|
||
align-items: center; justify-content: center; font-size: 11px;
|
||
font-weight: 700; flex-shrink: 0; }
|
||
.hv-rank.r1 { background: var(--amber-bg); color: var(--amber); }
|
||
.hv-rank.r2, .hv-rank.r3 { background: var(--accent-bg); color: var(--accent2); }
|
||
.hv-rank.rn { background: var(--border); color: var(--text2); }
|
||
.hv-info { flex: 1; min-width: 0; }
|
||
.hv-name { font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.hv-meta { font-size: 11px; color: var(--text2); margin-top: 1px; }
|
||
.hv-visits { font-weight: 700; font-size: 14px; color: var(--accent2); flex-shrink: 0; }
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
DASHBOARD: QUICK ACTIONS
|
||
═══════════════════════════════════════════════════════════════════════ */
|
||
.quick-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||
.qa-btn {
|
||
display: flex; align-items: center; justify-content: center; gap: 6px;
|
||
padding: 10px; border-radius: var(--radius-sm); border: 1px solid var(--border);
|
||
background: var(--card2); color: var(--text); font-size: 13px; font-weight: 600;
|
||
cursor: pointer; transition: background 0.15s;
|
||
}
|
||
.qa-btn:active { background: var(--border); }
|
||
.qa-btn.full { grid-column: 1 / -1; }
|
||
.qa-btn .qa-icon { font-size: 16px; }
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
REPORT TABLES
|
||
═══════════════════════════════════════════════════════════════════════ */
|
||
.report-subtitle { font-size: 12px; font-weight: 600; color: var(--text2); margin-bottom: 6px; }
|
||
.rpt-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||
.rpt-table th {
|
||
text-align: left; padding: 8px 10px; border-bottom: 2px solid var(--border);
|
||
color: var(--text2); font-weight: 600; font-size: 11px; text-transform: uppercase;
|
||
letter-spacing: 0.03em; white-space: nowrap; user-select: none;
|
||
}
|
||
.rpt-table th.sortable { cursor: pointer; }
|
||
.rpt-table th.sortable:hover { color: var(--accent); }
|
||
.rpt-table th .sort-arrow { font-size: 10px; margin-left: 3px; opacity: 0.4; }
|
||
.rpt-table th .sort-arrow.asc, .rpt-table th .sort-arrow.desc { opacity: 1; color: var(--accent); }
|
||
.rpt-table td {
|
||
padding: 8px 10px; border-bottom: 1px solid rgba(255,255,255,0.04);
|
||
vertical-align: middle;
|
||
}
|
||
.rpt-table tr:last-child td { border-bottom: none; }
|
||
.rpt-table .rpt-number { text-align: right; font-variant-numeric: tabular-nums; }
|
||
|
||
/* report inline bar */
|
||
.rpt-bar-wrap { display: flex; align-items: center; gap: 6px; }
|
||
.rpt-bar-track { flex: 1; height: 6px; background: #0a0b0f; border-radius: 3px; overflow: hidden; min-width: 40px; }
|
||
.rpt-bar-fill { height: 100%; border-radius: 3px; background: var(--accent); transition: width 0.4s; }
|
||
.rpt-bar-val { font-size: 12px; font-weight: 600; min-width: 40px; text-align: right; }
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
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; }
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
CHECKIN HISTORY ITEMS
|
||
═══════════════════════════════════════════════════════════════════════ */
|
||
.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); }
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
CATEGORY ICON COLORS (v2)
|
||
═══════════════════════════════════════════════════════════════════════ */
|
||
.asset-item .ai-icon.cat-Coffee { background: #2e1a0a; }
|
||
.asset-item .ai-icon.cat-Cold-Beverage { background: #0a1a2e; }
|
||
.asset-item .ai-icon.cat-Snacks { background: #2a2010; }
|
||
.asset-item .ai-icon.cat-Cold-Food { background: #1a2e1a; }
|
||
.asset-item .ai-icon.cat-Market { background: #2e1a2e; }
|
||
.asset-item .ai-icon.cat-Smart-Cooler { background: #1a2e2e; }
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
SETTINGS TAB
|
||
═══════════════════════════════════════════════════════════════════════ */
|
||
.card-header {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
margin-bottom: 10px;
|
||
}
|
||
.card-header .card-title { margin-bottom: 0; }
|
||
|
||
.settings-list { }
|
||
|
||
.settings-item {
|
||
display: flex; align-items: center; gap: 10px;
|
||
padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.04);
|
||
font-size: 14px;
|
||
}
|
||
.settings-item:last-child { border-bottom: none; }
|
||
.settings-item .si-icon { font-size: 18px; width: 24px; text-align: center; }
|
||
.settings-item .si-name { flex: 1; }
|
||
.settings-item .si-meta { font-size: 11px; color: var(--text2); margin-left: 8px; }
|
||
.settings-item .si-actions { display: flex; gap: 6px; flex-shrink: 0; }
|
||
.si-btn {
|
||
width: 28px; height: 28px; border: none; background: transparent;
|
||
color: var(--text2); font-size: 14px; cursor: pointer;
|
||
display: flex; align-items: center; justify-content: center;
|
||
border-radius: var(--radius-xs);
|
||
-webkit-tap-highlight-color: transparent;
|
||
}
|
||
.si-btn:active { background: var(--border); }
|
||
.si-btn.delete { color: var(--red); }
|
||
|
||
/* Make expand chevron */
|
||
.settings-item.make-item { cursor: pointer; font-weight: 600; }
|
||
.settings-item.make-item .si-chevron {
|
||
transition: transform 0.2s; font-size: 12px; color: var(--text2);
|
||
}
|
||
.settings-item.make-item.expanded .si-chevron { transform: rotate(90deg); }
|
||
|
||
/* Model sub-list */
|
||
.models-sublist { display: none; padding-left: 34px; }
|
||
.models-sublist.open { display: block; }
|
||
.models-sublist .settings-item { font-size: 13px; padding: 8px 0; }
|
||
.models-sublist .settings-item .si-icon { width: 20px; }
|
||
|
||
.add-model-row {
|
||
display: flex; align-items: center; gap: 8px;
|
||
padding: 6px 0 6px 34px; border-bottom: 1px solid rgba(255,255,255,0.04);
|
||
}
|
||
.add-model-row input { flex: 1; margin-bottom: 0; font-size: 13px; padding: 7px 10px; }
|
||
|
||
/* Settings inline form */
|
||
.settings-inline-form {
|
||
display: flex; align-items: center; gap: 8px; padding: 8px 0;
|
||
border-bottom: 1px solid var(--accent);
|
||
}
|
||
.settings-inline-form input { flex: 1; margin-bottom: 0; font-size: 14px; }
|
||
.settings-inline-form select { margin-bottom: 0; font-size: 14px; }
|
||
.settings-inline-form .si-actions { display: flex; gap: 4px; }
|
||
|
||
/* Emoji picker */
|
||
.emoji-picker {
|
||
display: flex; flex-wrap: wrap; gap: 4px; padding: 6px 0;
|
||
}
|
||
.emoji-picker .emoji-opt {
|
||
width: 32px; height: 32px; display: flex; align-items: center; justify-content: center;
|
||
font-size: 18px; border-radius: var(--radius-xs); cursor: pointer;
|
||
border: 1px solid transparent; background: transparent;
|
||
}
|
||
.emoji-opt:hover, .emoji-opt:active { background: var(--border); }
|
||
.emoji-opt.selected { border-color: var(--accent); background: var(--accent-bg); }
|
||
|
||
/* Setting row (config) */
|
||
.setting-row {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.04);
|
||
font-size: 14px;
|
||
}
|
||
.setting-row:last-child { border-bottom: none; }
|
||
.setting-val { color: var(--text2); font-size: 13px; }
|
||
|
||
/* Admin-only visibility */
|
||
.admin-only { display: none !important; }
|
||
.is-admin .admin-only { display: revert !important; }
|
||
|
||
/* Role badge inline */
|
||
.role-badge {
|
||
display: inline-block; font-size: 10px; font-weight: 600; padding: 2px 8px;
|
||
border-radius: 10px; text-transform: uppercase;
|
||
}
|
||
.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(--amber-bg); color: var(--amber); }
|
||
|
||
/* User detail inline */
|
||
.user-detail-inline {
|
||
padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.04);
|
||
}
|
||
.user-detail-inline .input-field { margin-bottom: 6px; }
|
||
|
||
/* Icon upload thumb */
|
||
.icon-thumb-small {
|
||
width: 28px; height: 28px; border-radius: 4px; object-fit: cover;
|
||
border: 1px solid var(--border);
|
||
}
|
||
</style>
|
||
<style>
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
CUSTOMERS & LOCATIONS — list cards
|
||
═══════════════════════════════════════════════════════════════════════ */
|
||
.cust-card {
|
||
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;
|
||
}
|
||
.cust-card:hover, .cust-card:active { background: rgba(255,255,255,0.03); }
|
||
.cust-card .cc-icon {
|
||
width: 42px; height: 42px; border-radius: var(--radius-sm); display: flex;
|
||
align-items: center; justify-content: center; font-size: 20px; flex-shrink: 0;
|
||
background: var(--accent-bg);
|
||
}
|
||
.cust-card .cc-info { flex: 1; min-width: 0; }
|
||
.cust-card .cc-name { font-weight: 600; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.cust-card .cc-meta { font-size: 12px; color: var(--text2); margin-top: 2px; }
|
||
.cust-card .cc-arrow { color: var(--text2); font-size: 18px; }
|
||
|
||
.loc-card {
|
||
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;
|
||
}
|
||
.loc-card:hover, .loc-card:active { background: rgba(255,255,255,0.03); }
|
||
.loc-card .lc-icon {
|
||
width: 38px; height: 38px; border-radius: var(--radius-sm); display: flex;
|
||
align-items: center; justify-content: center; font-size: 18px; flex-shrink: 0;
|
||
background: var(--green-bg);
|
||
}
|
||
.loc-card .lc-info { flex: 1; min-width: 0; }
|
||
.loc-card .lc-name { font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.loc-card .lc-meta { font-size: 12px; color: var(--text2); margin-top: 2px; }
|
||
.loc-card .lc-arrow { color: var(--text2); font-size: 18px; }
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
CONTACTS SUB-FORM
|
||
═══════════════════════════════════════════════════════════════════════ */
|
||
.contact-row {
|
||
display: flex; flex-direction: column; gap: 4px;
|
||
background: var(--card2); border-radius: var(--radius-sm);
|
||
padding: 10px; margin-bottom: 6px; position: relative;
|
||
}
|
||
.contact-row .cr-remove {
|
||
position: absolute; top: 6px; right: 8px;
|
||
background: none; border: none; color: var(--red); font-size: 16px;
|
||
cursor: pointer; padding: 2px 6px; border-radius: 4px;
|
||
}
|
||
.contact-row .cr-remove:active { background: var(--red-bg); }
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
CONTACT DISPLAY (in detail views)
|
||
═══════════════════════════════════════════════════════════════════════ */
|
||
.contact-disp {
|
||
display: flex; align-items: center; gap: 10px; padding: 8px 0;
|
||
border-bottom: 1px solid rgba(255,255,255,0.03);
|
||
}
|
||
.contact-disp .cd-icon {
|
||
width: 32px; height: 32px; border-radius: 50%; display: flex;
|
||
align-items: center; justify-content: center; font-size: 14px;
|
||
background: var(--accent-bg); color: var(--accent2); flex-shrink: 0;
|
||
}
|
||
.contact-disp .cd-info { flex: 1; min-width: 0; }
|
||
.contact-disp .cd-name { font-weight: 600; font-size: 14px; }
|
||
.contact-disp .cd-detail { font-size: 12px; color: var(--text2); }
|
||
.contact-disp .cd-detail a { color: var(--accent2); text-decoration: none; }
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
ROOM LIST ITEMS
|
||
═══════════════════════════════════════════════════════════════════════ */
|
||
.room-item {
|
||
display: flex; align-items: center; gap: 8px; padding: 9px 0;
|
||
border-bottom: 1px solid rgba(255,255,255,0.03);
|
||
}
|
||
.room-item .ri-name { flex: 1; font-size: 14px; font-weight: 500; }
|
||
.room-item .ri-floor { font-size: 11px; color: var(--text2); margin-right: 4px; }
|
||
.room-item .ri-actions { display: flex; gap: 4px; flex-shrink: 0; }
|
||
.room-item .ri-btn {
|
||
background: none; border: none; font-size: 15px; cursor: pointer;
|
||
padding: 3px 6px; border-radius: 4px; color: var(--text2);
|
||
}
|
||
.room-item .ri-btn.edit:active { color: var(--accent2); background: var(--accent-bg); }
|
||
.room-item .ri-btn.del:active { color: var(--red); background: var(--red-bg); }
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
TAB SECTION (sub-views within Customers tab)
|
||
═══════════════════════════════════════════════════════════════════════ */
|
||
.tab-section { padding: 0; }
|
||
|
||
/* inline room editing row */
|
||
.room-edit-row { display: flex; gap: 4px; padding: 8px 0; align-items: center; }
|
||
.room-edit-row input { margin-bottom: 0; flex: 1; }
|
||
.room-edit-row button { flex-shrink: 0; }
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════
|
||
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);
|
||
padding: 12px;
|
||
}
|
||
.geofence-panel .gp-header {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
margin-bottom: 8px;
|
||
}
|
||
.geofence-panel .gp-title {
|
||
font-size: 13px; font-weight: 600;
|
||
}
|
||
.geofence-item {
|
||
display: flex; align-items: center; gap: 8px;
|
||
padding: 8px 0; border-top: 1px solid var(--border);
|
||
font-size: 13px;
|
||
}
|
||
.geofence-item:first-of-type { border-top: none; }
|
||
.geofence-item .gf-color {
|
||
width: 14px; height: 14px; border-radius: 3px; flex-shrink: 0;
|
||
border: 1px solid rgba(255,255,255,0.2);
|
||
}
|
||
.geofence-item .gf-name { flex: 1; }
|
||
.geofence-item .gf-actions {
|
||
display: flex; gap: 4px;
|
||
}
|
||
.geofence-item .gf-btn {
|
||
background: none; border: none; color: var(--text2); font-size: 16px;
|
||
cursor: pointer; padding: 2px 6px; border-radius: 4px;
|
||
-webkit-tap-highlight-color: transparent;
|
||
}
|
||
.geofence-item .gf-btn:active { background: var(--border); }
|
||
.geofence-item .gf-btn.danger { color: var(--red); }
|
||
/* 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; }
|
||
/* Color picker inline */
|
||
.color-picker-row {
|
||
display: flex; align-items: center; gap: 8px;
|
||
margin-top: 8px;
|
||
}
|
||
.color-picker-row input[type=color] {
|
||
width: 32px; height: 32px; border: none; border-radius: 6px;
|
||
background: transparent; cursor: pointer;
|
||
}
|
||
/* Leaflet draw toolbar dark theme overrides */
|
||
.leaflet-draw-toolbar a {
|
||
background: var(--card2) !important;
|
||
border-color: var(--border) !important;
|
||
}
|
||
.leaflet-draw-toolbar a:hover {
|
||
background: var(--accent-bg) !important;
|
||
}
|
||
.leaflet-draw-actions {
|
||
background: var(--card) !important;
|
||
border-color: var(--border) !important;
|
||
}
|
||
.leaflet-draw-actions a {
|
||
background: var(--card2) !important;
|
||
color: var(--text) !important;
|
||
border-color: var(--border) !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; }
|
||
</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">📍 GPS...</span>
|
||
<div class="user-badge" id="userBadge" onclick="openDrawer()" title="User">?</div>
|
||
</div>
|
||
</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>
|
||
<button class="dn-item" data-tab="tabCustomers" onclick="navFromDrawer('tabCustomers')">
|
||
<span class="dn-icon">🏢</span> Customers & Locations
|
||
</button>
|
||
<button class="dn-item" data-tab="tabDashboard" onclick="navFromDrawer('tabDashboard')">
|
||
<span class="dn-icon">📊</span> Dashboard
|
||
</button>
|
||
<button class="dn-item" data-tab="tabReports" onclick="navFromDrawer('tabReports')">
|
||
<span class="dn-icon">📋</span> Reports
|
||
</button>
|
||
<button class="dn-item" data-tab="tabActivity" onclick="navFromDrawer('tabActivity')">
|
||
<span class="dn-icon">📜</span> Activity Feed
|
||
</button>
|
||
<button class="dn-item" data-tab="tabSettings" onclick="navFromDrawer('tabSettings')">
|
||
<span class="dn-icon">⚙️</span> Settings
|
||
</button>
|
||
<div style="border-top:1px solid var(--border);margin:6px 16px;"></div>
|
||
<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 v2.0</div>
|
||
</div>
|
||
|
||
<!-- ═══════════════════════════════════════════════════════════════════════
|
||
TAB: ADD ASSET (Barcode / OCR / Manual)
|
||
═══════════════════════════════════════════════════════════════════════ -->
|
||
<div id="tabAddAsset" class="tab-panel active">
|
||
<!-- Mode Toggles -->
|
||
<div class="mode-toggles">
|
||
<button class="mode-toggle active" data-mode="barcode" onclick="setAddAssetMode('barcode')">📷 Barcode</button>
|
||
<button class="mode-toggle" data-mode="ocr" onclick="setAddAssetMode('ocr')">🔍 OCR</button>
|
||
<button class="mode-toggle" data-mode="manual" onclick="setAddAssetMode('manual')">✏️ Manual</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="Description (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>
|
||
<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>
|
||
<!-- OCR result -->
|
||
<div id="ocrResult" class="ocr-result" style="display:none;"></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="Description" 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 #">
|
||
</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>
|
||
<input id="manParkingLocation" type="text" class="input-field" placeholder="Parking location" style="margin-bottom:0;">
|
||
</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;">
|
||
<div id="manPhotoPlaceholder" class="camera-placeholder" onclick="startManualPhoto()">
|
||
<span class="cam-icon">📸</span>
|
||
<div class="cam-hint" style="margin-top:4px;">Tap to take photo</div>
|
||
</div>
|
||
<video id="manPhotoVideo" autoplay playsinline muted style="display:none;"></video>
|
||
</div>
|
||
<div id="manPhotoCaptureRow" style="display:none;justify-content:center;margin-bottom:8px;">
|
||
<button class="ocr-capture-btn" onclick="captureManualPhoto()" 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>
|
||
|
||
<!-- ═══════════════════════════════════════════════════════════════════════
|
||
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 assets..." oninput="loadAssets()">
|
||
<button class="clear-btn" id="clearSearch" onclick="clearAssetSearch()">✕</button>
|
||
</div>
|
||
<div class="assets-toolbar">
|
||
<div class="filter-scroll" id="filterPills"></div>
|
||
<button class="btn btn-outline btn-sm import-btn" onclick="showImportView()">📥 Import</button>
|
||
</div>
|
||
<div id="assetList"></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>
|
||
|
||
<!-- 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>
|
||
</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="Description" 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 active" id="chipPins" onclick="togglePins()">📍 Pins</span>
|
||
<span class="map-chip" id="chipHeat" onclick="toggleHeatmap()">🔥 Heatmap</span>
|
||
<span class="map-chip" id="chipGeo" onclick="toggleGeofenceDraw()">✏️ Add Geofence</span>
|
||
<span class="map-chip" onclick="centerOnGPS()">◎ My Location</span>
|
||
</div>
|
||
<div id="mapContainer"></div>
|
||
<div id="geofenceColorRow" class="color-picker-row" style="display:none;">
|
||
<span style="font-size:12px;color:var(--text2);">Color:</span>
|
||
<input type="color" id="geofenceColor" value="#3388ff">
|
||
<button class="btn btn-outline btn-sm" onclick="saveDrawnGeofence()" style="flex:1;">Save Geofence</button>
|
||
<button class="btn btn-sm" onclick="cancelGeofenceDraw()" style="color:var(--text2);background:transparent;border:none;">Cancel</button>
|
||
</div>
|
||
<div class="geofence-panel" id="geofencePanel">
|
||
<div class="gp-header">
|
||
<span class="gp-title">📍 Geofences</span>
|
||
<span style="font-size:11px;color:var(--text2);" id="gfCount">0 zones</span>
|
||
</div>
|
||
<div id="geofenceList">
|
||
<div style="font-size:12px;color:var(--text3);padding:4px 0;">No geofences yet — tap ✏️ to draw one</div>
|
||
</div>
|
||
</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: CUSTOMERS & LOCATIONS
|
||
═══════════════════════════════════════════════════════════════════════ -->
|
||
<div id="tabCustomers" class="tab-panel">
|
||
|
||
<!-- ═══════════════════════════════════════════════════════════════════════
|
||
CUSTOMERS LIST VIEW
|
||
═══════════════════════════════════════════════════════════════════════ -->
|
||
<div id="custListView" class="tab-section">
|
||
<div class="search-bar">
|
||
<input id="custSearch" type="text" class="input-field" placeholder="Search customers..." oninput="loadCustomers()">
|
||
<button class="clear-btn" id="clearCustSearch" onclick="clearCustSearch()">✕</button>
|
||
</div>
|
||
<div class="card">
|
||
<div id="custList"></div>
|
||
</div>
|
||
<button class="btn btn-primary" onclick="showCustForm()" style="margin-top:6px;">+ New Customer</button>
|
||
</div>
|
||
|
||
<!-- ═══════════════════════════════════════════════════════════════════════
|
||
CUSTOMER DETAIL VIEW
|
||
═══════════════════════════════════════════════════════════════════════ -->
|
||
<div id="custDetailView" class="tab-section" style="display:none;">
|
||
<div class="detail-header">
|
||
<button class="back-btn" onclick="showCustList()">←</button>
|
||
<div class="dh-info">
|
||
<div id="custDetailName" style="font-size:18px;font-weight:700;"></div>
|
||
</div>
|
||
</div>
|
||
<div class="card" id="custDetailContacts">
|
||
<div class="card-title">Contacts</div>
|
||
<div id="custContactsList"></div>
|
||
</div>
|
||
<div class="card" id="custDetailLocations">
|
||
<div class="card-title">Locations <span id="custLocCount" style="color:var(--accent2);font-weight:400;"></span></div>
|
||
<div id="custLocationsList"></div>
|
||
</div>
|
||
<div class="form-row" style="gap:10px;margin-bottom:10px;">
|
||
<button class="btn btn-outline btn-sm" onclick="editCustomer()" style="flex:1;">Edit</button>
|
||
<button class="btn btn-danger btn-sm" onclick="deleteCustomer()" style="flex:1;">Delete</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══════════════════════════════════════════════════════════════════════
|
||
CUSTOMER FORM VIEW (Create/Edit)
|
||
═══════════════════════════════════════════════════════════════════════ -->
|
||
<div id="custFormView" class="tab-section" style="display:none;">
|
||
<div class="detail-header">
|
||
<button class="back-btn" onclick="cancelCustForm()">←</button>
|
||
<div id="custFormTitle" style="font-size:18px;font-weight:700;">New Customer</div>
|
||
</div>
|
||
<div class="card">
|
||
<input id="custFormName" type="text" class="input-field" placeholder="Customer name *" required>
|
||
|
||
<div class="card-title" style="margin-top:8px;">Contacts</div>
|
||
<div id="custFormContacts"></div>
|
||
<button class="btn btn-outline btn-sm" onclick="addContactRow()" style="width:100%;margin-top:4px;">+ Add Contact</button>
|
||
|
||
<button class="btn btn-primary" id="custFormSaveBtn" onclick="saveCustomer()" style="margin-top:12px;">Save Customer</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══════════════════════════════════════════════════════════════════════
|
||
LOCATIONS LIST VIEW (for a customer)
|
||
═══════════════════════════════════════════════════════════════════════ -->
|
||
<div id="locListView" class="tab-section" style="display:none;">
|
||
<div class="detail-header">
|
||
<button class="back-btn" onclick="showCustList()">←</button>
|
||
<div class="dh-info">
|
||
<div id="locListCustName" style="font-size:18px;font-weight:700;"></div>
|
||
<div style="font-size:12px;color:var(--text2);">Locations</div>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div id="locList"></div>
|
||
</div>
|
||
<button class="btn btn-primary" onclick="showLocForm()" style="margin-top:6px;">+ New Location</button>
|
||
</div>
|
||
|
||
<!-- ═══════════════════════════════════════════════════════════════════════
|
||
LOCATION DETAIL VIEW
|
||
═══════════════════════════════════════════════════════════════════════ -->
|
||
<div id="locDetailView" class="tab-section" style="display:none;">
|
||
<div class="detail-header">
|
||
<button class="back-btn" onclick="backFromLocDetail()">←</button>
|
||
<div class="dh-info">
|
||
<div id="locDetailName" style="font-size:18px;font-weight:700;"></div>
|
||
<div id="locDetailCust" style="font-size:12px;color:var(--text2);"></div>
|
||
</div>
|
||
</div>
|
||
<div class="card" id="locDetailFields"></div>
|
||
<div class="card">
|
||
<div class="card-title">Rooms <span id="roomCount" style="color:var(--accent2);font-weight:400;"></span></div>
|
||
<div id="roomsList"></div>
|
||
<div id="roomAddRow" style="display:flex;gap:6px;margin-top:8px;">
|
||
<input id="newRoomName" type="text" class="input-field" placeholder="Room name" style="flex:2;margin-bottom:0;">
|
||
<input id="newRoomFloor" type="text" class="input-field" placeholder="Floor" style="flex:1;margin-bottom:0;">
|
||
<button class="btn btn-green btn-sm" onclick="addRoom()" style="flex-shrink:0;">+ Add</button>
|
||
</div>
|
||
</div>
|
||
<div class="form-row" style="gap:10px;margin-bottom:10px;">
|
||
<button class="btn btn-outline btn-sm" onclick="editLocation()" style="flex:1;">Edit</button>
|
||
<button class="btn btn-danger btn-sm" onclick="deleteLocation()" style="flex:1;">Delete</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══════════════════════════════════════════════════════════════════════
|
||
LOCATION FORM VIEW (Create/Edit)
|
||
═══════════════════════════════════════════════════════════════════════ -->
|
||
<div id="locFormView" class="tab-section" style="display:none;">
|
||
<div class="detail-header">
|
||
<button class="back-btn" onclick="cancelLocForm()">←</button>
|
||
<div id="locFormTitle" style="font-size:18px;font-weight:700;">New Location</div>
|
||
</div>
|
||
<div class="card">
|
||
<select id="locFormCustomer" class="input-field">
|
||
<option value="">Select customer *</option>
|
||
</select>
|
||
<input id="locFormName" type="text" class="input-field" placeholder="Location name *" required>
|
||
<input id="locFormAddress" type="text" class="input-field" placeholder="Address">
|
||
<div class="form-row">
|
||
<input id="locFormBldgName" type="text" class="input-field" placeholder="Building name">
|
||
<input id="locFormBldgNum" type="text" class="input-field" placeholder="Building #">
|
||
</div>
|
||
<div class="form-row">
|
||
<input id="locFormFloor" type="text" class="input-field" placeholder="Floor">
|
||
<input id="locFormTrailer" type="text" class="input-field" placeholder="Trailer #">
|
||
</div>
|
||
<input id="locFormSiteHours" type="text" class="input-field" placeholder="Site hours (e.g. M-F 8am-5pm)">
|
||
<textarea id="locFormAccess" class="input-field" placeholder="Access notes (gate codes, call-ahead)" rows="2"></textarea>
|
||
<textarea id="locFormWalking" class="input-field" placeholder="Walking directions" rows="2"></textarea>
|
||
<input id="locFormMapLink" type="url" class="input-field" placeholder="Map link (URL)">
|
||
<button class="btn btn-primary" id="locFormSaveBtn" onclick="saveLocation()" style="margin-top:6px;">Save Location</button>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- ═══════════════════════════════════════════════════════════════════════
|
||
TAB: DASHBOARD
|
||
═══════════════════════════════════════════════════════════════════════ -->
|
||
<div id="tabDashboard" class="tab-panel">
|
||
|
||
<!-- Stat cards -->
|
||
<div class="stats-grid three" id="statsGrid">
|
||
<div class="stat-card"><div class="stat-value" id="statAssets">—</div><div class="stat-label">Total<br>Assets</div></div>
|
||
<div class="stat-card"><div class="stat-value" id="statCheckins">—</div><div class="stat-label">Total<br>Check-ins</div></div>
|
||
<div class="stat-card"><div class="stat-value" id="statTechs">—</div><div class="stat-label">Active<br>Technicians</div></div>
|
||
</div>
|
||
|
||
<!-- By Category -->
|
||
<div class="card">
|
||
<div class="card-title">By Category</div>
|
||
<div id="statsCategories"></div>
|
||
</div>
|
||
|
||
<!-- By Status -->
|
||
<div class="card">
|
||
<div class="card-title">By Status</div>
|
||
<div id="statsStatuses"></div>
|
||
</div>
|
||
|
||
<!-- By Make/Model -->
|
||
<div class="card">
|
||
<div class="card-title">By Make / Manufacturer</div>
|
||
<div id="statsMakes"></div>
|
||
</div>
|
||
|
||
<!-- High Visit Rate -->
|
||
<div class="card" id="highVisitCard">
|
||
<div class="card-title">🔝 Most Visited Assets</div>
|
||
<div id="highVisitList"></div>
|
||
</div>
|
||
|
||
<!-- Quick Actions -->
|
||
<div class="card">
|
||
<div class="card-title">Quick Actions</div>
|
||
<div class="quick-actions">
|
||
<button class="qa-btn" onclick="exportCSV('assets')"><span class="qa-icon">📋</span> Assets CSV</button>
|
||
<button class="qa-btn" onclick="exportCSV('checkins')"><span class="qa-icon">📍</span> Check-ins CSV</button>
|
||
<button class="qa-btn" onclick="exportCSV('service')"><span class="qa-icon">📊</span> Service Summary</button>
|
||
<button class="qa-btn" onclick="loadDashboard()"><span class="qa-icon">🔄</span> Refresh</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Recent Activity Feed -->
|
||
<div class="card">
|
||
<div class="card-title">📝 Recent Activity</div>
|
||
<div id="activityFeed"></div>
|
||
<div style="margin-top:8px;text-align:center;">
|
||
<a href="javascript:void(0)" onclick="loadFullActivity()" style="color:var(--accent2);font-size:13px;font-weight:600;text-decoration:none;">View All →</a>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- ═══════════════════════════════════════════════════════════════════════
|
||
TAB: ACTIVITY FEED (Phase M)
|
||
═══════════════════════════════════════════════════════════════════════ -->
|
||
<div id="tabActivity" class="tab-panel">
|
||
<div class="card">
|
||
<div class="card-title">📜 Activity Feed</div>
|
||
<div class="search-bar">
|
||
<input id="actSearch" type="text" class="input-field" placeholder="Search activity..." oninput="loadActivity()" style="margin-bottom:0;">
|
||
<button class="clear-btn" id="clearActSearch" onclick="document.getElementById('actSearch').value='';loadActivity();">✕</button>
|
||
</div>
|
||
<div class="act-filter-row">
|
||
<select id="actUserFilter" class="input-field" onchange="loadActivity()">
|
||
<option value="">All Users</option>
|
||
</select>
|
||
<select id="actTypeFilter" class="input-field" onchange="loadActivity()">
|
||
<option value="">All Actions</option>
|
||
<option value="created">✨ Created</option>
|
||
<option value="updated">✏️ Updated</option>
|
||
<option value="deleted">🗑️ Deleted</option>
|
||
<option value="checkin">✓ Check-in</option>
|
||
</select>
|
||
</div>
|
||
<div id="actList"></div>
|
||
<div class="act-pagination" id="actPagination"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══════════════════════════════════════════════════════════════════════
|
||
TAB: REPORTS
|
||
<!-- ═══════════════════════════════════════════════════════════════════════
|
||
TAB: REPORTS
|
||
═══════════════════════════════════════════════════════════════════════ -->
|
||
<div id="tabReports" class="tab-panel">
|
||
|
||
<!-- Service Summary -->
|
||
<div class="card">
|
||
<div class="card-title">Service Summary</div>
|
||
<div style="margin-bottom:10px;">
|
||
<div class="form-row" style="margin-bottom:8px;">
|
||
<input type="date" class="input-field" id="reportDateFrom" style="font-size:13px;">
|
||
<input type="date" class="input-field" id="reportDateTo" style="font-size:13px;">
|
||
</div>
|
||
<button class="btn btn-primary btn-sm" onclick="loadReports()">Generate Report</button>
|
||
<button class="btn btn-outline btn-sm" onclick="exportServiceSummary()">⬇ Download CSV</button>
|
||
</div>
|
||
<div class="stats-grid" id="reportSummaryCards" style="margin-bottom:10px;">
|
||
<div class="stat-card"><div class="stat-value" id="rptAssets">—</div><div class="stat-label">Assets Serviced</div></div>
|
||
<div class="stat-card"><div class="stat-value" id="rptVisits">—</div><div class="stat-label">Total Visits</div></div>
|
||
<div class="stat-card"><div class="stat-value" id="rptAvgTime">—</div><div class="stat-label">Avg Time on Site</div></div>
|
||
<div class="stat-card"><div class="stat-value" id="rptTechs">—</div><div class="stat-label">Technicians</div></div>
|
||
</div>
|
||
<div id="reportServiceTable"></div>
|
||
</div>
|
||
|
||
<!-- Visit Frequency -->
|
||
<div class="card">
|
||
<div class="card-title">Visit Frequency</div>
|
||
<div id="reportVisitTable"></div>
|
||
</div>
|
||
|
||
<!-- Time on Site -->
|
||
<div class="card">
|
||
<div class="card-title">Time on Site</div>
|
||
<div class="report-subtitle">Per Technician</div>
|
||
<div id="reportTimeTech"></div>
|
||
<div class="report-subtitle" style="margin-top:12px;">Per Asset</div>
|
||
<div id="reportTimeAsset"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══════════════════════════════════════════════════════════════════════
|
||
TAB: SETTINGS
|
||
═══════════════════════════════════════════════════════════════════════ -->
|
||
<div id="tabSettings" class="tab-panel">
|
||
|
||
<!-- Categories -->
|
||
<div class="card" data-entity="categories">
|
||
<div class="card-header">
|
||
<div class="card-title">📂 Categories</div>
|
||
<button class="btn btn-sm btn-outline admin-only" data-action="settingsAdd" data-entity="categories">+ Add</button>
|
||
</div>
|
||
<div id="settingsCategories" class="settings-list"></div>
|
||
</div>
|
||
|
||
<!-- Makes & Models -->
|
||
<div class="card" data-entity="makes">
|
||
<div class="card-header">
|
||
<div class="card-title">🏭 Makes & Models</div>
|
||
<button class="btn btn-sm btn-outline admin-only" data-action="settingsAdd" data-entity="makes">+ Add Make</button>
|
||
</div>
|
||
<div id="settingsMakes" class="settings-list"></div>
|
||
</div>
|
||
|
||
<!-- Key Names -->
|
||
<div class="card" data-entity="key_names">
|
||
<div class="card-header">
|
||
<div class="card-title">🔑 Key Names</div>
|
||
<button class="btn btn-sm btn-outline admin-only" data-action="settingsAdd" data-entity="key_names">+ Add</button>
|
||
</div>
|
||
<div id="settingsKeyNames" class="settings-list"></div>
|
||
</div>
|
||
|
||
<!-- Key Types -->
|
||
<div class="card" data-entity="key_types">
|
||
<div class="card-header">
|
||
<div class="card-title">🔐 Key Types</div>
|
||
<button class="btn btn-sm btn-outline admin-only" data-action="settingsAdd" data-entity="key_types">+ Add</button>
|
||
</div>
|
||
<div id="settingsKeyTypes" class="settings-list"></div>
|
||
</div>
|
||
|
||
<!-- Badge Types -->
|
||
<div class="card" data-entity="badge_types">
|
||
<div class="card-header">
|
||
<div class="card-title">🪪 Security Badge Requirements</div>
|
||
<button class="btn btn-sm btn-outline admin-only" data-action="settingsAdd" data-entity="badge_types">+ Add</button>
|
||
</div>
|
||
<div id="settingsBadgeTypes" class="settings-list"></div>
|
||
</div>
|
||
|
||
<!-- User Accounts (admin only) -->
|
||
<div class="card admin-only" id="settingsUsersCard">
|
||
<div class="card-header">
|
||
<div class="card-title">👥 User Accounts</div>
|
||
<button class="btn btn-sm btn-outline" data-action="addUser">+ Add</button>
|
||
</div>
|
||
<div id="settingsUsers" class="settings-list"></div>
|
||
</div>
|
||
|
||
<!-- App Configuration -->
|
||
<div class="card">
|
||
<div class="card-title">⚙️ App Configuration</div>
|
||
<div class="settings-config">
|
||
<div class="setting-row">
|
||
<span>Theme</span>
|
||
<span class="setting-val">Dark</span>
|
||
</div>
|
||
<div class="setting-row">
|
||
<button class="btn btn-sm btn-danger admin-only" data-action="resetDatabase">🗑 Reset Database</button>
|
||
</div>
|
||
<div class="about-section">
|
||
<div class="about-text" style="font-size:12px;color:var(--text3);margin-top:12px;">
|
||
Canteen Asset Tracker v2.0<br>FastAPI + SQLite · Mobile-first
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</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="tabDashboard" onclick="switchTab('tabDashboard')">
|
||
<span class="tab-icon">📊</span> Dash
|
||
</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 = {}) {
|
||
// 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);
|
||
}
|
||
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 isAdmin() { return AppState.currentUser && AppState.currentUser.role === 'admin'; }
|
||
function isTechnician() { return AppState.currentUser && AppState.currentUser.role === 'technician'; }
|
||
|
||
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';
|
||
|
||
// Route guards: show/hide admin-only elements
|
||
document.querySelectorAll('.admin-only').forEach(el => {
|
||
el.style.display = isAdmin() ? '' : '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 result = await api('/api/auth/login', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ username, password }),
|
||
});
|
||
|
||
AppState.authToken = result.token;
|
||
AppState.currentUser = { id: result.id, username: result.username, role: result.role };
|
||
|
||
const remember = document.getElementById('loginRemember').checked;
|
||
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');
|
||
}
|
||
}
|
||
|
||
function doLogout() {
|
||
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();
|
||
}
|
||
}
|
||
});
|
||
|
||
// =========================================================================
|
||
// TECH OPTIONS: Load users for Assigned To dropdown (Phase M)
|
||
// =========================================================================
|
||
async function loadTechnicianOptions() {
|
||
try {
|
||
var users = await api('/api/users');
|
||
var sel = document.getElementById('editAssignedTo');
|
||
if (!sel) return;
|
||
var current = sel.value;
|
||
sel.innerHTML = '<option value="">Assigned To (none)</option>';
|
||
users.forEach(function(u) {
|
||
sel.innerHTML += '<option value="' + u.id + '">' + esc(u.username) + ' (' + u.role + ')</option>';
|
||
});
|
||
sel.value = current;
|
||
} catch (e) {
|
||
console.error('Failed to load users:', e);
|
||
}
|
||
}
|
||
|
||
// =========================================================================
|
||
// ACTIVITY FEED (Phase M)
|
||
// =========================================================================
|
||
var actPage = 0;
|
||
var ACT_PER_PAGE = 30;
|
||
|
||
var ACT_ICONS = {
|
||
created: '\u2728', updated: '\u270F\uFE0F', deleted: '\uD83D\uDDD1\uFE0F',
|
||
checkin: '\u2713', geofence: '\uD83D\uDCCD', customer: '\uD83C\uDFE2', user: '\uD83D\uDC64',
|
||
};
|
||
|
||
async function loadActivity() {
|
||
var userFilter = document.getElementById('actUserFilter').value;
|
||
var typeFilter = document.getElementById('actTypeFilter').value;
|
||
var searchQ = document.getElementById('actSearch').value.trim();
|
||
var clearBtn = document.getElementById('clearActSearch');
|
||
if (clearBtn) clearBtn.style.display = searchQ ? 'block' : 'none';
|
||
|
||
clearTimeout(loadActivity._debounce);
|
||
loadActivity._debounce = setTimeout(function() { _loadActivity(userFilter, typeFilter, searchQ); }, 200);
|
||
}
|
||
|
||
async function _loadActivity(userFilter, typeFilter, searchQ) {
|
||
var params = new URLSearchParams();
|
||
if (userFilter) params.set('user_id', userFilter);
|
||
if (typeFilter) params.set('entity_type', typeFilter);
|
||
params.set('limit', String(ACT_PER_PAGE));
|
||
params.set('offset', String(actPage * ACT_PER_PAGE));
|
||
|
||
try {
|
||
var items = await api('/api/activity?' + params.toString());
|
||
|
||
if (searchQ) {
|
||
var q = searchQ.toLowerCase();
|
||
items = items.filter(function(a) {
|
||
return (a.details && a.details.toLowerCase().indexOf(q) !== -1) ||
|
||
(a.user_name && a.user_name.toLowerCase().indexOf(q) !== -1) ||
|
||
(a.entity_type && a.entity_type.toLowerCase().indexOf(q) !== -1);
|
||
});
|
||
}
|
||
|
||
renderActivity(items);
|
||
renderActivityPagination(items.length);
|
||
} catch (e) {
|
||
document.getElementById('actList').innerHTML =
|
||
'<div class="empty-state">Failed to load activity</div>';
|
||
}
|
||
}
|
||
|
||
function renderActivity(items) {
|
||
var el = document.getElementById('actList');
|
||
if (!items.length) {
|
||
el.innerHTML = '<div class="empty-state">No activity yet</div>';
|
||
return;
|
||
}
|
||
el.innerHTML = items.map(function(a) {
|
||
var icon = ACT_ICONS[a.action] || '\uD83D\uDCCC';
|
||
var cssClass = a.action || 'other';
|
||
var text = a.details || (a.action + ' ' + a.entity_type + ' #' + a.entity_id);
|
||
var html = '<div class="activity-item">';
|
||
html += '<div class="act-icon ' + cssClass + '">' + icon + '</div>';
|
||
html += '<div class="act-info">';
|
||
html += '<div class="act-text">' + esc(text) + '</div>';
|
||
html += '<div class="act-time">' + timeAgo(a.created_at) + '</div>';
|
||
if (a.user_name) html += '<div class="act-user">by ' + esc(a.user_name) + '</div>';
|
||
html += '</div></div>';
|
||
return html;
|
||
}).join('');
|
||
}
|
||
|
||
function renderActivityPagination(count) {
|
||
var el = document.getElementById('actPagination');
|
||
var html = '';
|
||
if (actPage > 0) {
|
||
html += '<button class="btn btn-outline btn-sm" onclick="actPage--;loadActivity();">\u2190 Newer</button>';
|
||
}
|
||
if (count >= ACT_PER_PAGE) {
|
||
html += '<button class="btn btn-outline btn-sm" onclick="actPage++;loadActivity();">Older \u2192</button>';
|
||
}
|
||
el.innerHTML = html;
|
||
}
|
||
|
||
async function loadActivityUserFilter() {
|
||
try {
|
||
var users = await api('/api/users');
|
||
var sel = document.getElementById('actUserFilter');
|
||
if (!sel) return;
|
||
users.forEach(function(u) {
|
||
sel.innerHTML += '<option value="' + u.id + '">' + esc(u.username) + '</option>';
|
||
});
|
||
} catch (e) {
|
||
console.error('Failed to load user filter:', e);
|
||
}
|
||
}
|
||
|
||
// =========================================================================
|
||
// TECHNICIAN VIEW: My Assets (Phase M)
|
||
// =========================================================================
|
||
function showMyAssets() {
|
||
if (!isTechnician() || !AppState.currentUser) {
|
||
showToast('Only available for technicians', true);
|
||
return;
|
||
}
|
||
AppState._myAssetsFilter = AppState.currentUser.id;
|
||
assetFilters.assigned_to = AppState.currentUser.id;
|
||
switchTab('tabAssets');
|
||
loadAssets();
|
||
}
|
||
|
||
// =========================================================================
|
||
// GPS
|
||
// =========================================================================
|
||
function initGPS() {
|
||
const badge = document.getElementById('gpsBadge');
|
||
if (!navigator.geolocation) {
|
||
badge.className = 'gps-badge err';
|
||
badge.textContent = '📍 Unavailable';
|
||
return;
|
||
}
|
||
navigator.geolocation.getCurrentPosition(
|
||
pos => {
|
||
AppState.gpsLat = pos.coords.latitude;
|
||
AppState.gpsLng = pos.coords.longitude;
|
||
AppState.gpsAcc = pos.coords.accuracy;
|
||
badge.className = 'gps-badge ok';
|
||
badge.textContent = `📍 ${AppState.gpsLat.toFixed(4)}, ${AppState.gpsLng.toFixed(4)}`;
|
||
},
|
||
err => {
|
||
badge.className = 'gps-badge err';
|
||
badge.textContent = '📍 ' + err.message;
|
||
},
|
||
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 300000 }
|
||
);
|
||
}
|
||
|
||
// =========================================================================
|
||
// 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) {
|
||
AppState.currentTab = tabId;
|
||
|
||
// 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',
|
||
tabDashboard: 'tabDashboard',
|
||
};
|
||
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(); loadTechnicianOptions(); }
|
||
if (tabId === 'tabDashboard') loadDashboard();
|
||
if (tabId === 'tabActivity') { loadActivity(); loadActivityUserFilter(); }
|
||
|
||
// Map tab lifecycle
|
||
if (tabId === 'tabMap') {
|
||
initMap();
|
||
if (map) {
|
||
setTimeout(() => map.invalidateSize(), 150);
|
||
loadAssetPins(); // Refresh pins on every tab visit
|
||
}
|
||
startVisitTracking();
|
||
} else {
|
||
stopVisitTracking();
|
||
if (drawingGeofence) cancelGeofenceDraw();
|
||
}
|
||
if (tabId === 'tabReports') loadReports();
|
||
|
||
// 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 = {};
|
||
|
||
// ── 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();
|
||
stopManualPhoto();
|
||
|
||
if (mode === 'barcode') {
|
||
startScanning();
|
||
}
|
||
}
|
||
|
||
// ── Shared: Load settings dropdowns ─────────────────────────────────────
|
||
async function loadSettingsCache() {
|
||
if (settingsCache._loaded) return; // already loaded successfully
|
||
try {
|
||
const [cats, makes, models, keyNames, keyTypes, badgeTypes, customers] = await Promise.all([
|
||
api('/api/settings/categories'),
|
||
api('/api/settings/makes'),
|
||
api('/api/settings/models'),
|
||
api('/api/settings/key_names'),
|
||
api('/api/settings/key_types'),
|
||
api('/api/settings/badge_types'),
|
||
api('/api/customers'),
|
||
]);
|
||
settingsCache.categories = cats || [];
|
||
settingsCache.makes = makes || [];
|
||
settingsCache.models = models || [];
|
||
settingsCache.keyNames = keyNames || [];
|
||
settingsCache.keyTypes = keyTypes || [];
|
||
settingsCache.badgeTypes = badgeTypes || [];
|
||
settingsCache.customers = customers || [];
|
||
settingsCache._loaded = true;
|
||
} catch (e) {
|
||
console.error('Failed to load settings:', e);
|
||
// Don't cache empty results — leave fields undefined so retries work
|
||
settingsCache._loaded = false;
|
||
}
|
||
}
|
||
|
||
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;
|
||
try {
|
||
const locs = await api(`/api/locations?customer_id=${custId}`);
|
||
sel.innerHTML += (locs || []).map(l =>
|
||
`<option value="${l.id}">${esc(l.name)}</option>`).join('');
|
||
} 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
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
async function startScanning() {
|
||
if (addAssetMode !== 'barcode') return;
|
||
if (scanningActive) return;
|
||
try {
|
||
setScanStatus('Starting camera...', 'working');
|
||
if (stream) {
|
||
stream.getTracks().forEach(t => t.stop());
|
||
stream = null;
|
||
}
|
||
stream = await navigator.mediaDevices.getUserMedia({
|
||
video: { facingMode: { ideal: 'environment' }, width: { ideal: 1920 }, height: { ideal: 1080 } },
|
||
audio: false,
|
||
});
|
||
const video = document.getElementById('cameraVideo');
|
||
if (!video) return;
|
||
video.srcObject = stream;
|
||
await video.play();
|
||
document.getElementById('cameraPlaceholder').style.display = 'none';
|
||
video.style.display = 'block';
|
||
scanningActive = true;
|
||
|
||
if (!codeReader) codeReader = new ZXing.BrowserMultiFormatReader();
|
||
setScanStatus('Point camera at a barcode', 'success');
|
||
|
||
codeReader.decodeFromVideoElement(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 */ }
|
||
}
|
||
if (stream) {
|
||
stream.getTracks().forEach(t => t.stop());
|
||
stream = 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 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(asset.category || 'Other')} ·
|
||
<span class="status-tag ${asset.status}">${asset.status || 'active'}</span>
|
||
</div>
|
||
<div class="sr-actions">
|
||
<button class="btn btn-green btn-sm" onclick="showCheckinForm()" style="flex:1;">✓ Check In</button>
|
||
<button class="btn btn-outline btn-sm" onclick="switchTab('tabAssets');viewAsset(${asset.id});" style="flex:1;">View Details</button>
|
||
</div>
|
||
`;
|
||
|
||
document.getElementById('checkinCard').style.display = 'block';
|
||
}
|
||
|
||
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('checkinNotes').focus();
|
||
}
|
||
|
||
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,
|
||
};
|
||
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;
|
||
}
|
||
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);
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// 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; }
|
||
}
|
||
|
||
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';
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('file', blob, 'sticker.jpg');
|
||
const data = await api('/api/ocr', { method: 'POST', body: formData });
|
||
|
||
const el = document.getElementById('ocrResult');
|
||
el.style.display = 'block';
|
||
|
||
if (data.machine_id) {
|
||
const confLabel = data.confidence === 'high' ? '✅ High confidence' :
|
||
data.confidence === 'low' ? '⚠️ Low confidence' : '';
|
||
el.innerHTML = `
|
||
<div class="or-id">${esc(data.machine_id)}</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');
|
||
}
|
||
} catch (e) {
|
||
setOcrStatus('OCR failed: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
try {
|
||
const asset = await api('/api/assets', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
machine_id: machineId, name,
|
||
category: document.getElementById('ocrCatSelect').value || 'Other',
|
||
status: document.getElementById('ocrStatus').value,
|
||
}),
|
||
});
|
||
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;
|
||
// Show check-in option
|
||
document.getElementById('checkinCard').style.display = 'block';
|
||
} catch (e) {
|
||
showToast(e.message, true);
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// MANUAL MODE — Photo
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
let manualPhotoStream = null;
|
||
|
||
async function startManualPhoto() {
|
||
if (manualPhotoStream) return;
|
||
try {
|
||
manualPhotoStream = await navigator.mediaDevices.getUserMedia({
|
||
video: { facingMode: { ideal: 'environment' }, width: { ideal: 1920 }, height: { ideal: 1080 } },
|
||
audio: false,
|
||
});
|
||
const video = document.getElementById('manPhotoVideo');
|
||
if (!video) return;
|
||
video.srcObject = manualPhotoStream;
|
||
await video.play();
|
||
document.getElementById('manPhotoPlaceholder').style.display = 'none';
|
||
video.style.display = 'block';
|
||
document.getElementById('manPhotoCaptureRow').style.display = 'flex';
|
||
} catch (e) {
|
||
showToast('Camera failed: ' + e.message, true);
|
||
}
|
||
}
|
||
|
||
async function captureManualPhoto() {
|
||
const video = document.getElementById('manPhotoVideo');
|
||
if (!video || !manualPhotoStream) return;
|
||
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = video.videoWidth;
|
||
canvas.height = video.videoHeight;
|
||
canvas.getContext('2d').drawImage(video, 0, 0);
|
||
manualPhotoBlob = await new Promise(r => canvas.toBlob(r, 'image/jpeg', 0.85));
|
||
|
||
const url = URL.createObjectURL(manualPhotoBlob);
|
||
document.getElementById('manPhotoPreview').src = url;
|
||
document.getElementById('manPhotoPreview').style.display = 'block';
|
||
document.getElementById('manPhotoRetake').style.display = 'block';
|
||
stopManualPhoto();
|
||
}
|
||
|
||
function retakeManualPhoto() {
|
||
manualPhotoBlob = null;
|
||
document.getElementById('manPhotoPreview').style.display = 'none';
|
||
document.getElementById('manPhotoRetake').style.display = 'none';
|
||
startManualPhoto();
|
||
}
|
||
|
||
function stopManualPhoto() {
|
||
if (manualPhotoStream) {
|
||
manualPhotoStream.getTracks().forEach(t => t.stop());
|
||
manualPhotoStream = null;
|
||
}
|
||
const video = document.getElementById('manPhotoVideo');
|
||
if (video) video.style.display = 'none';
|
||
const ph = document.getElementById('manPhotoPlaceholder');
|
||
if (ph) ph.style.display = '';
|
||
const row = document.getElementById('manPhotoCaptureRow');
|
||
if (row) row.style.display = 'none';
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// 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 (manualPhotoBlob) {
|
||
try {
|
||
const fd = new FormData();
|
||
fd.append('file', manualPhotoBlob, 'photo.jpg');
|
||
const upData = await api('/api/upload/photo', { method: 'POST', body: fd });
|
||
photoPath = upData.path;
|
||
} 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 || '' : '';
|
||
|
||
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;
|
||
|
||
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);
|
||
}
|
||
|
||
// Settings are loaded after auth (in doLogin / initAuth)
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// ASSETS TAB — List, Filter, Detail, Edit, Import
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
// ── Category config ───────────────────────────────────────────────────
|
||
const CATEGORY_MAP = {
|
||
'Coffee': { icon: '☕', color: '#2e1a0a' },
|
||
'Cold Beverage': { icon: '🥤', color: '#0a1a2e' },
|
||
'Snacks': { icon: '🍿', color: '#2a2010' },
|
||
'Cold Food': { icon: '🥪', color: '#1a2e1a' },
|
||
'Market': { icon: '🏪', color: '#2e1a2e' },
|
||
'Smart Cooler': { icon: '🧊', color: '#1a2e2e' },
|
||
};
|
||
function catIcon(cat) {
|
||
const entry = CATEGORY_MAP[cat];
|
||
if (entry) return entry.icon;
|
||
if (cat) return cat.charAt(0).toUpperCase();
|
||
return '📦';
|
||
}
|
||
|
||
// ── Filter state ──────────────────────────────────────────────────────
|
||
let assetFilters = { category: null, status: null, make: null, q: '' };
|
||
let assetDebounce = null;
|
||
let cachedAssets = [];
|
||
|
||
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';
|
||
clearTimeout(assetDebounce);
|
||
assetDebounce = setTimeout(_loadAssets, 200);
|
||
}
|
||
|
||
async function _loadAssets() {
|
||
const params = new URLSearchParams();
|
||
if (assetFilters.category) params.set('category', assetFilters.category);
|
||
if (assetFilters.status) params.set('status', assetFilters.status);
|
||
if (assetFilters.make) params.set('make', assetFilters.make);
|
||
if (assetFilters.q) params.set('q', assetFilters.q);
|
||
params.set('limit', '100');
|
||
try {
|
||
const assets = await api('/api/assets?' + params.toString());
|
||
cachedAssets = assets;
|
||
renderAssetList(assets);
|
||
renderFilterPills(assets);
|
||
} catch (e) {
|
||
document.getElementById('assetList').innerHTML =
|
||
'<div class="empty-state">Failed to load assets</div>';
|
||
}
|
||
}
|
||
|
||
// _loadAssets patched by Phase M to support assigned_to filter
|
||
var __loadAssets_orig = _loadAssets;
|
||
_loadAssets = async function() {
|
||
var params = new URLSearchParams();
|
||
if (assetFilters.category) params.set('category', assetFilters.category);
|
||
if (assetFilters.status) params.set('status', assetFilters.status);
|
||
if (assetFilters.q) params.set('q', assetFilters.q);
|
||
if (assetFilters.assigned_to) params.set('assigned_to', assetFilters.assigned_to);
|
||
params.set('limit', '100');
|
||
|
||
try {
|
||
var assets = await api('/api/assets?' + params.toString());
|
||
// Client-side filter fallback if server doesn't support assigned_to
|
||
var filtered = assetFilters.assigned_to
|
||
? assets.filter(function(a) { return a.assigned_to === assetFilters.assigned_to; })
|
||
: assets;
|
||
renderAssetList(filtered);
|
||
renderFilterPills(assets);
|
||
} 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.map(a => {
|
||
const icon = catIcon(a.category);
|
||
const catClass = a.category ? 'cat-' + a.category.replace(/[^a-zA-Z0-9]/g, '-') : '';
|
||
return `
|
||
<div class="asset-item" onclick="viewAsset(${a.id})">
|
||
<div class="ai-icon ${catClass}">${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) : ''}
|
||
· <span class="status-tag ${a.status || 'active'}">${a.status || 'active'}</span>
|
||
</div>
|
||
</div>
|
||
<div class="ai-arrow">›</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderFilterPills(assets) {
|
||
const cats = new Set(), stats = new Set(), makes = new Set();
|
||
assets.forEach(a => {
|
||
if (a.category) cats.add(a.category);
|
||
if (a.status) stats.add(a.status);
|
||
if (a.make) makes.add(a.make);
|
||
});
|
||
|
||
let html = '<span class="pill' + (!assetFilters.category && !assetFilters.status && !assetFilters.make ? ' active' : '') + '" onclick="setFilter(null,null,null)">All</span>';
|
||
|
||
// Category pills
|
||
[...cats].sort().forEach(c => {
|
||
html += `<span class="pill${assetFilters.category === c ? ' active' : ''}" onclick="setFilter('${esc(c)}',null,null)">📂 ${esc(c)}</span>`;
|
||
});
|
||
|
||
// Status pills
|
||
[...stats].sort().forEach(s => {
|
||
html += `<span class="pill${assetFilters.status === s ? ' active' : ''}" onclick="setFilter(null,'${esc(s)}',null)">${s === 'active' ? '🟢' : s === 'maintenance' ? '🟡' : '🔴'} ${s}</span>`;
|
||
});
|
||
|
||
// Make pills
|
||
[...makes].sort().forEach(m => {
|
||
html += `<span class="pill${assetFilters.make === m ? ' active' : ''}" onclick="setFilter(null,null,'${esc(m)}')">🏭 ${esc(m)}</span>`;
|
||
});
|
||
|
||
document.getElementById('filterPills').innerHTML = html;
|
||
}
|
||
|
||
function setFilter(category, status, make) {
|
||
assetFilters.category = category;
|
||
assetFilters.status = status;
|
||
assetFilters.make = make;
|
||
loadAssets();
|
||
}
|
||
|
||
function clearAssetSearch() {
|
||
document.getElementById('assetSearch').value = '';
|
||
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)} · <span class="status-tag ${a.status || 'active'}">${a.status || 'active'}</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('Description', a.description),
|
||
df('Category', a.category),
|
||
df('Make', a.make),
|
||
df('Model', a.model),
|
||
df('Status', statusBadge(a.status)),
|
||
].filter(Boolean).join('');
|
||
|
||
// Customer / Location / Technician
|
||
if (a.customer_name) {
|
||
document.getElementById('detailFields').innerHTML += df('Customer',
|
||
`<span class="df-link" onclick="switchTab('tabCustomers')">${esc(a.customer_name)}</span>`);
|
||
}
|
||
if (a.location_name) {
|
||
document.getElementById('detailFields').innerHTML += df('Location',
|
||
`<span class="df-link" onclick="switchTab('tabCustomers')">${esc(a.location_name)}</span>`);
|
||
}
|
||
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';
|
||
}
|
||
|
||
// 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';
|
||
}
|
||
|
||
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 `<span class="status-tag ${status || 'active'}">${status || 'active'}</span>`;
|
||
}
|
||
|
||
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');
|
||
}).catch(() => {});
|
||
}
|
||
|
||
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>';
|
||
}
|
||
}
|
||
|
||
// ── Edit ──────────────────────────────────────────────────────────────
|
||
async function editAsset() {
|
||
if (!AppState.currentAssetId) return;
|
||
try {
|
||
const a = await api('/api/assets/' + AppState.currentAssetId);
|
||
showEditView();
|
||
document.getElementById('editFormCard').innerHTML = renderEditForm(a);
|
||
// Phase M: set assigned_to value after DOM is ready
|
||
setTimeout(function() {
|
||
var sel = document.getElementById('editAssignedTo');
|
||
if (sel && a.assigned_to) sel.value = a.assigned_to;
|
||
}, 0);
|
||
loadTechnicianOptions();
|
||
} 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="Description" 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>
|
||
<option value="retired" ${a.status === 'retired' ? 'selected' : ''}>Retired</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 || '')}">
|
||
<input id="editParking" type="text" class="input-field" placeholder="Parking location" value="${esc(a.parking_location || '')}">
|
||
<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');
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// CUSTOMERS & LOCATIONS TAB
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
const CustState = {
|
||
view: 'list', // list | detail | form | locations | loc-detail | loc-form
|
||
custId: null,
|
||
locId: null,
|
||
allCusts: [],
|
||
allLocs: {}, // { custId: [locations] }
|
||
formMode: 'create', // 'create' | 'edit'
|
||
locFormMode: 'create', // 'create' | 'edit'
|
||
formContacts: [], // [{name, phone, email}]
|
||
};
|
||
|
||
// ── View management ───────────────────────────────────────────────────
|
||
function showCustView(name) {
|
||
const views = ['custListView','custDetailView','custFormView','locListView','locDetailView','locFormView'];
|
||
views.forEach(v => { const el = document.getElementById(v); if (el) el.style.display = 'none'; });
|
||
const active = document.getElementById(name);
|
||
if (active) active.style.display = 'block';
|
||
CustState.view = name === 'custListView' ? 'list'
|
||
: name === 'custDetailView' ? 'detail'
|
||
: name === 'custFormView' ? 'form'
|
||
: name === 'locListView' ? 'locations'
|
||
: name === 'locDetailView' ? 'loc-detail'
|
||
: name === 'locFormView' ? 'loc-form' : 'list';
|
||
}
|
||
|
||
// ── Customers List ────────────────────────────────────────────────────
|
||
async function loadCustomers() {
|
||
if (AppState.currentTab !== 'tabCustomers') return;
|
||
const q = (document.getElementById('custSearch').value || '').trim().toLowerCase();
|
||
const clearBtn = document.getElementById('clearCustSearch');
|
||
if (clearBtn) clearBtn.style.display = q ? 'block' : 'none';
|
||
|
||
try {
|
||
const custs = await api('/api/customers');
|
||
CustState.allCusts = custs;
|
||
|
||
// Fetch all locations to get counts
|
||
try {
|
||
const allLocs = await api('/api/locations');
|
||
CustState.allLocs = {};
|
||
allLocs.forEach(l => {
|
||
const cid = l.customer_id;
|
||
if (cid) {
|
||
if (!CustState.allLocs[cid]) CustState.allLocs[cid] = [];
|
||
CustState.allLocs[cid].push(l);
|
||
}
|
||
});
|
||
} catch (e) { CustState.allLocs = {}; }
|
||
|
||
renderCustList(q);
|
||
} catch (e) {
|
||
document.getElementById('custList').innerHTML =
|
||
'<div class="empty-state">Failed to load customers: ' + esc(e.message) + '</div>';
|
||
}
|
||
}
|
||
|
||
function renderCustList(query) {
|
||
let custs = CustState.allCusts;
|
||
if (query) {
|
||
custs = custs.filter(c => c.name.toLowerCase().includes(query));
|
||
}
|
||
const el = document.getElementById('custList');
|
||
if (!custs.length) {
|
||
el.innerHTML = '<div class="empty-state"><div class="es-icon">🏢</div>No customers found</div>';
|
||
return;
|
||
}
|
||
el.innerHTML = custs.map(c => {
|
||
const locCount = (CustState.allLocs[c.id] || []).length;
|
||
const firstContact = (c.contacts && c.contacts.length) ? c.contacts[0] : null;
|
||
const contactInfo = firstContact
|
||
? [firstContact.phone, firstContact.email].filter(Boolean).join(' · ') || firstContact.name
|
||
: '';
|
||
return `
|
||
<div class="cust-card" onclick="viewCustomer(${c.id})">
|
||
<div class="cc-icon">🏢</div>
|
||
<div class="cc-info">
|
||
<div class="cc-name">${esc(c.name)}</div>
|
||
<div class="cc-meta">
|
||
${locCount} location${locCount !== 1 ? 's' : ''}
|
||
${contactInfo ? ' · ' + esc(contactInfo) : ''}
|
||
</div>
|
||
</div>
|
||
<div class="cc-arrow">›</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function clearCustSearch() {
|
||
document.getElementById('custSearch').value = '';
|
||
loadCustomers();
|
||
}
|
||
|
||
function showCustList() {
|
||
showCustView('custListView');
|
||
loadCustomers();
|
||
}
|
||
|
||
// ── Customer Detail ───────────────────────────────────────────────────
|
||
async function viewCustomer(id) {
|
||
CustState.custId = id;
|
||
try {
|
||
const cust = await api(`/api/customers/${id}`);
|
||
const locs = CustState.allLocs[id] || [];
|
||
|
||
showCustView('custDetailView');
|
||
document.getElementById('custDetailName').textContent = cust.name;
|
||
|
||
// Contacts
|
||
const contactsEl = document.getElementById('custContactsList');
|
||
if (!cust.contacts || !cust.contacts.length) {
|
||
contactsEl.innerHTML = '<div class="empty-state">No contacts added</div>';
|
||
} else {
|
||
contactsEl.innerHTML = cust.contacts.map(c => `
|
||
<div class="contact-disp">
|
||
<div class="cd-icon">👤</div>
|
||
<div class="cd-info">
|
||
<div class="cd-name">${esc(c.name || 'Unnamed')}</div>
|
||
<div class="cd-detail">
|
||
${c.phone ? `<a href="tel:${esc(c.phone)}">📞 ${esc(c.phone)}</a>` : ''}
|
||
${c.phone && c.email ? ' · ' : ''}
|
||
${c.email ? `<a href="mailto:${esc(c.email)}">📧 ${esc(c.email)}</a>` : ''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
// Locations
|
||
document.getElementById('custLocCount').textContent = locs.length ? `(${locs.length})` : '';
|
||
const locsEl = document.getElementById('custLocationsList');
|
||
if (!locs.length) {
|
||
locsEl.innerHTML = '<div class="empty-state">No locations for this customer</div>';
|
||
} else {
|
||
locsEl.innerHTML = locs.map(l => `
|
||
<div class="loc-card" onclick="event.stopPropagation();viewLocation(${l.id})">
|
||
<div class="lc-icon">📍</div>
|
||
<div class="lc-info">
|
||
<div class="lc-name">${esc(l.name)}</div>
|
||
<div class="lc-meta">${esc(l.address || '')}${l.building_name ? ' · ' + esc(l.building_name) : ''}</div>
|
||
</div>
|
||
<div class="lc-arrow">›</div>
|
||
</div>
|
||
`).join('') +
|
||
`<button class="btn btn-outline btn-sm" onclick="event.stopPropagation();showLocFormForCust(${id})" style="width:100%;margin-top:8px;">+ Add Location</button>`;
|
||
}
|
||
} catch (e) {
|
||
showToast(e.message, true);
|
||
showCustList();
|
||
}
|
||
}
|
||
|
||
// ── Customer Form (Create/Edit) ───────────────────────────────────────
|
||
function showCustForm() {
|
||
CustState.formMode = 'create';
|
||
CustState.custId = null;
|
||
CustState.formContacts = [];
|
||
showCustView('custFormView');
|
||
document.getElementById('custFormTitle').textContent = 'New Customer';
|
||
document.getElementById('custFormName').value = '';
|
||
document.getElementById('custFormSaveBtn').textContent = 'Save Customer';
|
||
renderContactForm();
|
||
}
|
||
|
||
async function editCustomer() {
|
||
try {
|
||
const cust = await api(`/api/customers/${CustState.custId}`);
|
||
CustState.formMode = 'edit';
|
||
CustState.formContacts = (cust.contacts || []).map(c => ({ name: c.name || '', phone: c.phone || '', email: c.email || '' }));
|
||
showCustView('custFormView');
|
||
document.getElementById('custFormTitle').textContent = 'Edit Customer';
|
||
document.getElementById('custFormName').value = cust.name;
|
||
document.getElementById('custFormSaveBtn').textContent = 'Update Customer';
|
||
renderContactForm();
|
||
} catch (e) {
|
||
showToast(e.message, true);
|
||
}
|
||
}
|
||
|
||
function cancelCustForm() {
|
||
showCustView(CustState.formMode === 'edit' ? 'custDetailView' : 'custListView');
|
||
}
|
||
|
||
function renderContactForm() {
|
||
const el = document.getElementById('custFormContacts');
|
||
if (!CustState.formContacts.length) {
|
||
CustState.formContacts.push({ name: '', phone: '', email: '' });
|
||
}
|
||
el.innerHTML = CustState.formContacts.map((c, i) => `
|
||
<div class="contact-row">
|
||
${CustState.formContacts.length > 1 ? `<button class="cr-remove" onclick="removeContact(${i})">✕</button>` : ''}
|
||
<input type="text" class="input-field" placeholder="Contact name" value="${esc(c.name)}" oninput="updateContact(${i},'name',this.value)" style="margin-bottom:0;">
|
||
<div class="form-row" style="margin-bottom:0;">
|
||
<input type="tel" class="input-field" placeholder="Phone" value="${esc(c.phone)}" oninput="updateContact(${i},'phone',this.value)" style="margin-bottom:0;">
|
||
<input type="email" class="input-field" placeholder="Email" value="${esc(c.email)}" oninput="updateContact(${i},'email',this.value)" style="margin-bottom:0;">
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function addContactRow() {
|
||
CustState.formContacts.push({ name: '', phone: '', email: '' });
|
||
renderContactForm();
|
||
}
|
||
|
||
function removeContact(i) {
|
||
if (CustState.formContacts.length <= 1) return;
|
||
CustState.formContacts.splice(i, 1);
|
||
renderContactForm();
|
||
}
|
||
|
||
function updateContact(i, field, value) {
|
||
CustState.formContacts[i][field] = value;
|
||
}
|
||
|
||
async function saveCustomer() {
|
||
const name = document.getElementById('custFormName').value.trim();
|
||
if (!name) { showToast('Customer name is required', true); return; }
|
||
|
||
const contacts = CustState.formContacts
|
||
.filter(c => c.name || c.phone || c.email)
|
||
.map(c => ({ name: c.name.trim(), phone: c.phone.trim(), email: c.email.trim() }));
|
||
|
||
const payload = { name, contacts };
|
||
|
||
try {
|
||
if (CustState.formMode === 'create') {
|
||
const cust = await api('/api/customers', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
showToast('Customer created!');
|
||
CustState.custId = cust.id;
|
||
await loadCustomers();
|
||
viewCustomer(cust.id);
|
||
} else {
|
||
await api(`/api/customers/${CustState.custId}`, {
|
||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
showToast('Customer updated!');
|
||
await loadCustomers();
|
||
viewCustomer(CustState.custId);
|
||
}
|
||
} catch (e) {
|
||
showToast(e.message, true);
|
||
}
|
||
}
|
||
|
||
async function deleteCustomer() {
|
||
const cust = await api(`/api/customers/${CustState.custId}`);
|
||
const confirmed = await showModal(
|
||
'Delete Customer',
|
||
`Delete "${cust.name}" and all its locations? This cannot be undone.`,
|
||
'Delete'
|
||
);
|
||
if (!confirmed) return;
|
||
try {
|
||
await api(`/api/customers/${CustState.custId}`, { method: 'DELETE' });
|
||
showToast('Customer deleted');
|
||
showCustList();
|
||
} catch (e) {
|
||
showToast(e.message, true);
|
||
}
|
||
}
|
||
|
||
// ── Locations: List for a Customer ────────────────────────────────────
|
||
async function showLocationsForCust(custId) {
|
||
CustState.custId = custId;
|
||
try {
|
||
const cust = await api(`/api/customers/${custId}`);
|
||
const locs = CustState.allLocs[custId] || [];
|
||
showCustView('locListView');
|
||
document.getElementById('locListCustName').textContent = cust.name;
|
||
renderLocList(locs);
|
||
} catch (e) {
|
||
showToast(e.message, true);
|
||
}
|
||
}
|
||
|
||
function renderLocList(locs) {
|
||
const el = document.getElementById('locList');
|
||
if (!locs.length) {
|
||
el.innerHTML = '<div class="empty-state">No locations for this customer</div>';
|
||
return;
|
||
}
|
||
el.innerHTML = locs.map(l => `
|
||
<div class="loc-card" onclick="viewLocation(${l.id})">
|
||
<div class="lc-icon">📍</div>
|
||
<div class="lc-info">
|
||
<div class="lc-name">${esc(l.name)}</div>
|
||
<div class="lc-meta">${esc(l.address || '')}${l.building_name ? ' · ' + esc(l.building_name) : ''}${(l.rooms || []).length ? ' · ' + l.rooms.length + ' room' + (l.rooms.length !== 1 ? 's' : '') : ''}</div>
|
||
</div>
|
||
<div class="lc-arrow">›</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
// ── Location Detail ───────────────────────────────────────────────────
|
||
async function viewLocation(locId) {
|
||
CustState.locId = locId;
|
||
try {
|
||
const loc = await api(`/api/locations/${locId}`);
|
||
showCustView('locDetailView');
|
||
|
||
// Find customer name
|
||
const cust = CustState.allCusts.find(c => c.id === loc.customer_id);
|
||
document.getElementById('locDetailName').textContent = loc.name;
|
||
document.getElementById('locDetailCust').textContent = cust ? cust.name : '';
|
||
|
||
const fields = [
|
||
['Address', loc.address],
|
||
['Building Name', loc.building_name],
|
||
['Building Number', loc.building_number],
|
||
['Floor', loc.floor],
|
||
['Trailer Number', loc.trailer_number],
|
||
['Site Hours', loc.site_hours],
|
||
['Access Notes', loc.access_notes],
|
||
['Walking Directions', loc.walking_directions],
|
||
];
|
||
|
||
let html = '';
|
||
fields.forEach(([label, val]) => {
|
||
if (val) {
|
||
html += `<div class="detail-field"><div class="df-label">${label}</div><div class="df-value">${esc(val)}</div></div>`;
|
||
}
|
||
});
|
||
|
||
if (loc.map_link) {
|
||
html += `<div class="detail-field">
|
||
<div class="df-label">Map Link</div>
|
||
<div class="df-value"><a href="${esc(loc.map_link)}" target="_blank" rel="noopener" style="color:var(--accent2);">🗺️ Get Directions</a></div>
|
||
</div>`;
|
||
}
|
||
|
||
if (!fields.some(([,v]) => v) && !loc.map_link) {
|
||
html = '<div class="empty-state">No additional details</div>';
|
||
}
|
||
|
||
document.getElementById('locDetailFields').innerHTML = html;
|
||
|
||
// Rooms
|
||
renderRoomsList(loc.rooms || []);
|
||
} catch (e) {
|
||
showToast(e.message, true);
|
||
showCustList();
|
||
}
|
||
}
|
||
|
||
function backFromLocDetail() {
|
||
// Go back to wherever we came from (customer detail or locations list)
|
||
if (CustState.custId) {
|
||
viewCustomer(CustState.custId);
|
||
} else {
|
||
showCustList();
|
||
}
|
||
}
|
||
|
||
// ── Location Form (Create/Edit) ──────────────────────────────────────
|
||
async function showLocForm() {
|
||
CustState.locFormMode = 'create';
|
||
CustState.locId = null;
|
||
await populateCustomerSelect();
|
||
showCustView('locFormView');
|
||
document.getElementById('locFormTitle').textContent = 'New Location';
|
||
document.getElementById('locFormSaveBtn').textContent = 'Save Location';
|
||
clearLocForm();
|
||
// Pre-select customer if we're in the context of one
|
||
if (CustState.custId) {
|
||
document.getElementById('locFormCustomer').value = CustState.custId;
|
||
}
|
||
}
|
||
|
||
function showLocFormForCust(custId) {
|
||
CustState.custId = custId;
|
||
showLocForm();
|
||
}
|
||
|
||
async function editLocation() {
|
||
try {
|
||
const loc = await api(`/api/locations/${CustState.locId}`);
|
||
CustState.locFormMode = 'edit';
|
||
await populateCustomerSelect();
|
||
showCustView('locFormView');
|
||
document.getElementById('locFormTitle').textContent = 'Edit Location';
|
||
document.getElementById('locFormSaveBtn').textContent = 'Update Location';
|
||
document.getElementById('locFormCustomer').value = loc.customer_id || '';
|
||
document.getElementById('locFormName').value = loc.name || '';
|
||
document.getElementById('locFormAddress').value = loc.address || '';
|
||
document.getElementById('locFormBldgName').value = loc.building_name || '';
|
||
document.getElementById('locFormBldgNum').value = loc.building_number || '';
|
||
document.getElementById('locFormFloor').value = loc.floor || '';
|
||
document.getElementById('locFormTrailer').value = loc.trailer_number || '';
|
||
document.getElementById('locFormSiteHours').value = loc.site_hours || '';
|
||
document.getElementById('locFormAccess').value = loc.access_notes || '';
|
||
document.getElementById('locFormWalking').value = loc.walking_directions || '';
|
||
document.getElementById('locFormMapLink').value = loc.map_link || '';
|
||
} catch (e) {
|
||
showToast(e.message, true);
|
||
}
|
||
}
|
||
|
||
function clearLocForm() {
|
||
document.getElementById('locFormName').value = '';
|
||
document.getElementById('locFormAddress').value = '';
|
||
document.getElementById('locFormBldgName').value = '';
|
||
document.getElementById('locFormBldgNum').value = '';
|
||
document.getElementById('locFormFloor').value = '';
|
||
document.getElementById('locFormTrailer').value = '';
|
||
document.getElementById('locFormSiteHours').value = '';
|
||
document.getElementById('locFormAccess').value = '';
|
||
document.getElementById('locFormWalking').value = '';
|
||
document.getElementById('locFormMapLink').value = '';
|
||
}
|
||
|
||
function cancelLocForm() {
|
||
if (CustState.locFormMode === 'edit') {
|
||
viewLocation(CustState.locId);
|
||
} else if (CustState.custId) {
|
||
showLocationsForCust(CustState.custId);
|
||
} else {
|
||
showCustList();
|
||
}
|
||
}
|
||
|
||
async function populateCustomerSelect() {
|
||
const sel = document.getElementById('locFormCustomer');
|
||
const currentVal = sel.value;
|
||
sel.innerHTML = '<option value="">Select customer *</option>';
|
||
try {
|
||
const custs = await api('/api/customers');
|
||
custs.forEach(c => {
|
||
const opt = document.createElement('option');
|
||
opt.value = c.id;
|
||
opt.textContent = c.name;
|
||
sel.appendChild(opt);
|
||
});
|
||
if (currentVal) sel.value = currentVal;
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
async function saveLocation() {
|
||
const custId = parseInt(document.getElementById('locFormCustomer').value);
|
||
const name = document.getElementById('locFormName').value.trim();
|
||
if (!custId) { showToast('Please select a customer', true); return; }
|
||
if (!name) { showToast('Location name is required', true); return; }
|
||
|
||
const payload = {
|
||
customer_id: custId,
|
||
name,
|
||
address: document.getElementById('locFormAddress').value.trim(),
|
||
building_name: document.getElementById('locFormBldgName').value.trim(),
|
||
building_number: document.getElementById('locFormBldgNum').value.trim(),
|
||
floor: document.getElementById('locFormFloor').value.trim(),
|
||
trailer_number: document.getElementById('locFormTrailer').value.trim(),
|
||
site_hours: document.getElementById('locFormSiteHours').value.trim(),
|
||
access_notes: document.getElementById('locFormAccess').value.trim(),
|
||
walking_directions: document.getElementById('locFormWalking').value.trim(),
|
||
map_link: document.getElementById('locFormMapLink').value.trim(),
|
||
};
|
||
|
||
try {
|
||
if (CustState.locFormMode === 'create') {
|
||
const loc = await api('/api/locations', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
showToast('Location created!');
|
||
CustState.locId = loc.id;
|
||
// Refresh location cache
|
||
try {
|
||
const allLocs = await api('/api/locations');
|
||
CustState.allLocs = {};
|
||
allLocs.forEach(l => {
|
||
const cid = l.customer_id;
|
||
if (cid) {
|
||
if (!CustState.allLocs[cid]) CustState.allLocs[cid] = [];
|
||
CustState.allLocs[cid].push(l);
|
||
}
|
||
});
|
||
} catch (e) { /* ignore */ }
|
||
viewLocation(loc.id);
|
||
} else {
|
||
await api(`/api/locations/${CustState.locId}`, {
|
||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
showToast('Location updated!');
|
||
viewLocation(CustState.locId);
|
||
}
|
||
} catch (e) {
|
||
showToast(e.message, true);
|
||
}
|
||
}
|
||
|
||
async function deleteLocation() {
|
||
const loc = await api(`/api/locations/${CustState.locId}`);
|
||
const confirmed = await showModal(
|
||
'Delete Location',
|
||
`Delete "${loc.name}" and all its rooms? This cannot be undone.`,
|
||
'Delete'
|
||
);
|
||
if (!confirmed) return;
|
||
try {
|
||
await api(`/api/locations/${CustState.locId}`, { method: 'DELETE' });
|
||
showToast('Location deleted');
|
||
// Refresh cache
|
||
try {
|
||
const allLocs = await api('/api/locations');
|
||
CustState.allLocs = {};
|
||
allLocs.forEach(l => {
|
||
const cid = l.customer_id;
|
||
if (cid) {
|
||
if (!CustState.allLocs[cid]) CustState.allLocs[cid] = [];
|
||
CustState.allLocs[cid].push(l);
|
||
}
|
||
});
|
||
} catch (e) { /* ignore */ }
|
||
if (CustState.custId) {
|
||
viewCustomer(CustState.custId);
|
||
} else {
|
||
showCustList();
|
||
}
|
||
} catch (e) {
|
||
showToast(e.message, true);
|
||
}
|
||
}
|
||
|
||
// ── Rooms ─────────────────────────────────────────────────────────────
|
||
function renderRoomsList(rooms) {
|
||
document.getElementById('roomCount').textContent = rooms.length ? `(${rooms.length})` : '';
|
||
const el = document.getElementById('roomsList');
|
||
if (!rooms.length) {
|
||
el.innerHTML = '<div class="empty-state">No rooms added yet</div>';
|
||
return;
|
||
}
|
||
// Store room data for safe access by edit handlers
|
||
rooms.forEach(r => { CustState._roomData = CustState._roomData || {}; CustState._roomData[r.id] = r; });
|
||
el.innerHTML = rooms.map(r => `
|
||
<div class="room-item" id="roomRow${r.id}">
|
||
<span class="ri-name">🚪 ${esc(r.name)}</span>
|
||
${r.floor ? `<span class="ri-floor">Floor ${esc(r.floor)}</span>` : ''}
|
||
<span class="ri-actions">
|
||
<button class="ri-btn edit" onclick="startEditRoom(${r.id})" title="Edit">✏️</button>
|
||
<button class="ri-btn del" onclick="deleteRoom(${r.id})" title="Delete">🗑️</button>
|
||
</span>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
async function addRoom() {
|
||
const name = document.getElementById('newRoomName').value.trim();
|
||
if (!name) { showToast('Room name is required', true); return; }
|
||
const floor = document.getElementById('newRoomFloor').value.trim();
|
||
|
||
try {
|
||
await api('/api/rooms', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ location_id: CustState.locId, name, floor }),
|
||
});
|
||
showToast('Room added!');
|
||
document.getElementById('newRoomName').value = '';
|
||
document.getElementById('newRoomFloor').value = '';
|
||
viewLocation(CustState.locId); // refresh
|
||
} catch (e) {
|
||
showToast(e.message, true);
|
||
}
|
||
}
|
||
|
||
function startEditRoom(roomId) {
|
||
const data = CustState._roomData && CustState._roomData[roomId];
|
||
if (!data) return;
|
||
const row = document.getElementById('roomRow' + roomId);
|
||
if (!row) return;
|
||
row.innerHTML = `
|
||
<div class="room-edit-row" style="width:100%;">
|
||
<input id="editRoomName${roomId}" type="text" class="input-field" value="${esc(data.name)}" placeholder="Room name" style="flex:2;">
|
||
<input id="editRoomFloor${roomId}" type="text" class="input-field" value="${esc(data.floor||'')}" placeholder="Floor" style="flex:1;">
|
||
<button class="btn btn-green btn-sm" onclick="saveEditRoom(${roomId})">✓</button>
|
||
<button class="btn btn-outline btn-sm" onclick="viewLocation(${CustState.locId})">✕</button>
|
||
</div>
|
||
`;
|
||
document.getElementById('editRoomName' + roomId).focus();
|
||
}
|
||
|
||
async function saveEditRoom(roomId) {
|
||
const name = document.getElementById('editRoomName' + roomId).value.trim();
|
||
if (!name) { showToast('Room name is required', true); return; }
|
||
const floor = document.getElementById('editRoomFloor' + roomId).value.trim();
|
||
|
||
try {
|
||
await api(`/api/rooms/${roomId}`, {
|
||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name, floor }),
|
||
});
|
||
showToast('Room updated!');
|
||
viewLocation(CustState.locId); // refresh
|
||
} catch (e) {
|
||
showToast(e.message, true);
|
||
}
|
||
}
|
||
|
||
async function deleteRoom(roomId) {
|
||
const confirmed = await showModal(
|
||
'Delete Room',
|
||
'Delete this room? This cannot be undone.',
|
||
'Delete'
|
||
);
|
||
if (!confirmed) return;
|
||
try {
|
||
await api(`/api/rooms/${roomId}`, { method: 'DELETE' });
|
||
showToast('Room deleted');
|
||
viewLocation(CustState.locId); // refresh
|
||
} catch (e) {
|
||
showToast(e.message, true);
|
||
}
|
||
}
|
||
|
||
// ── Hook into tab switching ───────────────────────────────────────────
|
||
document.addEventListener('tabChange', function(e) {
|
||
if (e.detail && e.detail.tabId === 'tabCustomers') {
|
||
if (CustState.view === 'list' || !CustState.view) {
|
||
showCustList();
|
||
}
|
||
}
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// DASHBOARD TAB
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
async function loadDashboard() {
|
||
try {
|
||
// Fetch stats and activity in parallel
|
||
const [stats, activity] = await Promise.all([
|
||
api('/api/stats'),
|
||
api('/api/activity?limit=15'),
|
||
]);
|
||
|
||
// --- Stat cards ---
|
||
document.getElementById('statAssets').textContent = stats.total_assets || 0;
|
||
document.getElementById('statCheckins').textContent = stats.total_checkins || 0;
|
||
const activeTechs = (stats.time_on_site && stats.time_on_site.length) || 0;
|
||
document.getElementById('statTechs').textContent = activeTechs;
|
||
|
||
// --- By Category ---
|
||
const catEntries = Object.entries(stats.by_category || {});
|
||
const maxCat = Math.max(1, ...catEntries.map(([, c]) => c));
|
||
document.getElementById('statsCategories').innerHTML = catEntries.length
|
||
? catEntries.map(([cat, cnt]) => `
|
||
<div class="stat-bar">
|
||
<span class="sb-label">${esc(cat)}</span>
|
||
<div class="sb-track"><div class="sb-fill" style="width:${(cnt/maxCat*100).toFixed(0)}%;"></div></div>
|
||
<span class="sb-count">${cnt}</span>
|
||
</div>
|
||
`).join('')
|
||
: '<div class="empty-state">No data</div>';
|
||
|
||
// --- By Status ---
|
||
const statEntries = Object.entries(stats.by_status || {});
|
||
const maxStat = Math.max(1, ...statEntries.map(([, c]) => c));
|
||
document.getElementById('statsStatuses').innerHTML = statEntries.length
|
||
? statEntries.map(([st, cnt]) => {
|
||
const color = st === 'active' ? 'var(--green)' : st === 'maintenance' ? 'var(--amber)' : 'var(--red)';
|
||
return `
|
||
<div class="stat-bar">
|
||
<span class="sb-label">${esc(st)}</span>
|
||
<div class="sb-track"><div class="sb-fill" style="width:${(cnt/maxStat*100).toFixed(0)}%;background:${color};"></div></div>
|
||
<span class="sb-count">${cnt}</span>
|
||
</div>`;
|
||
}).join('')
|
||
: '<div class="empty-state">No data</div>';
|
||
|
||
// --- By Make ---
|
||
const makeEntries = Object.entries(stats.by_make || {});
|
||
const maxMake = Math.max(1, ...makeEntries.map(([, c]) => c));
|
||
document.getElementById('statsMakes').innerHTML = makeEntries.length
|
||
? makeEntries.map(([make, cnt]) => `
|
||
<div class="stat-bar">
|
||
<span class="sb-label">${esc(make)}</span>
|
||
<div class="sb-track"><div class="sb-fill" style="width:${(cnt/maxMake*100).toFixed(0)}%;background:var(--accent2);"></div></div>
|
||
<span class="sb-count">${cnt}</span>
|
||
</div>
|
||
`).join('')
|
||
: '<div class="empty-state">No manufacturer data</div>';
|
||
|
||
// --- High Visit Rate ---
|
||
const topVisited = stats.top_visited || [];
|
||
document.getElementById('highVisitList').innerHTML = topVisited.length
|
||
? topVisited.map((v, i) => {
|
||
const rank = i + 1;
|
||
const rankCls = rank === 1 ? 'r1' : rank <= 3 ? 'r2' : 'rn';
|
||
const lastDate = v.last_visit_date ? formatDate(v.last_visit_date) : '—';
|
||
return `
|
||
<div class="hv-row" onclick="navToAsset('${esc(v.machine_id || '')}')">
|
||
<div class="hv-rank ${rankCls}">${rank}</div>
|
||
<div class="hv-info">
|
||
<div class="hv-name">${esc(v.name)}</div>
|
||
<div class="hv-meta">${esc(v.machine_id || '—')} · Last: ${lastDate}</div>
|
||
</div>
|
||
<div class="hv-visits">${v.visit_count}</div>
|
||
</div>`;
|
||
}).join('')
|
||
: '<div class="empty-state">No visit data yet</div>';
|
||
|
||
// --- Recent Activity ---
|
||
renderActivityFeed(activity || []);
|
||
|
||
} catch (e) {
|
||
console.error('Dashboard error:', e);
|
||
showToast('Failed to load dashboard: ' + e.message, true);
|
||
}
|
||
|
||
// Phase M: Add My Assets quick link for technicians
|
||
if (isTechnician() && AppState.currentUser) {
|
||
var grid = document.getElementById('statsGrid');
|
||
if (grid && !document.getElementById('myAssetsLink')) {
|
||
var card = document.createElement('div');
|
||
card.id = 'myAssetsLink';
|
||
card.className = 'stat-card';
|
||
card.style.cursor = 'pointer';
|
||
card.onclick = showMyAssets;
|
||
card.innerHTML = '<div class="stat-value">👤</div><div class="stat-label">My Assets</div>';
|
||
grid.appendChild(card);
|
||
}
|
||
}
|
||
}
|
||
|
||
function renderActivityFeed(activities) {
|
||
const el = document.getElementById('activityFeed');
|
||
if (!activities.length) {
|
||
el.innerHTML = '<div class="empty-state">No recent activity</div>';
|
||
return;
|
||
}
|
||
const ICON_MAP = { created: '✨', updated: '✏️', deleted: '🗑️', checked_in: '📍', checked_out: '🚪', login: '🔑' };
|
||
el.innerHTML = activities.slice(0, 10).map(a => {
|
||
const action = a.action || '';
|
||
const icon = ICON_MAP[action] || '📌';
|
||
const userName = esc(a.user_name || 'System');
|
||
const detail = esc(a.details || `${action} ${a.entity_type || ''} #${a.entity_id || ''}`);
|
||
return `
|
||
<div class="activity-item">
|
||
<div class="act-icon ${esc(action)}">${icon}</div>
|
||
<div class="act-text">
|
||
<div class="act-desc"><span class="act-user">${userName}</span> ${detail}</div>
|
||
<div class="act-time">${timeAgo(a.created_at)}</div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
async function loadFullActivity() {
|
||
try {
|
||
const activities = await api('/api/activity?limit=100');
|
||
renderActivityFeed(activities);
|
||
document.getElementById('activityFeed').scrollIntoView({ behavior: 'smooth' });
|
||
} catch (e) {
|
||
showToast('Failed to load activity: ' + e.message, true);
|
||
}
|
||
}
|
||
|
||
async function exportCSV(type) {
|
||
const urls = {
|
||
assets: '/api/export/assets',
|
||
checkins: '/api/export/checkins',
|
||
service: '/api/export/service-summary',
|
||
};
|
||
const url = urls[type];
|
||
if (!url) return;
|
||
try {
|
||
showToast('Downloading...');
|
||
const opts = {};
|
||
if (AppState.authToken) {
|
||
opts.headers = { 'Authorization': 'Bearer ' + AppState.authToken };
|
||
}
|
||
const res = await fetch(url, opts);
|
||
if (!res.ok) throw new Error(`Export failed: ${res.status}`);
|
||
const blob = await res.blob();
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = `${type}.csv`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(a.href);
|
||
showToast(`${type} CSV downloaded`);
|
||
} catch (e) {
|
||
showToast('Export error: ' + e.message, true);
|
||
}
|
||
}
|
||
|
||
async function navToAsset(machineId) {
|
||
if (!machineId) return;
|
||
try {
|
||
// Search for asset by machine_id
|
||
const results = await api(`/api/assets/search?q=${encodeURIComponent(machineId)}`);
|
||
if (results && results.length > 0) {
|
||
AppState.currentAssetId = results[0].id;
|
||
switchTab('tabAssets');
|
||
// Delay to let the tab switch, then show detail
|
||
setTimeout(() => viewAsset(results[0].id), 100);
|
||
} else {
|
||
showToast('Asset not found', true);
|
||
}
|
||
} catch (e) {
|
||
showToast('Navigation error: ' + e.message, true);
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// REPORTS TAB
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
let reportVisitsCache = [];
|
||
let reportSortState = { key: null, dir: 1 };
|
||
|
||
async function loadReports() {
|
||
const df = document.getElementById('reportDateFrom').value;
|
||
const dt = document.getElementById('reportDateTo').value;
|
||
|
||
const params = new URLSearchParams();
|
||
if (df) params.set('date_from', df);
|
||
if (dt) params.set('date_to', dt);
|
||
params.set('limit', '1000');
|
||
|
||
try {
|
||
const visits = await api('/api/visits?' + params.toString());
|
||
reportVisitsCache = visits;
|
||
renderServiceSummary(visits);
|
||
renderVisitFrequency(visits);
|
||
renderTimeOnSite(visits);
|
||
} catch (e) {
|
||
showToast('Failed to load reports: ' + e.message, true);
|
||
}
|
||
}
|
||
|
||
function renderServiceSummary(visits) {
|
||
const assetSet = new Set(visits.map(v => v.asset_id));
|
||
const userSet = new Set(visits.map(v => v.user_id));
|
||
const totalSecs = visits.reduce((sum, v) => {
|
||
if (v.checkout_time && v.checkin_time) {
|
||
return sum + ((new Date(v.checkout_time) - new Date(v.checkin_time)) / 1000);
|
||
}
|
||
return sum;
|
||
}, 0);
|
||
const avgMin = visits.length ? Math.round(totalSecs / visits.length / 60) : 0;
|
||
|
||
document.getElementById('rptAssets').textContent = assetSet.size;
|
||
document.getElementById('rptVisits').textContent = visits.length;
|
||
document.getElementById('rptAvgTime').textContent = avgMin + ' min';
|
||
document.getElementById('rptTechs').textContent = userSet.size;
|
||
|
||
// Build per-asset summary table
|
||
const assetMap = {};
|
||
visits.forEach(v => {
|
||
const key = v.asset_id || '?';
|
||
if (!assetMap[key]) {
|
||
assetMap[key] = { name: v.asset_name || v.machine_id || 'Unknown', visits: 0, last: null };
|
||
}
|
||
assetMap[key].visits++;
|
||
if (!assetMap[key].last || v.checkin_time > assetMap[key].last) {
|
||
assetMap[key].last = v.checkin_time;
|
||
}
|
||
});
|
||
const rows = Object.values(assetMap).sort((a, b) => b.visits - a.visits);
|
||
const html = rows.length ? `
|
||
<table class="rpt-table">
|
||
<thead><tr>
|
||
<th>Asset</th>
|
||
<th class="rpt-number">Visits</th>
|
||
<th>Last Visit</th>
|
||
</tr></thead>
|
||
<tbody>${rows.map(r => `
|
||
<tr>
|
||
<td>${esc(r.name)}</td>
|
||
<td class="rpt-number">${r.visits}</td>
|
||
<td>${formatDate(r.last)}</td>
|
||
</tr>`).join('')}</tbody>
|
||
</table>` : '<div class="empty-state">No visits in range</div>';
|
||
document.getElementById('reportServiceTable').innerHTML = html;
|
||
}
|
||
|
||
function renderVisitFrequency(visits) {
|
||
const assetMap = {};
|
||
visits.forEach(v => {
|
||
const key = v.asset_id || '?';
|
||
if (!assetMap[key]) {
|
||
assetMap[key] = { name: v.asset_name || v.machine_id || 'Unknown', visits: 0, last: null, techs: new Set() };
|
||
}
|
||
assetMap[key].visits++;
|
||
if (!assetMap[key].last || v.checkin_time > assetMap[key].last) {
|
||
assetMap[key].last = v.checkin_time;
|
||
}
|
||
if (v.user_name) assetMap[key].techs.add(v.user_name);
|
||
});
|
||
|
||
let rows = Object.values(assetMap).sort((a, b) => b.visits - a.visits);
|
||
|
||
if (reportSortState.key) {
|
||
const k = reportSortState.key;
|
||
const d = reportSortState.dir;
|
||
if (k === 'asset') rows.sort((a, b) => d * a.name.localeCompare(b.name));
|
||
else if (k === 'visits') rows.sort((a, b) => d * (a.visits - b.visits));
|
||
else if (k === 'last') {
|
||
rows.sort((a, b) => {
|
||
const da = a.last ? new Date(a.last).getTime() : 0;
|
||
const db = b.last ? new Date(b.last).getTime() : 0;
|
||
return d * (da - db);
|
||
});
|
||
} else if (k === 'tech') rows.sort((a, b) => d * (a.techs.size - b.techs.size));
|
||
}
|
||
|
||
function sortArrow(k) {
|
||
if (reportSortState.key !== k) return ' <span class="sort-arrow">↕</span>';
|
||
return reportSortState.dir === 1 ? ' <span class="sort-arrow asc">▲</span>' : ' <span class="sort-arrow desc">▼</span>';
|
||
}
|
||
|
||
const tableHtml = rows.length ? `
|
||
<table class="rpt-table">
|
||
<thead><tr>
|
||
<th class="sortable" onclick="sortReport('asset')">Asset${sortArrow('asset')}</th>
|
||
<th class="sortable rpt-number" onclick="sortReport('visits')">Visits${sortArrow('visits')}</th>
|
||
<th class="sortable" onclick="sortReport('last')">Last Visit${sortArrow('last')}</th>
|
||
<th class="sortable rpt-number" onclick="sortReport('tech')">Techs${sortArrow('tech')}</th>
|
||
</tr></thead>
|
||
<tbody>${rows.map(r => `
|
||
<tr>
|
||
<td>${esc(r.name)}</td>
|
||
<td class="rpt-number">${r.visits}</td>
|
||
<td>${formatDate(r.last)}</td>
|
||
<td class="rpt-number">${r.techs.size}</td>
|
||
</tr>`).join('')}</tbody>
|
||
</table>` : '<div class="empty-state">No visits in range</div>';
|
||
|
||
document.getElementById('reportVisitTable').innerHTML = tableHtml;
|
||
}
|
||
|
||
function sortReport(key) {
|
||
if (reportSortState.key === key) {
|
||
reportSortState.dir *= -1;
|
||
} else {
|
||
reportSortState.key = key;
|
||
reportSortState.dir = 1;
|
||
}
|
||
renderVisitFrequency(reportVisitsCache);
|
||
}
|
||
|
||
function renderTimeOnSite(visits) {
|
||
// Per technician
|
||
const techMap = {};
|
||
visits.forEach(v => {
|
||
if (!v.user_name) return;
|
||
const key = v.user_name;
|
||
if (!techMap[key]) techMap[key] = { visits: 0, totalSecs: 0 };
|
||
techMap[key].visits++;
|
||
if (v.checkout_time && v.checkin_time) {
|
||
techMap[key].totalSecs += (new Date(v.checkout_time) - new Date(v.checkin_time)) / 1000;
|
||
}
|
||
});
|
||
let techRows = Object.entries(techMap).map(([name, d]) => ({
|
||
name, visits: d.visits, totalMin: Math.round(d.totalSecs / 60), avgMin: d.visits ? Math.round(d.totalSecs / d.visits / 60) : 0,
|
||
}));
|
||
const maxAvgTech = Math.max(1, ...techRows.map(r => r.avgMin));
|
||
techRows.sort((a, b) => b.totalMin - a.totalMin);
|
||
|
||
const techHtml = techRows.length ? `
|
||
<table class="rpt-table">
|
||
<thead><tr>
|
||
<th>Technician</th>
|
||
<th class="rpt-number">Visits</th>
|
||
<th class="rpt-number">Total Time</th>
|
||
<th style="width:50%;">Avg Time</th>
|
||
</tr></thead>
|
||
<tbody>${techRows.map(r => {
|
||
const pct = (r.avgMin / maxAvgTech * 100).toFixed(0);
|
||
return `<tr>
|
||
<td>${esc(r.name)}</td>
|
||
<td class="rpt-number">${r.visits}</td>
|
||
<td class="rpt-number">${r.totalMin} min</td>
|
||
<td><div class="rpt-bar-wrap">
|
||
<div class="rpt-bar-track"><div class="rpt-bar-fill" style="width:${pct}%;"></div></div>
|
||
<span class="rpt-bar-val">${r.avgMin} min</span>
|
||
</div></td>
|
||
</tr>`;
|
||
}).join('')}</tbody>
|
||
</table>` : '<div class="empty-state">No visit data available</div>';
|
||
document.getElementById('reportTimeTech').innerHTML = techHtml;
|
||
|
||
// Per asset
|
||
const assetMap = {};
|
||
visits.forEach(v => {
|
||
const key = v.asset_id || '?';
|
||
if (!assetMap[key]) {
|
||
assetMap[key] = { name: v.asset_name || v.machine_id || 'Unknown', visits: 0, totalSecs: 0 };
|
||
}
|
||
assetMap[key].visits++;
|
||
if (v.checkout_time && v.checkin_time) {
|
||
assetMap[key].totalSecs += (new Date(v.checkout_time) - new Date(v.checkin_time)) / 1000;
|
||
}
|
||
});
|
||
let assetRows = Object.entries(assetMap).map(([id, d]) => ({
|
||
name: d.name, visits: d.visits, totalMin: Math.round(d.totalSecs / 60), avgMin: d.visits ? Math.round(d.totalSecs / d.visits / 60) : 0,
|
||
}));
|
||
const maxAvgAsset = Math.max(1, ...assetRows.map(r => r.avgMin));
|
||
assetRows.sort((a, b) => b.totalMin - a.totalMin);
|
||
|
||
const assetHtml = assetRows.length ? `
|
||
<table class="rpt-table">
|
||
<thead><tr>
|
||
<th>Asset</th>
|
||
<th class="rpt-number">Visits</th>
|
||
<th class="rpt-number">Total Time</th>
|
||
<th style="width:50%;">Avg Time</th>
|
||
</tr></thead>
|
||
<tbody>${assetRows.map(r => {
|
||
const pct = (r.avgMin / maxAvgAsset * 100).toFixed(0);
|
||
return `<tr>
|
||
<td>${esc(r.name)}</td>
|
||
<td class="rpt-number">${r.visits}</td>
|
||
<td class="rpt-number">${r.totalMin} min</td>
|
||
<td><div class="rpt-bar-wrap">
|
||
<div class="rpt-bar-track"><div class="rpt-bar-fill" style="width:${pct}%;"></div></div>
|
||
<span class="rpt-bar-val">${r.avgMin} min</span>
|
||
</div></td>
|
||
</tr>`;
|
||
}).join('')}</tbody>
|
||
</table>` : '<div class="empty-state">No visit data available</div>';
|
||
document.getElementById('reportTimeAsset').innerHTML = assetHtml;
|
||
}
|
||
|
||
function exportServiceSummary() {
|
||
if (!reportVisitsCache.length) {
|
||
showToast('No data to export. Generate a report first.', true);
|
||
return;
|
||
}
|
||
// Build CSV client-side from the filtered data
|
||
const assetMap = {};
|
||
reportVisitsCache.forEach(v => {
|
||
const key = v.asset_id || '?';
|
||
if (!assetMap[key]) {
|
||
assetMap[key] = {
|
||
asset: v.asset_name || v.machine_id || 'Unknown',
|
||
visits: 0, last: null, techs: new Set(), totalSecs: 0,
|
||
};
|
||
}
|
||
assetMap[key].visits++;
|
||
if (!assetMap[key].last || v.checkin_time > assetMap[key].last) assetMap[key].last = v.checkin_time;
|
||
if (v.user_name) assetMap[key].techs.add(v.user_name);
|
||
if (v.checkout_time && v.checkin_time) {
|
||
assetMap[key].totalSecs += (new Date(v.checkout_time) - new Date(v.checkin_time)) / 1000;
|
||
}
|
||
});
|
||
const csv = 'Asset,Visits,Last Visit,Technicians,Avg Time (min)\n'
|
||
+ Object.values(assetMap).map(r => [
|
||
'"' + r.asset.replace(/"/g, '""') + '"',
|
||
r.visits,
|
||
'"' + (r.last ? formatDate(r.last) : '') + '"',
|
||
r.techs.size,
|
||
r.visits ? Math.round(r.totalSecs / r.visits / 60) : 0,
|
||
].join(',')).join('\n');
|
||
|
||
const blob = new Blob([csv], { type: 'text/csv' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = 'service_summary.csv';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
showToast('CSV downloaded');
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// MAP TAB — Leaflet map with pins, geofences, heatmap, auto-visit
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
let map = null;
|
||
let assetMarkers = []; // { asset, marker }
|
||
let geofenceLayers = []; // { geofence, polygon }
|
||
let heatLayer = null;
|
||
let heatVisible = false;
|
||
let drawnItems = null; // Leaflet FeatureGroup for drawn polygons
|
||
let drawControl = null;
|
||
let drawingGeofence = false;
|
||
let geoWatchId = null;
|
||
let visitTimers = {}; // { asset_id: { startTime, lastSeen } }
|
||
const VISIT_THRESHOLD_M = 50;
|
||
const VISIT_DURATION_MIN = 10;
|
||
|
||
// Category-to-color mapping for asset pins
|
||
const CAT_COLORS = {
|
||
'Furniture': '#4ade80',
|
||
'Appliances': '#60a5fa',
|
||
'Utensils & Serveware': '#fbbf24',
|
||
'Utensils & Serveware': '#fbbf24',
|
||
'Equipment': '#f87171',
|
||
'Other': '#a78bfa',
|
||
};
|
||
|
||
function catColor(cat) {
|
||
return CAT_COLORS[cat] || '#a78bfa';
|
||
}
|
||
|
||
// Category icon emoji for markers
|
||
const CAT_MARKER_EMOJI = {
|
||
'Furniture': '🪑', 'Appliances': '🔌', 'Utensils & Serveware': '🍴',
|
||
'Utensils & Serveware': '🍴', 'Equipment': '⚙️', 'Other': '📦',
|
||
};
|
||
|
||
function catEmoji(cat) {
|
||
return CAT_MARKER_EMOJI[cat] || '📦';
|
||
}
|
||
|
||
// DivIcon with colored circle + emoji
|
||
function makeAssetIcon(asset) {
|
||
const color = catColor(asset.category);
|
||
const emoji = catEmoji(asset.category);
|
||
return L.divIcon({
|
||
html: `<div style="width:32px;height:32px;border-radius:50%;background:${color};display:flex;align-items:center;justify-content:center;font-size:16px;border:2px solid #fff;box-shadow:0 2px 6px rgba(0,0,0,0.5);">${emoji}</div>`,
|
||
className: '',
|
||
iconSize: [32, 32],
|
||
iconAnchor: [16, 16],
|
||
popupAnchor: [0, -18],
|
||
});
|
||
}
|
||
|
||
// Initialize the map
|
||
function initMap() {
|
||
if (map) return;
|
||
|
||
const container = document.getElementById('mapContainer');
|
||
if (!container) return;
|
||
|
||
const lat = AppState.gpsLat || 40.7128;
|
||
const lng = AppState.gpsLng || -74.0060;
|
||
const zoom = AppState.gpsLat ? 15 : 13;
|
||
|
||
map = L.map('mapContainer', {
|
||
center: [lat, lng],
|
||
zoom: zoom,
|
||
zoomControl: true,
|
||
attributionControl: false,
|
||
});
|
||
|
||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||
maxZoom: 19,
|
||
}).addTo(map);
|
||
|
||
// FeatureGroup for drawn geofences
|
||
drawnItems = new L.FeatureGroup();
|
||
map.addLayer(drawnItems);
|
||
|
||
// Draw control (hidden by default, shown when user taps "Add Geofence")
|
||
drawControl = new L.Control.Draw({
|
||
draw: {
|
||
polygon: {
|
||
allowIntersection: false,
|
||
showArea: true,
|
||
shapeOptions: { color: '#3388ff', weight: 2 },
|
||
},
|
||
polyline: false,
|
||
rectangle: false,
|
||
circle: false,
|
||
circlemarker: false,
|
||
marker: false,
|
||
},
|
||
edit: { featureGroup: drawnItems },
|
||
});
|
||
// Don't add to map yet — only when drawing mode active
|
||
|
||
// Handle drawn polygon
|
||
map.on(L.Draw.Event.CREATED, function (e) {
|
||
drawnItems.addLayer(e.layer);
|
||
// Show the save/cancel row
|
||
document.getElementById('geofenceColorRow').style.display = 'flex';
|
||
});
|
||
|
||
// Handle edits
|
||
map.on(L.Draw.Event.EDITED, function () {
|
||
syncEditedGeofences();
|
||
});
|
||
|
||
map.on(L.Draw.Event.DELETED, function () {
|
||
syncDeletedGeofences();
|
||
});
|
||
|
||
// Invalidate size when tab becomes visible
|
||
setTimeout(() => map.invalidateSize(), 100);
|
||
|
||
loadAssetPins();
|
||
loadGeofences();
|
||
}
|
||
|
||
// Load all assets with coordinates and place pins
|
||
async function loadAssetPins() {
|
||
try {
|
||
const assets = await api('/api/assets?limit=1000');
|
||
clearAssetMarkers();
|
||
const withCoords = assets.filter(a => a.latitude != null && a.longitude != null);
|
||
withCoords.forEach(asset => {
|
||
addAssetMarker(asset);
|
||
});
|
||
// Update heatmap layer if visible
|
||
if (heatVisible) await loadHeatmapData();
|
||
} catch (e) {
|
||
console.error('Failed to load asset pins:', e);
|
||
}
|
||
}
|
||
|
||
function clearAssetMarkers() {
|
||
assetMarkers.forEach(({ marker }) => {
|
||
if (map) map.removeLayer(marker);
|
||
});
|
||
assetMarkers = [];
|
||
}
|
||
|
||
function addAssetMarker(asset) {
|
||
if (!map) return;
|
||
const icon = makeAssetIcon(asset);
|
||
const marker = L.marker([asset.latitude, asset.longitude], { icon }).addTo(map);
|
||
|
||
const addrParts = [
|
||
asset.address, asset.building_name,
|
||
asset.building_number ? `#${asset.building_number}` : '',
|
||
asset.floor ? `Floor ${asset.floor}` : ''
|
||
].filter(Boolean);
|
||
const addr = addrParts.length ? addrParts.join(', ') : 'No address';
|
||
|
||
const dirLink = `https://www.google.com/maps/dir/?api=1&destination=${asset.latitude},${asset.longitude}`;
|
||
|
||
marker.bindPopup(`
|
||
<div style="min-width:180px;">
|
||
<div style="font-size:15px;font-weight:700;margin-bottom:4px;">${esc(asset.name)}</div>
|
||
<div style="font-size:11px;color:var(--text2);margin-bottom:6px;">
|
||
${asset.machine_id ? esc(asset.machine_id) + ' · ' : ''}${esc(asset.category || 'Other')}
|
||
${asset.status ? ` · <span style="color:${asset.status==='active'?'var(--green)':asset.status==='maintenance'?'var(--amber)':'var(--red)'};">${esc(asset.status)}</span>` : ''}
|
||
</div>
|
||
<div style="font-size:11px;color:var(--text2);margin-bottom:8px;">📍 ${esc(addr)}</div>
|
||
<div style="display:flex;gap:6px;flex-wrap:wrap;">
|
||
<a href="${dirLink}" target="_blank" rel="noopener" style="display:inline-flex;align-items:center;gap:4px;font-size:12px;font-weight:600;padding:6px 12px;border-radius:8px;background:var(--accent);color:#fff;text-decoration:none;">🧭 Directions</a>
|
||
<button onclick="switchTab('tabAssets');viewAsset(${asset.id});" style="font-size:12px;font-weight:600;padding:6px 12px;border-radius:8px;background:var(--card2);color:var(--text);border:1px solid var(--border);cursor:pointer;">📋 Details</button>
|
||
</div>
|
||
</div>
|
||
`);
|
||
|
||
assetMarkers.push({ asset, marker });
|
||
}
|
||
|
||
// Center map on user GPS
|
||
function centerOnGPS() {
|
||
if (!map) return;
|
||
if (AppState.gpsLat) {
|
||
map.setView([AppState.gpsLat, AppState.gpsLng], 16);
|
||
// Add a pulsing blue dot for current location
|
||
if (!map._userMarker) {
|
||
map._userMarker = L.circleMarker([AppState.gpsLat, AppState.gpsLng], {
|
||
radius: 8, fillColor: '#5b6ef7', color: '#fff', weight: 2,
|
||
fillOpacity: 0.9,
|
||
}).addTo(map);
|
||
} else {
|
||
map._userMarker.setLatLng([AppState.gpsLat, AppState.gpsLng]);
|
||
}
|
||
} else {
|
||
showToast('GPS location not available yet', true);
|
||
}
|
||
}
|
||
|
||
// Toggle asset pins visibility
|
||
function togglePins() {
|
||
const chip = document.getElementById('chipPins');
|
||
const show = !chip.classList.contains('active');
|
||
chip.classList.toggle('active', show);
|
||
assetMarkers.forEach(({ marker }) => {
|
||
if (show) marker.addTo(map);
|
||
else map.removeLayer(marker);
|
||
});
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// GEOFFENCE MANAGEMENT
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
function toggleGeofenceDraw() {
|
||
if (!map) return;
|
||
const chip = document.getElementById('chipGeo');
|
||
|
||
if (drawingGeofence) {
|
||
cancelGeofenceDraw();
|
||
return;
|
||
}
|
||
|
||
drawingGeofence = true;
|
||
chip.classList.add('active');
|
||
chip.textContent = '✏️ Drawing...';
|
||
map.addControl(drawControl);
|
||
drawnItems.clearLayers();
|
||
document.getElementById('geofenceColorRow').style.display = 'none';
|
||
new L.Draw.Polygon(map, drawControl.options.draw.polygon).enable();
|
||
}
|
||
|
||
function cancelGeofenceDraw() {
|
||
drawingGeofence = false;
|
||
const chip = document.getElementById('chipGeo');
|
||
chip.classList.remove('active');
|
||
chip.textContent = '✏️ Add Geofence';
|
||
if (drawControl) map.removeControl(drawControl);
|
||
drawnItems.clearLayers();
|
||
document.getElementById('geofenceColorRow').style.display = 'none';
|
||
}
|
||
|
||
async function saveDrawnGeofence() {
|
||
const layers = drawnItems.getLayers();
|
||
if (layers.length === 0) {
|
||
showToast('Draw a polygon on the map first', true);
|
||
return;
|
||
}
|
||
const polygon = layers[0];
|
||
const latlngs = polygon.getLatLngs()[0];
|
||
|
||
// Leaflet uses [lat, lng] arrays; backend expects {lat, lng} objects
|
||
const points = latlngs.map(ll => ({ lat: ll.lat, lng: ll.lng }));
|
||
const color = document.getElementById('geofenceColor').value;
|
||
|
||
const name = prompt('Geofence name:', 'Zone ' + (geofenceLayers.length + 1));
|
||
if (!name) return;
|
||
|
||
// Ask about user assignment
|
||
let user_ids = [];
|
||
try {
|
||
const users = await api('/api/users');
|
||
if (users && users.length > 0) {
|
||
let html = users.map(u => `
|
||
<label style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--border);cursor:pointer;">
|
||
<input type="checkbox" value="${u.id}" style="width:18px;height:18px;accent-color:var(--accent);">
|
||
<span>
|
||
<span style="color:var(--text);font-size:14px;">${esc(u.username)}</span>
|
||
<span style="color:var(--text3);font-size:11px;margin-left:6px;">(${esc(u.role)})</span>
|
||
</span>
|
||
</label>
|
||
`).join('');
|
||
const container = document.createElement('div');
|
||
container.innerHTML = html;
|
||
container.style.cssText = 'max-height:300px;overflow-y:auto;';
|
||
|
||
const confirmed = await showModal(
|
||
'Assign Users to Geofence',
|
||
container,
|
||
'Save',
|
||
'btn-primary'
|
||
);
|
||
if (confirmed) {
|
||
user_ids = [...container.querySelectorAll('input[type=checkbox]:checked')].map(cb => parseInt(cb.value));
|
||
} else {
|
||
return; // user cancelled — abort save
|
||
}
|
||
}
|
||
} catch (e) { /* no users loaded — skip assignment */ }
|
||
|
||
try {
|
||
await api('/api/geofences', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: name.trim(), points, color, user_ids }),
|
||
});
|
||
showToast('Geofence saved!');
|
||
cancelGeofenceDraw();
|
||
await loadGeofences();
|
||
} catch (e) {
|
||
showToast(e.message, true);
|
||
}
|
||
}
|
||
|
||
async function loadGeofences() {
|
||
try {
|
||
const geofences = await api('/api/geofences');
|
||
// Clear existing geofence layers
|
||
geofenceLayers.forEach(({ polygon }) => {
|
||
if (map) map.removeLayer(polygon);
|
||
});
|
||
geofenceLayers = [];
|
||
|
||
geofences.forEach(gf => {
|
||
const points = typeof gf.points === 'string' ? JSON.parse(gf.points) : gf.points;
|
||
const latlngs = points.map(p => [p.lat, p.lng]);
|
||
const polygon = L.polygon(latlngs, {
|
||
color: gf.color || '#3388ff',
|
||
fillColor: gf.color || '#3388ff',
|
||
fillOpacity: 0.15,
|
||
weight: 2,
|
||
}).addTo(map);
|
||
|
||
const assignedUsers = gf.assigned_users || [];
|
||
const popupBody = `<div style="min-width:150px;">
|
||
<div style="font-weight:700;margin-bottom:4px;">${esc(gf.name)}</div>
|
||
${assignedUsers.length ? `
|
||
<div style="font-size:11px;color:var(--text2);margin:4px 0;border-top:1px solid var(--border);padding-top:4px;">
|
||
👤 ${assignedUsers.map(u => `<span style="color:var(--accent2);">${esc(u.username)}</span>`).join(', ')}
|
||
</div>
|
||
` : ''}
|
||
<div style="display:flex;gap:4px;margin-top:4px;">
|
||
<button onclick="editGeofence(${gf.id})" style="font-size:11px;padding:3px 8px;border-radius:4px;background:var(--card2);color:var(--text);border:1px solid var(--border);cursor:pointer;">✏️ Edit</button>
|
||
<button onclick="assignGeofenceUsers(${gf.id})" style="font-size:11px;padding:3px 8px;border-radius:4px;background:var(--accent-bg);color:var(--accent);border:1px solid var(--accent);cursor:pointer;">👤 Assign</button>
|
||
<button onclick="deleteGeofence(${gf.id})" style="font-size:11px;padding:3px 8px;border-radius:4px;background:var(--red-bg);color:var(--red);border:1px solid var(--red);cursor:pointer;">🗑️ Delete</button>
|
||
</div>
|
||
</div>`;
|
||
polygon.bindPopup(popupBody);
|
||
|
||
geofenceLayers.push({ geofence: gf, polygon });
|
||
});
|
||
|
||
renderGeofenceList(geofences);
|
||
} catch (e) {
|
||
console.error('Failed to load geofences:', e);
|
||
}
|
||
}
|
||
|
||
function renderGeofenceList(geofences) {
|
||
document.getElementById('gfCount').textContent = geofences.length + ' zone' + (geofences.length !== 1 ? 's' : '');
|
||
const el = document.getElementById('geofenceList');
|
||
if (!geofences.length) {
|
||
el.innerHTML = '<div style="font-size:12px;color:var(--text3);padding:4px 0;">No geofences yet — tap ✏️ to draw one</div>';
|
||
return;
|
||
}
|
||
el.innerHTML = geofences.map(gf => `
|
||
<div class="geofence-item">
|
||
<div class="gf-color" style="background:${esc(gf.color || '#3388ff')};"></div>
|
||
<span class="gf-name">${esc(gf.name)}</span>
|
||
${gf.assigned_users && gf.assigned_users.length ? `
|
||
<span style="font-size:10px;color:var(--text3);margin-left:4px;flex-shrink:0;">
|
||
👤${gf.assigned_users.length}
|
||
</span>
|
||
` : ''}
|
||
<div class="gf-actions">
|
||
<button class="gf-btn" onclick="assignGeofenceUsers(${gf.id})" title="Assign Users">👤</button>
|
||
<button class="gf-btn" onclick="editGeofence(${gf.id})" title="Edit">✏️</button>
|
||
<button class="gf-btn danger" onclick="deleteGeofence(${gf.id})" title="Delete">🗑️</button>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
async function editGeofence(id) {
|
||
const gf = geofenceLayers.find(g => g.geofence.id === id);
|
||
if (!gf) return;
|
||
const newName = prompt('Geofence name:', gf.geofence.name);
|
||
if (!newName) return;
|
||
try {
|
||
await api(`/api/geofences/${id}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: newName.trim() }),
|
||
});
|
||
showToast('Geofence updated!');
|
||
await loadGeofences();
|
||
} catch (e) {
|
||
showToast(e.message, true);
|
||
}
|
||
}
|
||
|
||
async function deleteGeofence(id) {
|
||
const confirmed = await showModal('Delete Geofence', 'Remove this geofence zone?', 'Delete');
|
||
if (!confirmed) return;
|
||
try {
|
||
await api(`/api/geofences/${id}`, { method: 'DELETE' });
|
||
showToast('Geofence deleted');
|
||
await loadGeofences();
|
||
} catch (e) {
|
||
showToast(e.message, true);
|
||
}
|
||
}
|
||
|
||
async function assignGeofenceUsers(gfId) {
|
||
try {
|
||
const users = await api('/api/users');
|
||
const gf = geofenceLayers.find(g => g.geofence.id === gfId);
|
||
if (!gf) return;
|
||
const currentIds = (gf.geofence.assigned_users || []).map(u => u.id);
|
||
|
||
// Build a simple checkable list
|
||
let html = users.map(u => `
|
||
<label style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--border);cursor:pointer;">
|
||
<input type="checkbox" value="${u.id}" ${currentIds.includes(u.id) ? 'checked' : ''}
|
||
style="width:18px;height:18px;accent-color:var(--accent);">
|
||
<span>
|
||
<span style="color:var(--text);font-size:14px;">${esc(u.username)}</span>
|
||
<span style="color:var(--text3);font-size:11px;margin-left:6px;">(${esc(u.role)})</span>
|
||
</span>
|
||
</label>
|
||
`).join('') || '<div style="color:var(--text3);padding:8px 0;">No users found. Create users in the Settings tab first.</div>';
|
||
|
||
const container = document.createElement('div');
|
||
container.innerHTML = html;
|
||
container.style.cssText = 'max-height:300px;overflow-y:auto;';
|
||
|
||
const confirmed = await showModal(
|
||
'Assign Service Area',
|
||
container,
|
||
'Save',
|
||
'Cancel'
|
||
);
|
||
if (!confirmed) return;
|
||
|
||
const checked = [...container.querySelectorAll('input[type=checkbox]:checked')].map(cb => parseInt(cb.value));
|
||
await api(`/api/geofences/${gfId}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ user_ids: checked }),
|
||
});
|
||
showToast('Service area assigned!');
|
||
await loadGeofences();
|
||
} catch (e) {
|
||
showToast(e.message, true);
|
||
}
|
||
}
|
||
|
||
function syncEditedGeofences() {
|
||
// After draw:edit, update the geofence on the backend
|
||
const layers = drawnItems.getLayers();
|
||
if (layers.length === 0) return;
|
||
// For simplicity, show a toast — user can re-draw
|
||
showToast('Geofence edited — changes saved to layer');
|
||
}
|
||
|
||
function syncDeletedGeofences() {
|
||
showToast('Geofence removed from map');
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// 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)
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
|
||
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;
|
||
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);
|
||
}
|
||
}
|
||
|
||
// =========================================================================
|
||
// SETTINGS TAB — All entities CRUD
|
||
// =========================================================================
|
||
|
||
// Listen for tab changes to load/hide settings
|
||
document.addEventListener('tabChange', function(e) {
|
||
if (e.detail.tabId === 'tabSettings') loadAllSettings();
|
||
});
|
||
|
||
// Track expanded makes for the make→model tree
|
||
const SettingsState = {
|
||
categories: [], makes: [], models: [], key_names: [],
|
||
key_types: [], badge_types: [], users: [], geofences: [],
|
||
expandedMakes: {}, // { makeId: true }
|
||
addingModelFor: null, // makeId currently showing inline add form
|
||
};
|
||
|
||
const DEFAULT_EMOJIS = ['☕','🥤','🍿','🥪','🏪','❄️','🍩','🍕','🍔','🌯','🍜','🧁','📦','⚙️','🔧','🎯','🏷️','⭐'];
|
||
|
||
async function loadAllSettings() {
|
||
// Show/hide admin-only elements
|
||
document.body.classList.toggle('is-admin', isAdmin());
|
||
const usersCard = document.getElementById('settingsUsersCard');
|
||
if (usersCard) usersCard.style.display = isAdmin() ? '' : 'none';
|
||
|
||
await Promise.all([
|
||
loadEntity('categories'), loadEntity('makes'), loadEntity('models'),
|
||
loadEntity('key_names'), loadEntity('key_types'), loadEntity('badge_types'),
|
||
isAdmin() ? loadUsers() : Promise.resolve(),
|
||
isAdmin() ? loadGeofencesForSettings() : Promise.resolve(),
|
||
]);
|
||
renderAllSettings();
|
||
}
|
||
|
||
async function loadEntity(entity) {
|
||
try {
|
||
SettingsState[entity] = await api(`/api/settings/${entity}`);
|
||
} catch (e) {
|
||
SettingsState[entity] = [];
|
||
}
|
||
}
|
||
|
||
async function loadUsers() {
|
||
try { SettingsState.users = await api('/api/users'); }
|
||
catch (e) { SettingsState.users = []; }
|
||
}
|
||
|
||
async function loadGeofencesForSettings() {
|
||
try { SettingsState.geofences = await api('/api/geofences'); }
|
||
catch (e) { SettingsState.geofences = []; }
|
||
}
|
||
|
||
function renderAllSettings() {
|
||
renderSimpleList('categories', 'settingsCategories');
|
||
renderMakesModels();
|
||
renderSimpleList('key_names', 'settingsKeyNames');
|
||
renderSimpleList('key_types', 'settingsKeyTypes');
|
||
renderSimpleList('badge_types', 'settingsBadgeTypes');
|
||
if (isAdmin()) renderUsers();
|
||
}
|
||
|
||
// ── Simple list renderer (categories, key_names, key_types, badge_types) ──
|
||
|
||
function renderSimpleList(entity, containerId) {
|
||
const items = SettingsState[entity] || [];
|
||
const el = document.getElementById(containerId);
|
||
if (!items.length) {
|
||
el.innerHTML = '<div class="empty-state" style="padding:16px;font-size:12px;">No items yet</div>';
|
||
return;
|
||
}
|
||
const admin = isAdmin();
|
||
el.innerHTML = items.map(item => `
|
||
<div class="settings-item" data-id="${item.id}">
|
||
${entity === 'categories' ? `<span class="si-icon">${esc(item.icon || '📂')}</span>` : ''}
|
||
<span class="si-name">${esc(item.name)}</span>
|
||
${admin ? `
|
||
<div class="si-actions">
|
||
<button class="si-btn" data-action="settingsEdit" data-entity="${entity}" data-id="${item.id}">✏️</button>
|
||
<button class="si-btn delete" data-action="settingsDelete" data-entity="${entity}" data-id="${item.id}">🗑</button>
|
||
</div>` : ''}
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
// ── Makes & Models renderer (expandable tree) ──
|
||
|
||
function renderMakesModels() {
|
||
const el = document.getElementById('settingsMakes');
|
||
const makes = SettingsState.makes || [];
|
||
const models = SettingsState.models || [];
|
||
const admin = isAdmin();
|
||
|
||
if (!makes.length) {
|
||
el.innerHTML = '<div class="empty-state" style="padding:16px;font-size:12px;">No makes yet</div>';
|
||
return;
|
||
}
|
||
|
||
el.innerHTML = makes.map(make => {
|
||
const makeModels = models.filter(m => m.make_id === make.id);
|
||
const isExpanded = SettingsState.expandedMakes[make.id];
|
||
const isAdding = SettingsState.addingModelFor === make.id;
|
||
|
||
let html = `<div class="settings-item make-item ${isExpanded ? 'expanded' : ''}" data-action="toggleMake" data-id="${make.id}">
|
||
<span class="si-name">${esc(make.name)}</span>
|
||
<span class="si-meta">${makeModels.length} model${makeModels.length !== 1 ? 's' : ''}</span>
|
||
${admin ? `
|
||
<div class="si-actions" onclick="event.stopPropagation()">
|
||
<button class="si-btn" data-action="settingsEdit" data-entity="makes" data-id="${make.id}">✏️</button>
|
||
<button class="si-btn delete" data-action="settingsDelete" data-entity="makes" data-id="${make.id}">🗑</button>
|
||
</div>` : ''}
|
||
<span class="si-chevron">▶</span>
|
||
</div>`;
|
||
|
||
// Model sub-list
|
||
html += `<div class="models-sublist ${isExpanded ? 'open' : ''}" data-make-id="${make.id}">`;
|
||
makeModels.forEach(model => {
|
||
html += `<div class="settings-item">
|
||
${model.icon_path ? `<img class="icon-thumb-small" src="${esc(model.icon_path)}" alt="">` : '<span class="si-icon">📦</span>'}
|
||
<span class="si-name">${esc(model.name)}</span>
|
||
${admin ? `
|
||
<div class="si-actions">
|
||
<button class="si-btn" data-action="settingsEdit" data-entity="models" data-id="${model.id}">✏️</button>
|
||
<button class="si-btn delete" data-action="settingsDelete" data-entity="models" data-id="${model.id}">🗑</button>
|
||
</div>` : ''}
|
||
</div>`;
|
||
});
|
||
|
||
// Add model inline form
|
||
if (isAdding) {
|
||
html += `<div class="add-model-row">
|
||
<input type="text" class="input-field" id="newModelName_${make.id}" placeholder="Model name" style="margin-bottom:0;">
|
||
<input type="file" id="newModelIcon_${make.id}" accept="image/*" style="display:none;">
|
||
<button class="si-btn" onclick="document.getElementById('newModelIcon_${make.id}').click()" title="Upload icon">🖼</button>
|
||
<button class="si-btn" style="color:var(--green)" data-action="saveModel" data-make="${make.id}">✓</button>
|
||
<button class="si-btn" style="color:var(--red)" data-action="cancelAddModel">✕</button>
|
||
</div>`;
|
||
} else if (admin) {
|
||
html += `<div class="add-model-row">
|
||
<button class="btn btn-sm btn-outline" style="width:100%;font-size:12px;" data-action="showAddModel" data-make="${make.id}">+ Add Model</button>
|
||
</div>`;
|
||
}
|
||
|
||
html += '</div>';
|
||
return html;
|
||
}).join('');
|
||
}
|
||
|
||
// ── Users renderer ──
|
||
|
||
function renderUsers() {
|
||
const users = SettingsState.users || [];
|
||
const geofences = SettingsState.geofences || [];
|
||
const el = document.getElementById('settingsUsers');
|
||
if (!users.length) {
|
||
el.innerHTML = '<div class="empty-state" style="padding:16px;font-size:12px;">No users found</div>';
|
||
return;
|
||
}
|
||
el.innerHTML = users.map(u => {
|
||
const userGeofences = geofences.filter(gf =>
|
||
(gf.assigned_users || []).some(au => au.id === u.id)
|
||
);
|
||
const serviceHtml = userGeofences.length > 0
|
||
? `<div style="font-size:10px;color:var(--text2);margin-top:2px;">
|
||
📍 ${userGeofences.map(gf => `<span style="color:${esc(gf.color || '#3388ff')};">●</span> ${esc(gf.name)}`).join(' · ')}
|
||
</div>`
|
||
: '';
|
||
return `
|
||
<div class="settings-item" data-id="${u.id}">
|
||
<span class="si-icon" style="font-size:16px;">👤</span>
|
||
<div style="flex:1;min-width:0;">
|
||
<div style="display:flex;align-items:center;gap:6px;">
|
||
<span class="si-name" style="flex:none;">${esc(u.username)}</span>
|
||
<span class="role-badge ${esc(u.role)}">${esc(u.role)}</span>
|
||
</div>
|
||
${serviceHtml}
|
||
</div>
|
||
${isAdmin() ? `
|
||
<div class="si-actions">
|
||
<button class="si-btn" data-action="editUser" data-id="${u.id}">✏️</button>
|
||
<button class="si-btn delete" data-action="deleteUser" data-id="${u.id}">🗑</button>
|
||
</div>` : ''}
|
||
</div>
|
||
`}).join('');
|
||
}
|
||
|
||
// ── Settings item add (shows inline form) ──
|
||
|
||
function showAddForm(entity) {
|
||
const containerId = entity === 'makes' || entity === 'models' ? 'settingsMakes' :
|
||
entity === 'categories' ? 'settingsCategories' :
|
||
entity === 'key_names' ? 'settingsKeyNames' :
|
||
entity === 'key_types' ? 'settingsKeyTypes' :
|
||
entity === 'badge_types' ? 'settingsBadgeTypes' : null;
|
||
if (!containerId) return;
|
||
|
||
const el = document.getElementById(containerId);
|
||
const existing = el.querySelector('.settings-inline-form');
|
||
if (existing) existing.remove();
|
||
|
||
const isCategory = entity === 'categories';
|
||
const form = document.createElement('div');
|
||
form.className = 'settings-inline-form';
|
||
form.innerHTML = `
|
||
${isCategory ? `<div class="emoji-picker" id="emojiPicker_${entity}">${DEFAULT_EMOJIS.map((e,i) => `<span class="emoji-opt${i===0?' selected':''}" data-emoji="${e}">${e}</span>`).join('')}</div>` : ''}
|
||
<input type="text" class="input-field" id="addInput_${entity}" placeholder="${isCategory ? 'Category name' : 'Name'}" style="margin-bottom:0;">
|
||
<div class="si-actions">
|
||
<button class="si-btn" style="color:var(--green);" data-action="saveNewItem" data-entity="${entity}">✓</button>
|
||
<button class="si-btn" style="color:var(--red);" data-action="cancelAddForm" data-entity="${entity}">✕</button>
|
||
</div>
|
||
`;
|
||
el.insertBefore(form, el.firstChild);
|
||
|
||
// Emoji picker clicks
|
||
if (isCategory) {
|
||
form.querySelectorAll('.emoji-opt').forEach(opt => {
|
||
opt.addEventListener('click', function() {
|
||
form.querySelectorAll('.emoji-opt').forEach(o => o.classList.remove('selected'));
|
||
this.classList.add('selected');
|
||
});
|
||
});
|
||
}
|
||
|
||
setTimeout(() => form.querySelector('input').focus(), 100);
|
||
}
|
||
|
||
function cancelAddForm(entity) {
|
||
const containerId = entity === 'makes' || entity === 'models' ? 'settingsMakes' :
|
||
entity === 'categories' ? 'settingsCategories' :
|
||
entity === 'key_names' ? 'settingsKeyNames' :
|
||
entity === 'key_types' ? 'settingsKeyTypes' :
|
||
entity === 'badge_types' ? 'settingsBadgeTypes' : null;
|
||
if (!containerId) return;
|
||
const form = document.getElementById(containerId).querySelector('.settings-inline-form');
|
||
if (form) form.remove();
|
||
renderAllSettings();
|
||
}
|
||
|
||
async function saveNewItem(entity) {
|
||
const input = document.getElementById(`addInput_${entity}`);
|
||
if (!input) return;
|
||
const name = input.value.trim();
|
||
if (!name) { showToast('Name is required', true); return; }
|
||
|
||
let payload = { name };
|
||
if (entity === 'categories') {
|
||
const sel = document.querySelector(`#emojiPicker_${entity} .emoji-opt.selected`);
|
||
payload.icon = sel ? sel.dataset.emoji : '📂';
|
||
}
|
||
|
||
try {
|
||
await api(`/api/settings/${entity}`, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
|
||
});
|
||
showToast('Added!');
|
||
await loadEntity(entity);
|
||
renderAllSettings();
|
||
} catch (e) { showToast(e.message, true); }
|
||
}
|
||
|
||
// ── Settings item edit (show inline form pre-filled) ──
|
||
|
||
function showEditForm(entity, id) {
|
||
const item = (SettingsState[entity] || []).find(x => x.id === id);
|
||
if (!item) return;
|
||
|
||
const containerId = entity === 'makes' || entity === 'models' ? 'settingsMakes' :
|
||
entity === 'categories' ? 'settingsCategories' :
|
||
entity === 'key_names' ? 'settingsKeyNames' :
|
||
entity === 'key_types' ? 'settingsKeyTypes' :
|
||
entity === 'badge_types' ? 'settingsBadgeTypes' : null;
|
||
if (!containerId) return;
|
||
|
||
const el = document.getElementById(containerId);
|
||
const existing = el.querySelector('.settings-inline-form');
|
||
if (existing) existing.remove();
|
||
|
||
const isCategory = entity === 'categories';
|
||
const form = document.createElement('div');
|
||
form.className = 'settings-inline-form';
|
||
form.innerHTML = `
|
||
${isCategory ? `<div class="emoji-picker" id="editEmoji_${entity}_${id}">${DEFAULT_EMOJIS.map(e => `<span class="emoji-opt${e === (item.icon||'📂') ? ' selected' : ''}" data-emoji="${e}">${e}</span>`).join('')}</div>` : ''}
|
||
<input type="text" class="input-field" id="editInput_${entity}_${id}" value="${esc(item.name)}" style="margin-bottom:0;">
|
||
<div class="si-actions">
|
||
<button class="si-btn" style="color:var(--green);" data-action="saveEditItem" data-entity="${entity}" data-id="${id}">✓</button>
|
||
<button class="si-btn" style="color:var(--red);" data-action="cancelAddForm" data-entity="${entity}">✕</button>
|
||
</div>
|
||
`;
|
||
el.insertBefore(form, el.firstChild);
|
||
|
||
if (isCategory) {
|
||
form.querySelectorAll('.emoji-opt').forEach(opt => {
|
||
opt.addEventListener('click', function() {
|
||
form.querySelectorAll('.emoji-opt').forEach(o => o.classList.remove('selected'));
|
||
this.classList.add('selected');
|
||
});
|
||
});
|
||
}
|
||
|
||
setTimeout(() => form.querySelector('input').focus(), 100);
|
||
}
|
||
|
||
async function saveEditItem(entity, id) {
|
||
const input = document.getElementById(`editInput_${entity}_${id}`);
|
||
if (!input) return;
|
||
const name = input.value.trim();
|
||
if (!name) { showToast('Name is required', true); return; }
|
||
|
||
let payload = { name };
|
||
if (entity === 'categories') {
|
||
const sel = document.querySelector(`#editEmoji_${entity}_${id} .emoji-opt.selected`);
|
||
if (sel) payload.icon = sel.dataset.emoji;
|
||
}
|
||
|
||
try {
|
||
await api(`/api/settings/${entity}/${id}`, {
|
||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
|
||
});
|
||
showToast('Updated!');
|
||
await loadEntity(entity);
|
||
renderAllSettings();
|
||
} catch (e) { showToast(e.message, true); }
|
||
}
|
||
|
||
// ── Settings item delete ──
|
||
|
||
async function deleteSettingsItem(entity, id) {
|
||
const item = (SettingsState[entity] || []).find(x => x.id === id);
|
||
const name = item ? item.name : 'this item';
|
||
const conf = await showModal('Delete', `Delete "${name}"? This cannot be undone.`, 'Delete');
|
||
if (!conf) return;
|
||
|
||
try {
|
||
await api(`/api/settings/${entity}/${id}`, { method: 'DELETE' });
|
||
showToast('Deleted');
|
||
await loadEntity(entity);
|
||
if (entity === 'models') await loadEntity('makes');
|
||
renderAllSettings();
|
||
} catch (e) { showToast(e.message, true); }
|
||
}
|
||
|
||
// ── Make expand/collapse ──
|
||
|
||
function toggleMake(makeId) {
|
||
SettingsState.expandedMakes[makeId] = !SettingsState.expandedMakes[makeId];
|
||
renderMakesModels();
|
||
}
|
||
|
||
function showAddModel(makeId) {
|
||
SettingsState.addingModelFor = makeId;
|
||
renderMakesModels();
|
||
}
|
||
|
||
function cancelAddModel() {
|
||
SettingsState.addingModelFor = null;
|
||
renderMakesModels();
|
||
}
|
||
|
||
async function saveModel(makeId) {
|
||
const nameInput = document.getElementById(`newModelName_${makeId}`);
|
||
const iconInput = document.getElementById(`newModelIcon_${makeId}`);
|
||
const name = nameInput ? nameInput.value.trim() : '';
|
||
if (!name) { showToast('Model name is required', true); return; }
|
||
|
||
let payload = { make_id: makeId, name, icon_path: '' };
|
||
|
||
// Upload icon if selected
|
||
if (iconInput && iconInput.files.length) {
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('file', iconInput.files[0]);
|
||
const result = await api('/api/upload/icon', {
|
||
method: 'POST', body: formData,
|
||
});
|
||
payload.icon_path = result.path;
|
||
} catch (e) {
|
||
showToast('Icon upload failed: ' + e.message, true);
|
||
return;
|
||
}
|
||
}
|
||
|
||
try {
|
||
await api('/api/settings/models', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
|
||
});
|
||
showToast('Model added!');
|
||
SettingsState.addingModelFor = null;
|
||
await loadEntity('models');
|
||
renderMakesModels();
|
||
} catch (e) { showToast(e.message, true); }
|
||
}
|
||
|
||
// ── User management ──
|
||
|
||
function showAddUserForm() {
|
||
const el = document.getElementById('settingsUsers');
|
||
const existing = el.querySelector('.user-detail-inline');
|
||
if (existing) existing.remove();
|
||
|
||
const form = document.createElement('div');
|
||
form.className = 'user-detail-inline';
|
||
form.innerHTML = `
|
||
<input type="text" class="input-field" id="newUsername" placeholder="Username">
|
||
<input type="password" class="input-field" id="newPassword" placeholder="Password">
|
||
<select class="input-field" id="newRole">
|
||
<option value="technician">Technician</option>
|
||
<option value="admin">Admin</option>
|
||
<option value="readonly">Read-only</option>
|
||
</select>
|
||
<div style="display:flex;gap:8px;">
|
||
<button class="btn btn-sm btn-primary" style="flex:1;" data-action="saveNewUser">Create User</button>
|
||
<button class="btn btn-sm btn-outline" style="flex:1;" data-action="cancelAddUser">Cancel</button>
|
||
</div>
|
||
`;
|
||
el.insertBefore(form, el.firstChild);
|
||
setTimeout(() => form.querySelector('#newUsername').focus(), 100);
|
||
}
|
||
|
||
async function saveNewUser() {
|
||
const username = document.getElementById('newUsername').value.trim();
|
||
const password = document.getElementById('newPassword').value.trim();
|
||
const role = document.getElementById('newRole').value;
|
||
if (!username || !password) { showToast('Username and password required', true); return; }
|
||
|
||
try {
|
||
await api('/api/users', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ username, password, role }),
|
||
});
|
||
showToast('User created!');
|
||
await loadUsers();
|
||
renderUsers();
|
||
} catch (e) { showToast(e.message, true); }
|
||
}
|
||
|
||
function cancelAddUser() { renderUsers(); }
|
||
|
||
function showEditUserForm(userId) {
|
||
const user = (SettingsState.users || []).find(u => u.id === userId);
|
||
if (!user) return;
|
||
|
||
const el = document.getElementById('settingsUsers');
|
||
const existing = el.querySelector('.user-detail-inline');
|
||
if (existing) existing.remove();
|
||
|
||
const form = document.createElement('div');
|
||
form.className = 'user-detail-inline';
|
||
form.innerHTML = `
|
||
<div style="font-weight:600;margin-bottom:6px;">Edit: ${esc(user.username)}</div>
|
||
<select class="input-field" id="editRole_${userId}">
|
||
<option value="technician" ${user.role==='technician'?'selected':''}>Technician</option>
|
||
<option value="admin" ${user.role==='admin'?'selected':''}>Admin</option>
|
||
<option value="readonly" ${user.role==='readonly'?'selected':''}>Read-only</option>
|
||
</select>
|
||
<input type="password" class="input-field" id="editPassword_${userId}" placeholder="New password (leave blank to keep)">
|
||
<div style="display:flex;gap:8px;">
|
||
<button class="btn btn-sm btn-primary" style="flex:1;" data-action="saveEditUser" data-id="${userId}">Save</button>
|
||
<button class="btn btn-sm btn-outline" style="flex:1;" data-action="cancelAddUser">Cancel</button>
|
||
</div>
|
||
`;
|
||
el.insertBefore(form, el.firstChild);
|
||
}
|
||
|
||
async function saveEditUser(userId) {
|
||
const roleEl = document.getElementById(`editRole_${userId}`);
|
||
const pwEl = document.getElementById(`editPassword_${userId}`);
|
||
const payload = {};
|
||
if (roleEl) payload.role = roleEl.value;
|
||
if (pwEl && pwEl.value.trim()) payload.password = pwEl.value.trim();
|
||
|
||
if (!Object.keys(payload).length) { cancelAddUser(); return; }
|
||
|
||
try {
|
||
await api(`/api/users/${userId}`, {
|
||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
|
||
});
|
||
showToast('User updated!');
|
||
await loadUsers();
|
||
renderUsers();
|
||
} catch (e) { showToast(e.message, true); }
|
||
}
|
||
|
||
async function deleteUserById(userId) {
|
||
const user = (SettingsState.users || []).find(u => u.id === userId);
|
||
const name = user ? user.username : 'this user';
|
||
const conf = await showModal('Delete User', `Delete user "${name}"? This cannot be undone.`, 'Delete');
|
||
if (!conf) return;
|
||
|
||
try {
|
||
await api(`/api/users/${userId}`, { method: 'DELETE' });
|
||
showToast('User deleted');
|
||
await loadUsers();
|
||
renderUsers();
|
||
} catch (e) { showToast(e.message, true); }
|
||
}
|
||
|
||
// ── App Configuration ──
|
||
|
||
async function resetDatabase() {
|
||
const conf = await showModal('Reset Database',
|
||
'This will DELETE ALL DATA and re-seed the database. This cannot be undone!\n\nType "RESET" to confirm.',
|
||
'Reset Database');
|
||
if (!conf) return;
|
||
// Double-check with prompt
|
||
const input = prompt('Type RESET to confirm database wipe:');
|
||
if (input !== 'RESET') { showToast('Cancelled'); return; }
|
||
|
||
try {
|
||
await api('/api/admin/reset-db', { method: 'POST' });
|
||
showToast('Database reset! Refreshing...');
|
||
setTimeout(() => location.reload(), 1500);
|
||
} catch (e) {
|
||
showToast('Reset failed: ' + e.message, true);
|
||
}
|
||
}
|
||
|
||
// ── Register settings action handlers ──
|
||
|
||
registerAction('settingsAdd', (el) => { showAddForm(el.dataset.entity); });
|
||
registerAction('settingsEdit', (el) => { showEditForm(el.dataset.entity, parseInt(el.dataset.id)); });
|
||
registerAction('settingsDelete', (el) => { deleteSettingsItem(el.dataset.entity, parseInt(el.dataset.id)); });
|
||
registerAction('toggleMake', (el) => { toggleMake(parseInt(el.dataset.id)); });
|
||
registerAction('showAddModel', (el) => { showAddModel(parseInt(el.dataset.make)); });
|
||
registerAction('saveModel', (el) => { saveModel(parseInt(el.dataset.make)); });
|
||
registerAction('cancelAddModel', () => { cancelAddModel(); });
|
||
registerAction('saveNewItem', (el) => { saveNewItem(el.dataset.entity); });
|
||
registerAction('cancelAddForm', (el) => { cancelAddForm(el.dataset.entity); });
|
||
registerAction('saveEditItem', (el) => { saveEditItem(el.dataset.entity, parseInt(el.dataset.id)); });
|
||
registerAction('addUser', () => { showAddUserForm(); });
|
||
registerAction('editUser', (el) => { showEditUserForm(parseInt(el.dataset.id)); });
|
||
registerAction('deleteUser', (el) => { deleteUserById(parseInt(el.dataset.id)); });
|
||
registerAction('saveNewUser', () => { saveNewUser(); });
|
||
registerAction('cancelAddUser', () => { cancelAddUser(); });
|
||
registerAction('saveEditUser', (el) => { saveEditUser(parseInt(el.dataset.id)); });
|
||
registerAction('resetDatabase', () => { resetDatabase(); });
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
// INIT
|
||
// ═══════════════════════════════════════════════════════════════════════
|
||
initAuth();
|
||
initGPS();
|
||
startScanning();
|
||
loadAssets();
|
||
</script>
|
||
</body>
|
||
</html>
|