Files
Leo 25aec1490a feat: Asset Cleanup tool with bulk edit + name generator
Adds a new Cleanup page in the admin Dashboard for reviewing
and sanitizing asset records.

Features:
- Filter by Company, Place/City, or Make with live counts
- Inline edit for make, model, building, floor on each asset
- Bulk edit bar: set any field for ALL matching assets at once
- Auto-generates names in '<make> <model> @ <company> - <Building> <Floor>' format
- Paginated results (50/page) with prev/next navigation
- Backend: POST /api/assets/batch-update for efficient batch updates
- Backend: company/place/make filter params on GET /api/assets
2026-06-02 22:07:37 -04:00

6172 lines
283 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Canteen Admin</title>
<style>
/* ═══════════════════════════════════════════════════════════════════════
DESIGN TOKENS — matching main app dark theme
═══════════════════════════════════════════════════════════════════════ */
: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;
--sidebar-w: 240px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 14px; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
}
/* ═══════════════════════════════════════════════════════════════════════
SIDEBAR
═══════════════════════════════════════════════════════════════════════ */
.sidebar {
width: var(--sidebar-w);
background: var(--card);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
position: fixed;
top: 0; left: 0; bottom: 0;
z-index: 100;
overflow-y: auto;
}
.sidebar-header {
padding: 18px 16px;
border-bottom: 1px solid var(--border);
font-size: 16px;
font-weight: 700;
display: flex;
align-items: center;
gap: 8px;
}
.sidebar-header .sh-icon { font-size: 20px; }
.sidebar-user {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
}
.sidebar-user .su-avatar {
width: 36px; height: 36px; border-radius: 50%;
background: var(--accent-bg); color: var(--accent2);
display: flex; align-items: center; justify-content: center;
font-size: 14px; font-weight: 700; flex-shrink: 0;
}
.sidebar-user .su-info { flex: 1; min-width: 0; }
.sidebar-user .su-name { font-weight: 600; font-size: 13px; }
.sidebar-user .su-role { font-size: 11px; color: var(--text2); text-transform: capitalize; }
.sidebar-nav { flex: 1; padding: 8px 0; }
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 16px;
color: var(--text2);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.1s;
border: none;
background: transparent;
width: 100%;
text-align: left;
}
.nav-item:hover { background: rgba(255,255,255,0.03); color: var(--text); }
.nav-item.active {
background: var(--accent-bg);
color: var(--accent2);
border-right: 2px solid var(--accent);
}
.nav-item .ni-icon { font-size: 16px; width: 22px; text-align: center; flex-shrink: 0; }
.nav-item .ni-badge {
margin-left: auto;
font-size: 10px;
font-weight: 700;
padding: 2px 7px;
border-radius: 10px;
background: var(--accent-bg);
color: var(--accent2);
}
.sidebar-footer {
padding: 12px 16px;
border-top: 1px solid var(--border);
font-size: 11px;
color: var(--text3);
}
.sidebar-footer .logout-btn {
background: none; border: none; color: var(--red);
font-size: 12px; font-weight: 600; cursor: pointer;
padding: 6px 0; display: none;
width: 100%; text-align: left;
}
.sidebar-footer .logout-btn:hover { text-decoration: underline; }
/* ═══════════════════════════════════════════════════════════════════════
MAIN CONTENT
═══════════════════════════════════════════════════════════════════════ */
.main {
margin-left: var(--sidebar-w);
flex: 1;
padding: 24px;
max-width: calc(100vw - var(--sidebar-w));
position: relative;
}
.page-title {
font-size: 22px;
font-weight: 700;
margin-bottom: 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.page-title .pt-actions { display: flex; gap: 8px; align-items: center; }
/* ═══════════════════════════════════════════════════════════════════════
CARDS
═══════════════════════════════════════════════════════════════════════ */
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
margin-bottom: 14px;
}
.card-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text2);
margin-bottom: 12px;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.card-header .card-title { margin-bottom: 0; }
/* ═══════════════════════════════════════════════════════════════════════
BUTTONS
═══════════════════════════════════════════════════════════════════════ */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border: none;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 600;
padding: 9px 16px;
cursor: pointer;
transition: opacity 0.15s, transform 0.1s;
white-space: nowrap;
}
.btn:active { transform: scale(0.97); }
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { background: var(--accent2); }
.btn-sm { padding: 7px 12px; font-size: 12px; }
.btn-xs { padding: 4px 8px; font-size: 11px; }
.btn-outline { background: transparent; border: 1.5px solid var(--border); color: var(--text); }
.btn-danger { background: transparent; border: 1.5px solid var(--red); color: var(--red); }
.btn-green { background: var(--green); color: #000; }
.btn-green-outline { background: transparent; border: 1.5px solid var(--green); color: var(--green); }
.btn-accent { background: var(--accent-bg); border: 1.5px solid var(--accent); color: var(--accent2); }
.btn-accent:hover { background: var(--accent); color: #fff; }
.btn-amber { background: var(--amber); color: #000; }
.btn-block { width: 100%; }
/* ═══════════════════════════════════════════════════════════════════════
INPUTS
═══════════════════════════════════════════════════════════════════════ */
.input-field {
width: 100%;
background: #0a0b0f;
border: 1.5px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
padding: 9px 12px;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
margin-bottom: 8px;
}
.input-field:focus { border-color: var(--accent); }
.input-field::placeholder { color: #3a3d4a; }
select.input-field { appearance: none; }
textarea.input-field { resize: vertical; min-height: 60px; font-family: inherit; font-size: 13px; }
.form-row { display: flex; gap: 8px; }
.form-row .input-field { flex: 1; margin-bottom: 0; }
.form-group { margin-bottom: 10px; }
.form-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text2);
margin-bottom: 4px;
display: block;
}
/* ═══════════════════════════════════════════════════════════════════════
TABLES
═══════════════════════════════════════════════════════════════════════ */
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.data-table th {
text-align: left;
padding: 10px 12px;
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;
}
.data-table td {
padding: 10px 12px;
border-bottom: 1px solid rgba(255,255,255,0.04);
vertical-align: middle;
}
.data-table tr:hover td { background: rgba(255,255,255,0.02); }
.data-table .td-actions { text-align: right; white-space: nowrap; }
.data-table .td-actions button { margin-left: 4px; }
.data-table .rpt-number { text-align: right; font-variant-numeric: tabular-nums; }
.status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; vertical-align: middle; }
.status-active { background: var(--green); }
.status-maintenance { background: var(--amber); }
.status-retired { background: var(--red); }
.table-wrap { overflow-x: auto; }
.ae-cell { cursor: pointer; transition: background 0.15s; }
.ae-cell:hover { background: rgba(91,110,247,0.08); }
.ae-cell.editing { padding: 0 !important; }
.ae-empty-cell { border-left: 2px solid var(--amber); }
.ae-empty { color: var(--amber); font-style: italic; }
.ae-input {
background: var(--card2); color: var(--text);
border: 1px solid var(--accent); border-radius: 4px;
padding: 6px 8px; font-size: 11px; width: 100%;
outline: none; box-sizing: border-box;
}
.ae-input:focus { border-color: var(--accent2); box-shadow: 0 0 0 2px var(--accent-bg); }
.ae-select {
background: var(--card2); color: var(--text);
border: 1px solid var(--accent); border-radius: 4px;
padding: 6px 4px; font-size: 11px; width: 100%;
outline: none; cursor: pointer;
}
/* ═══════════════════════════════════════════════════════════════════════
STATUS & BADGES
═══════════════════════════════════════════════════════════════════════ */
.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); }
.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); }
.checkbox-group { display: flex; flex-direction: column; gap: 6px; }
.checkbox-label { display: flex; align-items: center; gap: 6px; font-size: 13px; cursor: pointer; }
.checkbox-label input[type="checkbox"] { width: 16px; height: 16px; cursor: pointer; }
/* ═══════════════════════════════════════════════════════════════════════
STATS GRID
═══════════════════════════════════════════════════════════════════════ */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
margin-bottom: 14px;
}
.stat-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
text-align: center;
}
.stat-card .stat-value { font-size: 30px; 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;
}
/* ═══════════════════════════════════════════════════════════════════════
SETTINGS LIST ITEMS
═══════════════════════════════════════════════════════════════════════ */
.settings-list { }
.settings-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 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: 16px; width: 22px; text-align: center; flex-shrink: 0; }
.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: 4px; flex-shrink: 0; }
.si-btn {
width: 26px; height: 26px; border: none; background: transparent;
color: var(--text2); font-size: 13px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
border-radius: var(--radius-xs);
}
.si-btn:hover { background: rgba(255,255,255,0.05); }
.si-btn.delete:hover { color: var(--red); background: var(--red-bg); }
.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: 13px; }
.settings-inline-form select { margin-bottom: 0; font-size: 13px; }
/* Make expand */
.settings-item.make-item { cursor: pointer; font-weight: 600; }
.settings-item.make-item .si-chevron {
transition: transform 0.2s; font-size: 11px; color: var(--text2);
}
.settings-item.make-item.expanded .si-chevron { transform: rotate(90deg); }
.models-sublist { display: none; padding-left: 32px; }
.models-sublist.open { display: block; }
.models-sublist .settings-item { font-size: 13px; padding: 7px 0; }
.add-model-row {
display: flex; align-items: center; gap: 8px;
padding: 6px 0 6px 32px; border-bottom: 1px solid rgba(255,255,255,0.04);
}
.add-model-row input { flex: 1; margin-bottom: 0; font-size: 12px; padding: 6px 10px; }
/* Emoji picker */
.emoji-picker {
display: flex; flex-wrap: wrap; gap: 4px; padding: 6px 0;
}
.emoji-picker .emoji-opt {
width: 30px; height: 30px; display: flex; align-items: center; justify-content: center;
font-size: 16px; border-radius: var(--radius-xs); cursor: pointer;
border: 1px solid transparent; background: transparent;
}
.emoji-opt:hover { background: var(--border); }
.emoji-opt.selected { border-color: var(--accent); background: var(--accent-bg); }
/* ═══════════════════════════════════════════════════════════════════════
ACTIVITY ITEMS
═══════════════════════════════════════════════════════════════════════ */
.activity-item {
display: flex;
gap: 10px;
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: 30px; height: 30px; 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-icon.login { background: var(--accent-bg); }
.activity-item .act-icon.user { background: #1e1a2e; }
.activity-item .act-icon.customer { background: #1a2e2a; }
.activity-item .act-icon.asset { background: #2a1a2e; }
.activity-item .act-icon.other { background: var(--border); }
.activity-item .act-info { flex: 1; min-width: 0; }
.activity-item .act-text { line-height: 1.4; }
.activity-item .act-time { font-size: 11px; color: var(--text2); margin-top: 2px; }
.act-filter-row { display: flex; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; }
.act-filter-row select, .act-filter-row input { flex: 1; min-width: 120px; margin-bottom: 0; }
.act-pagination { display: flex; gap: 8px; justify-content: center; padding: 14px 0; }
/* ═══════════════════════════════════════════════════════════════════════
CONTACT 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: 14px;
cursor: pointer; padding: 2px 6px; border-radius: 4px;
}
.contact-row .cr-remove:hover { background: var(--red-bg); }
/* ═══════════════════════════════════════════════════════════════════════
MODAL
═══════════════════════════════════════════════════════════════════════ */
.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: 24px; width: 100%; max-width: 480px;
}
.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
═══════════════════════════════════════════════════════════════════════ */
.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; }
/* ═══════════════════════════════════════════════════════════════════════
SPINNER & TOAST
═══════════════════════════════════════════════════════════════════════ */
.spinner {
width: 18px; height: 18px; 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); } }
.toast {
position: fixed; bottom: 24px; right: 24px; z-index: 400;
background: var(--green); color: #000;
font-weight: 700; padding: 10px 22px; border-radius: 20px; font-size: 14px;
opacity: 0; transition: opacity 0.3s; pointer-events: none;
white-space: nowrap; max-width: 90vw;
}
.toast.show { opacity: 1; }
.toast.error { background: var(--red); color: #fff; }
/* ═══════════════════════════════════════════════════════════════════════
MISC
═══════════════════════════════════════════════════════════════════════ */
.empty-state { text-align: center; padding: 28px 16px; color: var(--text2); font-size: 13px; }
.empty-state .es-icon { font-size: 36px; margin-bottom: 8px; opacity: 0.5; }
.loading-row { text-align: center; padding: 20px; color: var(--text2); }
/* ── Skeleton cards (dashboard loading) ── */
.skeleton-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
margin-bottom: 14px;
min-height: 80px;
}
.skeleton-card.skeleton-stat { padding: 18px; text-align: center; min-height: 100px; }
.skeleton-card .skeleton-bar {
height: 14px; background: var(--card2); border-radius: 8px; margin-bottom: 8px;
animation: skeleton-pulse 1.4s ease-in-out infinite;
}
.skeleton-card .skeleton-bar:last-child { margin-bottom: 0; }
.skeleton-card .skeleton-bar.w60 { width: 60%; }
.skeleton-card .skeleton-bar.w40 { width: 40%; }
.skeleton-card .skeleton-bar.w80 { width: 80%; }
.skeleton-card .skeleton-circle {
width: 48px; height: 48px; border-radius: 50%; background: var(--card2);
margin: 0 auto 10px; animation: skeleton-pulse 1.4s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 0.3; }
50% { opacity: 0.7; }
}
@keyframes pulse-border {
0%, 100% { border-color: var(--accent); }
50% { border-color: var(--accent2); }
}
td.saving {
opacity: 0.6;
box-shadow: inset 0 0 0 1px var(--accent);
animation: pulse-border 1s infinite;
}
.flex-row { display: flex; gap: 8px; align-items: center; }
.gap-sm { gap: 6px; }
.mt-8 { margin-top: 8px; }
.mb-8 { margin-bottom: 8px; }
.text-sm { font-size: 12px; }
.text-xs { font-size: 11px; }
.text-muted { color: var(--text2); }
.text-danger { color: var(--red); }
.w-full { width: 100%; }
.hidden { display: none !important; }
.mono { font-family: 'Courier New', monospace; font-size: 12px; }
/* ═══════════════════════════════════════════════════════════════════════
HAMBURGER BUTTON & SIDEBAR OVERLAY (mobile)
═══════════════════════════════════════════════════════════════════════ */
.hamburger {
display: none;
background: none; border: none; color: var(--text); font-size: 24px;
cursor: pointer; padding: 6px; margin-right: 10px;
line-height: 1; flex-shrink: 0;
}
.hamburger:hover { color: var(--accent2); }
.sidebar-overlay {
display: none;
position: fixed; inset: 0; background: rgba(0,0,0,0.5);
z-index: 99; opacity: 0; transition: opacity 0.25s;
pointer-events: none;
}
.sidebar-overlay.open { opacity: 1; pointer-events: auto; }
/* Responsive: off-canvas sidebar on narrow screens */
@media (max-width: 768px) {
.hamburger { display: inline-flex; align-items: center; }
.sidebar-overlay { display: block; }
.sidebar {
transform: translateX(-100%);
transition: transform 0.25s ease;
z-index: 100;
}
.sidebar.open { transform: translateX(0); }
.main {
margin-left: 0;
padding: 16px;
max-width: 100vw;
}
.page-title .pt-actions .btn .btn-label { display: none; }
.page-title .pt-actions .btn { min-width: 36px; justify-content: center; }
/* Stats grid: tighter cards */
.stats-grid { grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 6px; }
.stat-card { padding: 10px; }
.stat-card .stat-value { font-size: 22px; }
/* Import batch rows stack */
.cs-batch-row .csb-status { width: 60px; }
.cs-batch-row .csb-time { width: auto; font-size: 11px; }
.cs-batch-row .csb-rows { width: auto; text-align: left; margin-left: auto; }
/* Changes table horizontal scroll */
.cs-changes-table-wrapper { overflow-x: auto; }
.cs-changes-table { min-width: 480px; }
/* Page actions */
.page-title { flex-wrap: wrap; gap: 6px; }
.page-title h2 { font-size: 16px; }
/* Form rows stack */
.form-row { flex-direction: column; }
.form-row .input-field { flex: none; width: 100%; }
}
@media (max-width: 480px) {
.main { padding: 10px; }
.stats-grid { grid-template-columns: repeat(2, 1fr); gap: 4px; }
.stat-card { padding: 8px; }
.stat-card .stat-value { font-size: 18px; }
.stat-card .stat-label { font-size: 9px; }
/* Dashboard page title */
.page-title { flex-direction: column; align-items: flex-start; }
.page-title .pt-actions { width: 100%; }
.page-title .pt-actions button { flex: 1; }
/* Import diff badges stack 2x2 */
.cs-diff-summary { grid-template-columns: repeat(2, 1fr); }
.cs-diff-badge { padding: 8px; }
.cs-diff-badge .csd-val { font-size: 18px; }
/* Batch rows compact */
.cs-batch-row { flex-wrap: wrap; gap: 4px; padding: 8px; }
.cs-batch-row .csb-time { font-size: 10px; width: 100%; order: 3; }
/* Card padding */
.card { padding: 10px; }
.card-title { font-size: 13px; }
/* Tables scroll */
.cs-actions { flex-direction: column; }
.cs-actions button { width: 100%; }
}
/* ═══════════════════════════════════════════════════════════════════════
EXCEL IMPORT
═══════════════════════════════════════════════════════════════════════ */
.cs-diff-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 8px;
margin-bottom: 14px;
}
.cs-diff-badge {
background: var(--card2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 12px;
text-align: center;
}
.cs-diff-badge .csd-val { font-size: 24px; font-weight: 800; }
.cs-diff-badge .csd-label { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text2); margin-top: 2px; }
.cs-diff-badge.add .csd-val { color: var(--green); }
.cs-diff-badge.chg .csd-val { color: var(--amber); }
.cs-diff-badge.rem .csd-val { color: var(--red); }
.cs-diff-badge.unc .csd-val { color: var(--text2); }
.cs-batch-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-bottom: 1px solid rgba(255,255,255,0.04);
cursor: pointer;
transition: background 0.1s;
font-size: 13px;
}
.cs-batch-row:hover { background: rgba(255,255,255,0.03); }
.cs-batch-row .csb-status { width: 80px; flex-shrink: 0; }
.cs-batch-row .csb-time { width: 160px; flex-shrink: 0; color: var(--text2); }
.cs-batch-row .csb-rows { width: 80px; text-align: right; flex-shrink: 0; font-variant-numeric: tabular-nums; }
.cs-batch-row .csb-chevron { font-size: 11px; color: var(--text2); transition: transform 0.2s; flex-shrink: 0; }
.cs-batch-row.expanded .csb-chevron { transform: rotate(90deg); }
.cs-batch-detail {
display: none;
padding: 0 12px 14px 36px;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.cs-batch-detail.open { display: block; }
.cs-changes-table { width: 100%; border-collapse: collapse; font-size: 12px; margin-top: 8px; }
.cs-changes-table th { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); color: var(--text2); font-size: 10px; text-transform: uppercase; letter-spacing: 0.03em; }
.cs-changes-table td { padding: 6px 8px; border-bottom: 1px solid rgba(255,255,255,0.03); vertical-align: middle; }
.cs-changes-table .cs-old { color: var(--red); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cs-changes-table .cs-new { color: var(--green); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cs-changes-table .cs-arrow { color: var(--text2); padding: 0 4px; }
.cs-actions { display: flex; gap: 8px; margin-top: 12px; }
/* Replacement cards */
.cs-repl-section { margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--border); }
.cs-repl-section h4 { font-size: 14px; margin-bottom: 10px; }
.cs-repl-card {
display: flex; align-items: center; gap: 12px;
background: rgba(255,255,255,0.02); border: 1px solid var(--border);
border-radius: 6px; padding: 10px 14px; margin-bottom: 8px;
flex-wrap: wrap;
}
.cs-repl-card.resolved { opacity: 0.55; }
.cs-repl-card .cs-repl-info { flex: 1; min-width: 200px; }
.cs-repl-card .cs-repl-info .cs-repl-old { color: var(--red); font-size: 12px; }
.cs-repl-card .cs-repl-info .cs-repl-new { color: var(--green); font-size: 12px; }
.cs-repl-card .cs-repl-info .cs-repl-addr { color: var(--text2); font-size: 11px; margin-top: 2px; }
.cs-repl-card .cs-repl-conf { font-size: 11px; padding: 2px 8px; border-radius: 10px; white-space: nowrap; }
.cs-repl-conf.high { background: rgba(255,140,0,0.15); color: #ff8c00; }
.cs-repl-conf.medium { background: rgba(255,193,7,0.15); color: #ffc107; }
.cs-repl-card .cs-repl-actions { display: flex; gap: 6px; flex-shrink: 0; }
.cs-repl-card .cs-repl-actions button { font-size: 11px; padding: 4px 12px; }
.cs-repl-status { font-size: 11px; padding: 2px 8px; border-radius: 10px; white-space: nowrap; }
.cs-repl-status.approved { background: rgba(72,199,142,0.15); color: var(--green); }
.cs-repl-status.rejected { background: rgba(240,61,62,0.15); color: var(--red); }
.cs-repl-resolve-all { margin-top: 8px; }
/* ── Route Planner iframe ── */
#pageRoute {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
margin-top: 0; padding: 0;
display: flex; flex-direction: column;
}
#pageRoute.hidden { display: none !important; }
#routeFrame {
width: 100%;
flex: 1;
border: none;
background: #fff;
}
/* ── Reports / Charts ── */
.report-filters {
display: flex; gap: 8px; flex-wrap: wrap; align-items: center;
margin-bottom: 16px;
}
.report-filters select, .report-filters input {
background: var(--card2); color: var(--text);
border: 1.5px solid var(--border); border-radius: var(--radius-sm);
padding: 8px 10px; font-size: 13px; outline: none;
}
.report-filters select:focus, .report-filters input:focus { border-color: var(--accent); }
.report-filters .rf-chips { display: flex; gap: 4px; flex-wrap: wrap; }
.report-filters .tech-opt:hover { background: rgba(108,92,231,.15); }
.report-filters .date-nav { display: inline-flex; align-items: center; gap: 2px; }
.report-filters .date-nav button { background: var(--card2,#2a2a3e); border: 1.5px solid var(--border,#444); border-radius: var(--radius-sm,6px); color: var(--text,#ddd); cursor: pointer; transition: all .15s; }
.report-filters .date-nav button:hover { background: var(--accent,#6c5ce7); border-color: var(--accent,#6c5ce7); }
.report-filters .date-nav select { width: 120px; }
.rf-chip {
padding: 4px 12px; border-radius: 14px; font-size: 11px; font-weight: 600;
border: 1.5px solid var(--border); cursor: pointer; transition: all 0.15s;
background: transparent; color: var(--text2);
}
.rf-chip.active { border-color: var(--accent); background: var(--accent-bg); color: var(--accent2); }
.rf-chip:hover { border-color: var(--accent2); }
.report-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
margin-bottom: 14px;
}
.report-grid .card { margin-bottom: 0; }
.report-card-full { grid-column: 1 / -1; }
.stat-bar {
display: flex; align-items: center; gap: 8px;
padding: 5px 0; font-size: 13px;
}
.stat-bar .sb-label { width: 140px; text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex-shrink: 0; color: var(--text2); font-weight: 500; }
.stat-bar .sb-track { flex: 1; height: 20px; background: var(--border); border-radius: 10px; overflow: hidden; position: relative; }
.stat-bar .sb-fill { display: block; height: 100%; border-radius: 10px; background: var(--accent); transition: width 0.4s ease; min-width: 2px; }
.stat-bar .sb-fill.green { background: var(--green); }
.stat-bar .sb-fill.red { background: var(--red); }
.stat-bar .sb-fill.amber { background: var(--amber); }
.stat-bar .sb-fill.purple { background: #a78bfa; }
.stat-bar .sb-fill.cyan { background: #22d3ee; }
.stat-bar .sb-fill.pink { background: #f472b6; }
.stat-bar .sb-fill.orange { background: #fb923c; }
.stat-bar .sb-count { width: 40px; text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; flex-shrink: 0; }
.stat-bar.sb-bar-sm { padding: 3px 0; font-size: 12px; }
.stat-bar.sb-bar-sm .sb-label { width: 100px; font-size: 11px; }
.stat-bar.sb-bar-sm .sb-track { height: 14px; }
.stat-bar.sb-bar-sm .sb-count { width: 30px; font-size: 12px; }
.stat-bar.sb-bar-xs { padding: 2px 0; font-size: 11px; }
.stat-bar.sb-bar-xs .sb-label { width: 80px; font-size: 10px; }
.stat-bar.sb-bar-xs .sb-track { height: 10px; }
.stat-bar.sb-bar-xs .sb-count { width: 24px; font-size: 10px; }
/* ── Donut chart (conic-gradient) ── */
.donut-wrapper {
display: flex; align-items: center; gap: 20px; flex-wrap: wrap;
}
.donut-ring {
width: 140px; height: 140px; border-radius: 50%;
position: relative; flex-shrink: 0;
}
.donut-ring .donut-hole {
position: absolute; inset: 28px; border-radius: 50%;
background: var(--card);
display: flex; align-items: center; justify-content: center;
flex-direction: column;
}
.donut-ring .donut-hole .dh-value { font-size: 24px; font-weight: 800; color: var(--text); }
.donut-ring .donut-hole .dh-label { font-size: 9px; color: var(--text2); text-transform: uppercase; letter-spacing: 0.04em; }
.donut-legend { display: flex; flex-direction: column; gap: 4px; min-width: 120px; }
.donut-legend .dl-item {
display: flex; align-items: center; gap: 6px; font-size: 12px;
}
.donut-legend .dl-item .dl-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.donut-legend .dl-item .dl-label { color: var(--text2); flex: 1; }
.donut-legend .dl-item .dl-count { font-weight: 700; font-variant-numeric: tabular-nums; }
/* ── Sparkline (SVG) ── */
.sparkline-wrap { position: relative; height: 100px; margin: 8px 0; }
.sparkline-wrap svg { width: 100%; height: 100%; }
.sparkline-wrap .sl-overlay {
position: absolute; top: 0; left: 0; right: 0; bottom: 0;
display: flex; align-items: center; justify-content: center;
font-size: 13px; color: var(--text3);
}
/* ── Technician Performance Table ── */
.tech-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.tech-table th {
text-align: left; padding: 8px 10px;
border-bottom: 2px solid var(--border);
color: var(--text2); font-weight: 600; font-size: 10px;
text-transform: uppercase; letter-spacing: 0.04em; white-space: nowrap;
}
.tech-table td { padding: 8px 10px; border-bottom: 1px solid rgba(255,255,255,0.04); }
.tech-table tr:hover td { background: rgba(255,255,255,0.02); }
.tech-table .tt-bar { display: inline-block; height: 6px; border-radius: 3px; min-width: 2px; vertical-align: middle; }
.tech-table .tt-name { font-weight: 600; }
/* ── Mini stat cards for report summaries ── */
.report-stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 10px; margin-bottom: 14px; }
.rs-card {
background: var(--card); border: 1px solid var(--border);
border-radius: var(--radius); padding: 14px; text-align: center;
}
.rs-card .rs-value { font-size: 26px; font-weight: 800; color: var(--accent2); }
.rs-card .rs-value.green { color: var(--green); }
.rs-card .rs-value.amber { color: var(--amber); }
.rs-card .rs-value.red { color: var(--red); }
.rs-card .rs-label { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text2); margin-top: 2px; }
/* ── Status badge color classes ── */
.rep-status-690970003 { color: var(--green); }
.rep-status-690970004 { color: var(--accent2); }
.rep-status-690970001 { color: var(--amber); }
.rep-status-690970002 { color: #22d3ee; }
.rep-status-690970000 { color: var(--text3); }
.rep-status-690970005 { color: var(--red); }
.rep-status-690959000 { color: var(--text2); }
/* Status bar colors */
.sb-fill.status-690970003 { background: var(--green); }
.sb-fill.status-690970004 { background: var(--accent2); }
.sb-fill.status-690970001 { background: var(--amber); }
.sb-fill.status-690970002 { background: #22d3ee; }
.sb-fill.status-690970000 { background: var(--text3); }
.sb-fill.status-690970005 { background: var(--red); }
.sb-fill.status-690959000 { background: var(--text2); }
/* ── Stacked bar for technician status per tech ── */
.stack-bar {
display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: 12px;
}
.stack-bar .st-label { width: 100px; text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex-shrink: 0; color: var(--text2); }
.stack-bar .st-track { flex: 1; height: 18px; border-radius: 9px; overflow: hidden; display: flex; background: var(--border); }
.stack-bar .st-seg { height: 100%; transition: width 0.4s ease; min-width: 2px; }
.stack-bar .st-count { width: 36px; text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; flex-shrink: 0; font-size: 11px; }
.stack-legend { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 6px; }
.stack-legend .sl-item { display: flex; align-items: center; gap: 4px; font-size: 10px; color: var(--text2); }
.stack-legend .sl-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
@media (max-width: 1000px) {
.report-grid { grid-template-columns: 1fr; }
}
@media (max-width: 600px) {
.report-filters { flex-direction: column; align-items: stretch; }
.report-filters select, .report-filters input { width: 100%; }
.report-filters .date-nav { width: 100%; justify-content: center; }
.report-filters .date-nav select { flex: 1; }
.donut-wrapper { flex-direction: column; align-items: center; }
.report-stats { grid-template-columns: repeat(2, 1fr); }
}
/* ── Cleanup Tool ── */
.cu-filters { margin-bottom: 12px; }
.cu-f-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end; }
.cu-label { display: block; font-size: 11px; font-weight: 600; color: var(--text2); margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.3px; }
.cu-count { font-size: 14px; font-weight: 700; color: var(--accent2); padding: 6px 12px; background: var(--accent-bg); border-radius: var(--radius); white-space: nowrap; }
.cu-bulk-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 8px; margin-bottom: 8px; }
.cu-bulk-actions { display: flex; gap: 8px; margin-top: 8px; }
.cu-name-preview { font-size: 13px; color: var(--accent2); background: var(--accent-bg); padding: 8px 12px; border-radius: var(--radius-xs); margin-bottom: 8px; font-family: monospace; }
.cu-asset-row { display: flex; gap: 8px; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
.cu-asset-row:last-child { border-bottom: none; }
.cu-ar-id { font-size: 11px; color: var(--text3); min-width: 50px; }
.cu-ar-name { flex: 2; font-weight: 600; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cu-ar-make { flex: 1; min-width: 80px; }
.cu-ar-model { flex: 1; min-width: 80px; }
.cu-ar-building { flex: 1; min-width: 80px; }
.cu-ar-floor { flex: 0.6; min-width: 60px; }
.cu-ar-company { flex: 1.5; min-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cu-ar-place { flex: 1; min-width: 80px; }
.cu-pagination { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; }
.cu-pagination .btn { font-size: 12px; }
.cu-inline-edit { background: transparent; border: 1px solid var(--border); border-radius: var(--radius-xs); padding: 2px 6px; font-size: 12px; width: 100%; color: var(--text); }
.cu-inline-edit:focus { border-color: var(--accent); outline: none; }
.cu-bulk-edit-row { display: flex; gap: 6px; margin-bottom: 6px; }
.cu-select-all { margin-right: 8px; }
@media (max-width: 768px) {
.cu-f-row { flex-direction: column; }
.cu-bulk-grid { grid-template-columns: 1fr 1fr; }
.cu-asset-row { flex-wrap: wrap; }
.cu-ar-company { min-width: 100%; }
}
</style>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://cdn.jsdelivr.net/npm/exifr@7/dist/lite.umd.js"></script>
</head>
<body>
<!-- ═══ LOGIN OVERLAY ═══ -->
<div id="loginOverlay" class="login-overlay">
<div class="login-card">
<div class="login-icon">🗝️</div>
<h2>Canteen Admin</h2>
<div id="loginError" class="login-error"></div>
<input id="loginUser" class="input-field" type="text" placeholder="Username" autocomplete="username">
<input id="loginPass" class="input-field" type="password" placeholder="Password" autocomplete="current-password">
<label style="display:flex;align-items:center;gap:6px;font-size:13px;color:var(--text2,#999);margin-top:4px;cursor:pointer;">
<input type="checkbox" id="loginRemember" checked> Remember me (30 days)
</label>
<button id="loginBtn" class="btn btn-primary btn-block" onclick="doLogin()">Sign In</button>
</div>
</div>
<!-- ═══ SIDEBAR OVERLAY (mobile) ═══ -->
<div id="sidebarOverlay" class="sidebar-overlay" onclick="toggleSidebar()"></div>
<!-- ═══ SIDEBAR ═══ -->
<div class="sidebar" id="sidebar"> <!-- add hamburger to title bar-->
<div class="sidebar-header">
<span class="sh-icon">📊</span>
<span>Canteen Admin</span>
</div>
<div class="sidebar-user">
<div class="su-avatar" id="sideAvatar">?</div>
<div class="su-info">
<div class="su-name" id="sideName">Not logged in</div>
<div class="su-role" id="sideRole">guest</div>
</div>
</div>
<div class="sidebar-nav">
<button class="nav-item" data-page="dashboard" onclick="switchPage('dashboard')" title="Dashboard — overview stats, charts, and recent activity">
<span class="ni-icon">📈</span><span>Dashboard</span>
</button>
<button class="nav-item" data-page="assets" onclick="switchPage('assets')" title="Assets — search, sort, and inline-edit all assets">
<span class="ni-icon">📦</span><span>Assets</span><span class="ni-badge" id="assetBadgeNav"></span>
</button>
<button class="nav-item" data-page="settings" onclick="switchPage('settings')" title="Settings — manage categories, makes, models, key names, badge types">
<span class="ni-icon">⚙️</span><span>Settings</span>
</button>
<button class="nav-item" data-page="users" onclick="switchPage('users')" title="Users — manage admin accounts and roles">
<span class="ni-icon">👥</span><span>Users</span><span class="ni-badge" id="userBadgeNav"></span>
</button>
<button class="nav-item" data-page="customers" onclick="switchPage('customers')" title="Customers & Locations — manage customer info and site locations">
<span class="ni-icon">🏢</span><span>Customers</span><span class="ni-badge" id="custBadgeNav"></span>
</button>
<button class="nav-item" data-page="activity" onclick="switchPage('activity')" title="Activity Log — audit trail of all actions">
<span class="ni-icon">📋</span><span>Activity Log</span>
</button>
<button class="nav-item" data-page="export" onclick="switchPage('export')" title="Export & Admin — CSV exports, GPS clearing, database reset">
<span class="ni-icon">📤</span><span>Export</span>
</button>
<button class="nav-item" data-page="replacements" onclick="switchPage('replacements')" title="Replacements — machine replacement history tracking old→new MID at same location">
<span class="ni-icon">🔄</span><span>Replacements</span>
</button>
<button class="nav-item" data-page="cantaloupe" onclick="switchPage('cantaloupe')" title="Import — upload Excel files and review changes">
<span class="ni-icon">📥</span><span>Import</span>
</button>
<button class="nav-item" data-page="exifscanner" onclick="switchPage('exifscanner')" title="EXIF Scanner — upload photos, extract GPS, OCR machine IDs">
<span class="ni-icon">📸</span><span>EXIF Scanner</span>
</button>
<button class="nav-item" data-page="route" onclick="switchPage('route')" title="Route Planner — view asset routes (external iframe)">
<span class="ni-icon">🗺️</span><span>Route</span>
</button>
<button class="nav-item" data-page="reports" onclick="switchPage('reports')" title="Reports — work order KPIs and performance charts for field techs">
<span class="ni-icon">📊</span><span>Reports</span>
</button>
<button class="nav-item" data-page="workorders" onclick="switchPage('workorders')" title="Work Orders — search, filter, and view field service work orders">
<span class="ni-icon">🔧</span><span>Work Orders</span>
</button>
<button class="nav-item" data-page="msfssync" onclick="switchPage('msfssync')" title="MSFS Sync — trigger ADB data pull from the Dynamics 365 mobile app">
<span class="ni-icon">📡</span><span>MSFS Sync</span>
</button>
<button class="nav-item" data-page="cleanup" onclick="switchPage('cleanup')" title="Cleanup — review and sanitize asset names, locations, buildings">
<span class="ni-icon">🧹</span><span>Cleanup</span>
</button>
<button class="nav-item" onclick="openHelpModal()" title="Help — keyboard shortcuts, tips, and documentation" style="border-top:1px solid var(--border);margin-top:4px;">
<span class="ni-icon"></span><span>Help</span>
</button>
</div>
<div class="sidebar-footer">
<button id="logoutBtn" class="logout-btn" onclick="doLogout()">🚪 Sign out</button>
<span>v3.0 admin</span>
</div>
</div>
<!-- ═══ MAIN CONTENT ═══ -->
<div class="main">
<!-- Hamburger button (mobile) -->
<div style="display:flex;align-items:center;margin-bottom:12px;" class="mobile-title-row">
<button class="hamburger" onclick="toggleSidebar()" aria-label="Toggle menu"></button>
</div>
<!-- ── DASHBOARD ── -->
<div id="pageDashboard" class="page">
<div class="page-title">
<span>Dashboard</span>
<div class="pt-actions">
<button class="btn btn-sm btn-outline" onclick="loadDashboard()">🔄 Refresh</button>
</div>
</div>
<div id="dashStats" class="stats-grid"></div>
<div class="card">
<div class="card-title">By Category</div>
<div id="dashCategoryBars"></div>
</div>
<div class="card">
<div class="card-title">By Make</div>
<div id="dashMakeBars"></div>
</div>
<div class="card">
<div class="card-title">By Status</div>
<div id="dashStatusBars"></div>
</div>
<div class="card">
<div class="card-title">Recent Activity</div>
<div id="dashActivity"></div>
<div style="text-align:center;margin-top:10px;">
<button class="btn btn-sm btn-outline" onclick="switchPage('activity')">View Full Log →</button>
</div>
</div>
<div class="card">
<div class="card-title">📍 Clear Asset GPS</div>
<p style="font-size:13px;color:var(--text2);margin-bottom:10px;">
Clear GPS coordinates from an asset. Enter the asset ID or machine ID.
</p>
<div style="display:flex;gap:8px;margin-bottom:10px;">
<input id="clearGpsAssetId" type="text" class="input-field" placeholder="Asset ID or Machine ID" style="flex:1;">
<button class="btn btn-outline btn-sm" onclick="clearAssetGps()">📍 Clear GPS</button>
</div>
<div id="clearGpsResult" style="display:none;"></div>
</div>
</div>
<!-- ── SETTINGS ── -->
<div id="pageSettings" class="page hidden">
<div class="page-title">
<span>Settings</span>
<div class="pt-actions">
<button class="btn btn-sm btn-outline" onclick="renderAllSettings()">🔄 Refresh</button>
</div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">Categories</span>
<button class="btn btn-sm btn-outline" onclick="showSettingsAddForm('categories')">+ Add</button>
</div>
<div id="settingsCategories" class="settings-list"></div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">Makes & Models</span>
<button class="btn btn-sm btn-outline" onclick="showSettingsAddForm('makes')">+ Add Make</button>
</div>
<div id="settingsMakes" class="settings-list"></div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">Key Names</span>
<button class="btn btn-sm btn-outline" onclick="showSettingsAddForm('key_names')">+ Add</button>
</div>
<div id="settingsKeyNames" class="settings-list"></div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">Key Types</span>
<button class="btn btn-sm btn-outline" onclick="showSettingsAddForm('key_types')">+ Add</button>
</div>
<div id="settingsKeyTypes" class="settings-list"></div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">Badge Types</span>
<button class="btn btn-sm btn-outline" onclick="showSettingsAddForm('badge_types')">+ Add</button>
</div>
<div id="settingsBadgeTypes" class="settings-list"></div>
</div>
</div>
<!-- ── USERS ── -->
<div id="pageUsers" class="page hidden">
<div class="page-title">
<span>User Accounts</span>
<div class="pt-actions">
<button class="btn btn-sm btn-outline" onclick="showAddUserForm()">+ Add User</button>
<button class="btn btn-sm btn-outline" onclick="loadUsers()">🔄 Refresh</button>
</div>
</div>
<div id="usersContent"></div>
</div>
<!-- ── CUSTOMERS & LOCATIONS ── -->
<div id="pageCustomers" class="page hidden">
<div class="page-title">
<span>Customers & Locations</span>
<div class="pt-actions">
<button class="btn btn-sm btn-outline" onclick="showAddCustomerForm()">+ Add Customer</button>
<button class="btn btn-sm btn-outline" onclick="loadCustomers()">🔄 Refresh</button>
</div>
</div>
<input id="custSearch" type="text" class="input-field" placeholder="Search customers..." oninput="loadCustomers()">
<div id="customersContent"></div>
<div id="customerDetail" class="hidden">
<button class="btn btn-sm btn-outline mb-8" onclick="closeCustomerDetail()">← Back</button>
<div id="customerDetailContent"></div>
</div>
</div>
<!-- ── ACTIVITY LOG ── -->
<div id="pageActivity" class="page hidden">
<div class="page-title">
<span>Activity Log</span>
<div class="pt-actions">
<button class="btn btn-sm btn-outline" onclick="loadActivity()">🔄 Refresh</button>
</div>
</div>
<div class="card">
<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 types</option>
<option value="asset">Assets</option>
<option value="checkin">Check-ins</option>
<option value="user">Users</option>
<option value="customer">Customers</option>
<option value="location">Locations</option>
<option value="settings">Settings</option>
<option value="geofence">Geofences</option>
</select>
<input id="actDateFrom" class="input-field" type="date" onchange="loadActivity()">
<input id="actDateTo" class="input-field" type="date" onchange="loadActivity()">
</div>
<div id="activityList"></div>
<div id="activityPagination" class="act-pagination"></div>
</div>
</div>
<!-- ── EXPORT ── -->
<div id="pageExport" class="page hidden">
<div class="page-title">
<span>Export & Admin</span>
</div>
<div class="card">
<div class="card-title">CSV Exports</div>
<div style="display:flex;flex-wrap:wrap;gap:8px;">
<a href="/api/export/assets" class="btn btn-outline" download>📄 Export Assets CSV</a>
<a href="/api/export/checkins" class="btn btn-outline" download>📄 Export Check-Ins CSV</a>
</div>
</div>
<div class="card">
<div class="card-title">Clear Asset GPS</div>
<p style="font-size:13px;color:var(--text2);margin-bottom:10px;">
Clear GPS coordinates from an asset. Enter the asset ID or machine ID.
</p>
<div style="display:flex;gap:8px;margin-bottom:10px;">
<input id="clearGpsAssetId" type="text" class="input-field" placeholder="Asset ID or Machine ID" style="flex:1;">
<button class="btn btn-outline btn-sm" onclick="clearAssetGps()">📍 Clear GPS</button>
</div>
<div id="clearGpsResult" style="display:none;"></div>
</div>
<div class="card">
<div class="card-title">Database</div>
<div style="display:flex;flex-wrap:wrap;gap:8px;">
<button class="btn btn-danger btn-sm" onclick="confirmResetDb()">⚠️ Reset Database</button>
</div>
<div id="resetResult" class="text-sm text-muted mt-8"></div>
</div>
</div>
<!-- ── REPLACEMENTS ── -->
<div id="pageReplacements" class="page hidden">
<div class="page-title">
<span>🔄 Machine Replacements</span>
<div class="pt-actions">
<button class="btn btn-sm btn-outline" onclick="loadReplacements()">🔄 Refresh</button>
</div>
</div>
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;flex-wrap:wrap;gap:8px;">
<div>
<span style="font-size:13px;color:var(--text2);" id="repCount"></span>
</div>
<div style="display:flex;gap:8px;">
<input type="text" id="repSearch" placeholder="🔍 Search location or MID..." style="padding:6px 10px;border-radius:6px;border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:13px;width:260px;" oninput="filterReplacements()">
</div>
</div>
<div id="repError" class="text-sm text-muted" style="display:none;padding:16px;"></div>
<div style="overflow-x:auto;">
<table class="data-table" id="repTable">
<thead>
<tr>
<th>Date</th>
<th>Location</th>
<th>Old MID</th>
<th>Old Name</th>
<th>Old Status</th>
<th></th>
<th>New MID</th>
<th>New Name</th>
<th>New Status</th>
<th>Confidence</th>
</tr>
</thead>
<tbody id="repBody"></tbody>
</table>
</div>
<div id="repEmpty" class="empty-state" style="display:none;padding:40px;">
<div class="es-icon">🔄</div>
<div>No replacement events found</div>
<div class="text-sm text-muted">Replacements appear when a machine is swapped at the same location.</div>
</div>
</div>
</div>
<!-- ── EXCEL IMPORT ── -->
<div id="pageCantaloupe" class="page hidden">
<div class="page-title">
<span>Excel Import</span>
<div class="pt-actions">
<input type="file" id="csFileInput" accept=".xlsx,.xls" style="display:none" onchange="uploadSyncFile(event)">
<button class="btn btn-sm btn-primary" onclick="document.getElementById('csFileInput').click()">📤 Upload Excel</button>
<button class="btn btn-sm btn-outline" onclick="loadCantaloupeSync()">↻ Refresh</button>
</div>
</div>
<!-- Section 1: Sync Overview -->
<div class="card" id="csOverview">
<div class="card-title">Latest Sync Status</div>
<div id="csOverviewContent"><div class="loading-row"><div class="spinner"></div> Loading...</div></div>
</div>
<!-- Section 2: Pending Batch Review -->
<div class="card hidden" id="csPendingReview">
<div class="card-title">Pending Batch — Review Changes</div>
<div id="csPendingContent"></div>
</div>
<!-- Section 3: Import History -->
<div class="card">
<div class="card-title">Import History</div>
<div id="csHistoryContent"><div class="loading-row"><div class="spinner"></div> Loading...</div></div>
</div>
</div>
<!-- ── ASSETS ── -->
<div id="pageAssets" class="page hidden">
<div class="page-title">
<span>Assets</span>
<div class="pt-actions">
<input id="assetSearch" type="text" class="input-field" placeholder="Search name, machine ID, company... (Ctrl+K)" oninput="loadAssets()" style="width:260px;font-size:12px;padding:6px 10px;">
<select id="assetStatusFilter" class="input-field" onchange="loadAssets()" style="width:auto;font-size:12px;">
<option value="">All Status</option>
<option value="active">Active</option>
<option value="maintenance">Maintenance</option>
<option value="retired">Retired</option>
</select>
<button class="btn btn-sm btn-outline" onclick="loadAssets()">🔄 Refresh</button>
<button class="btn btn-sm btn-accent" onclick="batchLookupAll()">🔍 Batch Lookup</button>
<button class="btn btn-sm btn-outline" onclick="showBatchStatusModal()">🏷️ Batch Status</button>
</div>
</div>
<div id="assetsContent">
<div class="text-muted" style="padding:40px;text-align:center;">Loading assets...</div>
</div>
<div id="assetPagination" class="act-pagination"></div>
</div>
<!-- ── DETAIL PAGES ── -->
<div id="pageDetail" class="page hidden">
<div class="page-title">
<span id="detailTitle">Details</span>
<div class="pt-actions">
<button class="btn btn-sm btn-outline" onclick="closeDetail()">← Back</button>
</div>
</div>
<div id="detailContent"></div>
</div>
<!-- ── EXIF SCANNER ── -->
<div id="pageExifscanner" class="page hidden">
<div class="page-title">
<span>📸 EXIF Scanner</span>
<div class="pt-actions">
<button class="btn btn-sm btn-outline" onclick="loadExifScanner()">🔄 Refresh</button>
</div>
</div>
<!-- Upload area -->
<div id="exifUploadArea" class="card" style="cursor:pointer;text-align:center;padding:32px 20px;" onclick="document.getElementById('exifFileInput').click()">
<div style="font-size:48px;margin-bottom:8px;">📷</div>
<div style="font-size:16px;font-weight:600;">Drop photos or click to upload</div>
<div style="font-size:12px;color:var(--text2);margin-top:4px;">JPEG, PNG, HEIC — GPS data preserved</div>
<input id="exifFileInput" type="file" multiple accept="image/*" style="display:none" onchange="handleExifFiles(this.files)">
</div>
<!-- OCR Engine options -->
<div id="exifOcrOptions" class="card" style="display:none;">
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
<span style="font-size:12px;color:var(--text2);">🔎 OCR:</span>
<select id="exifOcrEngine" style="background:var(--card2);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:4px 8px;font-size:12px;" onchange="onExifOcrToggle()">
<option value="tesseract">Tesseract</option>
<option value="llm">LLM</option>
<option value="google">Google Gemini</option>
</select>
<label style="display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text2);cursor:pointer;">
<input type="checkbox" id="exifStickerMode" onchange="onExifOcrToggle()"> 🏷️ Sticker mode
</label>
<span id="exifModelLabel" style="font-size:11px;color:var(--text3);">tesseract</span>
<button class="btn btn-sm btn-primary" id="exifBulkBtn" style="display:none;" onclick="startExifBulkProcess()">🔍 Bulk Process (<span id="exifFileCount">0</span>)</button>
</div>
</div>
<!-- Summary -->
<div id="exifSummary" class="card" style="display:none;">
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:4px;text-align:center;">
<div><span class="summary-gps" id="exifSumGps" style="font-size:24px;font-weight:800;color:var(--green);">0</span><div style="font-size:10px;color:var(--text2);text-transform:uppercase;letter-spacing:0.05em;">GPS</div></div>
<div><span id="exifSumNoGps" style="font-size:24px;font-weight:800;color:var(--amber);">0</span><div style="font-size:10px;color:var(--text2);text-transform:uppercase;letter-spacing:0.05em;">No GPS</div></div>
<div><span id="exifSumMatched" style="font-size:24px;font-weight:800;color:var(--accent2);">0</span><div style="font-size:10px;color:var(--text2);text-transform:uppercase;letter-spacing:0.05em;">Matched</div></div>
<div><span id="exifSumTotal" style="font-size:24px;font-weight:800;">0</span><div style="font-size:10px;color:var(--text2);text-transform:uppercase;letter-spacing:0.05em;">Total</div></div>
</div>
</div>
<!-- Gallery -->
<div id="exifGallery" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:6px;margin-bottom:10px;display:none;"></div>
<!-- Detail -->
<div id="exifDetail" class="card" style="display:none;">
<img id="exifDetailPreview" style="width:100%;max-height:400px;object-fit:contain;border-radius:var(--radius-sm);margin-bottom:10px;cursor:zoom-in;" onclick="openExifLightbox(this.src)">
<div id="exifDetailContent"></div>
</div>
<!-- Bulk results -->
<div id="exifBulkSection" class="card" style="display:none;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;">
<span class="card-title">Bulk Results</span>
<span id="exifBulkSummary" class="status-tag" style="font-size:11px;"></span>
</div>
<div id="exifMapContainer" style="height:280px;border-radius:var(--radius-sm);margin-bottom:10px;display:none;background:var(--card2);"></div>
<div id="exifBulkResults"></div>
<!-- Batch bar -->
<div id="exifBatchBar" style="display:none;margin-top:8px;padding:8px;background:var(--card2);border-radius:var(--radius-sm);">
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;">
<span id="exifBatchCount" style="font-size:11px;">📎 0 selected</span>
<input type="text" id="exifBatchMachineId" placeholder="Machine ID..." style="background:var(--card);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:4px 8px;font-size:12px;flex:1;min-width:120px;" disabled>
<button class="btn btn-xs btn-primary" id="exifBatchLookup" disabled onclick="exifBatchLookup()">🔍</button>
<button class="btn btn-xs" id="exifBatchPush" style="background:var(--green);color:#000;display:none;" onclick="exifBatchPushGps()">📤 Push GPS</button>
</div>
</div>
</div>
<!-- Sessions -->
<div id="exifSessionsSection" class="card" style="display:none;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;">
<span class="card-title">📋 Sessions <span id="exifSessionCount" class="badge badge-info">0</span></span>
<div style="display:flex;gap:6px;align-items:center;">
<input type="text" id="exifSessionName" placeholder="New session name..." style="background:var(--card2);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:4px 8px;font-size:12px;width:180px;">
<button class="btn btn-xs btn-outline" onclick="exifCreateSession()">+ New</button>
</div>
</div>
<div id="exifSessionsList"></div>
</div>
<!-- Previously processed -->
<div id="exifPrevSection" class="card" style="display:none;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;">
<span class="card-title">📂 Previously Processed <span id="exifPrevCount" class="badge badge-info">0</span></span>
<button class="btn btn-xs btn-outline" onclick="loadExifPrevPhotos()">🔄 Refresh</button>
</div>
<div id="exifPrevResults"></div>
</div>
</div> <!-- /pageExifscanner -->
<!-- ── ROUTE PLANNER (iframe) ── -->
<div id="pageRoute" class="page hidden">
<iframe id="routeFrame" src="https://route.ourpad.casa" frameborder="0"></iframe>
</div>
<!-- ── REPORTS ── -->
<div id="pageReports" class="page hidden">
<div class="page-title">
<span>📊 Work Order Reports</span>
<div class="pt-actions">
<button class="btn btn-sm btn-outline" onclick="loadReports()">🔄 Refresh</button>
</div>
</div>
<!-- Filters -->
<div class="card" style="padding:12px 16px;">
<div class="report-filters">
<span class="date-nav">
<button type="button" class="btn btn-sm" onclick="shiftDateRange(-1)" title="Go back" id="datePrevBtn" style="padding:4px 8px;font-size:13px;line-height:1;"></button>
<select id="repDateRange" onchange="onRepFilterChange()" style="width:120px;">
<option value="all">🌐 All Time</option>
<option value="today">Today</option>
<option value="thisweek">This Week</option>
<option value="lastweek">Last Week</option>
<option value="thismonth">This Month</option>
<option value="last30">Last 30 Days</option>
<option value="last90">Last 90 Days</option>
<option value="custom">Custom Range</option>
</select>
<button type="button" class="btn btn-sm" onclick="shiftDateRange(1)" title="Go forward" id="dateNextBtn" style="padding:4px 8px;font-size:13px;line-height:1;"></button>
<span id="dateRangeLabel" style="font-size:11px;color:var(--text-muted,#888);margin-left:4px;white-space:nowrap;"></span>
</span>
<input id="repDateFrom" type="date" style="display:none;width:140px;" onchange="onRepFilterChange()">
<input id="repDateTo" type="date" style="display:none;width:140px;" onchange="onRepFilterChange()">
<div class="tech-multi" style="position:relative;width:200px;">
<div id="repTechTrigger" onclick="toggleTechPanel(event)" style="display:flex;align-items:center;gap:6px;padding:6px 10px;background:var(--input-bg,#2a2a3e);border:1px solid var(--border,#444);border-radius:8px;cursor:pointer;font-size:13px;white-space:nowrap;overflow:hidden;user-select:none;">
<span>👤</span>
<span id="repTechLabel" style="flex:1;overflow:hidden;text-overflow:ellipsis;">All Technicians</span>
<span style="font-size:10px;margin-left:auto;"></span>
</div>
<div id="repTechPanel" style="display:none;position:absolute;top:calc(100% + 2px);left:0;right:0;max-height:280px;overflow-y:auto;background:var(--card-bg,#1e1e2e);border:1px solid var(--border,#444);border-radius:8px;z-index:200;padding:6px 0;box-shadow:0 8px 24px rgba(0,0,0,.4);">
<label class="tech-opt" style="display:flex;align-items:center;gap:8px;padding:6px 12px;cursor:pointer;font-size:13px;">
<input type="checkbox" value="" checked onchange="onRepTechAll(this)" style="accent-color:#6c5ce7;">
<span>All Technicians</span>
</label>
<div style="height:1px;background:var(--border,#444);margin:4px 8px;"></div>
<div id="repTechChecks"></div>
</div>
</div>
<select id="repStatus" onchange="onRepFilterChange()" style="width:140px;">
<option value="">📋 All Statuses</option>
<option value="690970003">Completed</option>
<option value="690970004">Posted</option>
<option value="690970001">Scheduled</option>
<option value="690970002">In Progress</option>
<option value="690970000">Unscheduled</option>
<option value="690970005">Cancelled</option>
<option value="690959000">Postponed</option>
</select>
</div>
</div>
<!-- Summary Stats -->
<div id="repStats" class="report-stats">
<div class="rs-card"><div class="skeleton-circle"></div><div class="skeleton-bar w60" style="margin:4px auto"></div></div>
<div class="rs-card"><div class="skeleton-circle"></div><div class="skeleton-bar w60" style="margin:4px auto"></div></div>
<div class="rs-card"><div class="skeleton-circle"></div><div class="skeleton-bar w60" style="margin:4px auto"></div></div>
<div class="rs-card"><div class="skeleton-circle"></div><div class="skeleton-bar w60" style="margin:4px auto"></div></div>
<div class="rs-card"><div class="skeleton-circle"></div><div class="skeleton-bar w60" style="margin:4px auto"></div></div>
</div>
<!-- Charts Grid -->
<div class="report-grid">
<!-- WOs Over Time -->
<div class="card report-card-full">
<div class="card-title">📈 Work Orders Over Time</div>
<div id="repOverTime" style="height:140px;"><div class="spinner" style="margin:40px auto;display:block;"></div></div>
</div>
<!-- By Status (Donut) -->
<div class="card">
<div class="card-title">📊 By Status</div>
<div id="repByStatus"><div class="spinner" style="margin:30px auto;display:block;"></div></div>
</div>
<!-- By Work Type -->
<div class="card">
<div class="card-title">🔧 By Work Type</div>
<div id="repByWorkType"><div class="spinner" style="margin:30px auto;display:block;"></div></div>
</div>
<!-- By Technician -->
<div class="card report-card-full">
<div class="card-title">👷 By Technician</div>
<div id="repByTechnician"><div class="spinner" style="margin:30px auto;display:block;"></div></div>
</div>
<!-- Technician Performance Table -->
<div class="card report-card-full">
<div class="card-title">📋 Technician Performance</div>
<div id="repTechTableWrap"><div class="spinner" style="margin:30px auto;display:block;"></div></div>
</div>
<!-- By Priority -->
<div class="card">
<div class="card-title">⚡ By Priority</div>
<div id="repByPriority"><div class="spinner" style="margin:30px auto;display:block;"></div></div>
</div>
<!-- Stacked bar: Status per Tech -->
<div class="card">
<div class="card-title">📊 Status Distribution per Tech</div>
<div id="repStatusByTech"><div class="spinner" style="margin:30px auto;display:block;"></div></div>
</div>
</div>
</div>
<!-- ── WORK ORDERS ── -->
<div id="pageWorkorders" class="page hidden">
<div class="page-title">
<span>🔧 Work Orders</span>
<div class="pt-actions">
<button class="btn btn-sm btn-outline" onclick="loadWorkOrders()">🔄 Refresh</button>
</div>
</div>
<!-- Filters -->
<div class="card" style="padding:10px 14px;">
<div class="act-filter-row" style="align-items:center;">
<input id="woSearch" type="text" class="input-field" placeholder="🔍 Search WO#, account, city..." oninput="scheduleWOSearch()" style="flex:2;margin-bottom:0;">
<select id="woStatusFilter" class="input-field" onchange="loadWorkOrders()" style="width:130px;margin-bottom:0;">
<option value="">📋 All Status</option>
<option value="690970003">Completed</option>
<option value="690970004">Posted</option>
<option value="690970001">Scheduled</option>
<option value="690970002">In Progress</option>
<option value="690970000">Unscheduled</option>
<option value="690970005">Cancelled</option>
<option value="690959000">Postponed</option>
</select>
<select id="woTechFilter" class="input-field" onchange="loadWorkOrders()" style="width:140px;margin-bottom:0;">
<option value="">👤 All Techs</option>
</select>
<select id="woDateRange" class="input-field" onchange="onWODateRangeChange()" style="width:120px;margin-bottom:0;">
<option value="all">🌐 All Time</option>
<option value="today">Today</option>
<option value="thisweek">This Week</option>
<option value="thismonth">This Month</option>
<option value="custom">Custom</option>
</select>
<input id="woDateFrom" type="date" style="display:none;width:130px;" onchange="loadWorkOrders()" class="input-field">
<input id="woDateTo" type="date" style="display:none;width:130px;" onchange="loadWorkOrders()" class="input-field">
</div>
<div id="woActiveTags" style="display:flex;gap:4px;flex-wrap:wrap;margin-top:6px;"></div>
</div>
<!-- Active status pills -->
<div style="display:flex;gap:10px;margin-bottom:10px;flex-wrap:wrap;font-size:12px;color:var(--text2);">
<span id="woTotalCount">Loading...</span>
</div>
<!-- Work order cards -->
<div id="woCards" style="display:flex;flex-direction:column;gap:8px;">
<div class="card" style="text-align:center;padding:40px;"><div class="spinner" style="margin:0 auto;"></div></div>
</div>
<!-- Pagination -->
<div id="woPagination" class="act-pagination"></div>
</div>
<!-- ── MSFS SYNC (ADB) ── -->
<div id="pageMsfssync" class="page hidden">
<div class="page-title">
<span>📡 MSFS ADB Sync</span>
<div class="pt-actions">
<button class="btn btn-sm btn-outline" onclick="loadMsfsSync()">🔄 Refresh</button>
</div>
</div>
<!-- Connection Status -->
<div class="card">
<div class="card-title">ADB Connection Status</div>
<div id="msfsConnStatus">
<div class="loading-row"><div class="spinner"></div> Loading...</div>
</div>
</div>
<!-- Sync Control -->
<div class="card">
<div class="card-title">Sync Controls</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button id="msfsSyncBtn" class="btn btn-primary" onclick="startMsfsSync()">▶ Start Sync</button>
<button id="msfsLaunchBtn" class="btn btn-outline" onclick="launchMsfsApp()">📱 Launch App on Phone</button>
<button id="msfsCancelBtn" class="btn btn-danger" style="display:none;" onclick="cancelMsfsSync()">✕ Cancel Sync</button>
</div>
</div>
<!-- Sync Progress -->
<div id="msfsProgressCard" class="card" style="display:none;">
<div class="card-title">Sync Progress</div>
<div id="msfsProgressContent">
<div class="loading-row"><div class="spinner"></div> <span id="msfsProgressMsg">Starting...</span></div>
</div>
<div id="msfsProgressResult" style="display:none;margin-top:10px;"></div>
</div>
<!-- Recent Syncs -->
<div class="card">
<div class="card-title">Sync History</div>
<div id="msfsHistory">
<div style="font-size:13px;color:var(--text2);">No syncs recorded yet.</div>
</div>
</div>
</div>
<!-- ── CLEANUP TOOL ── -->
<div id="pageCleanup" class="page hidden">
<div class="page-title">
<span>🧹 Asset Cleanup</span>
<div class="pt-actions">
<button class="btn btn-sm btn-outline" onclick="loadCleanup()">🔄 Refresh</button>
</div>
</div>
<!-- Filters -->
<div class="card">
<div class="card-title">Filter Assets</div>
<div id="cleanupFilters" class="cu-filters">
<div class="cu-f-row">
<div style="flex:1;">
<label class="cu-label">Company</label>
<select id="cuFilterCompany" class="input-field" onchange="loadCleanup()">
<option value="">All Companies</option>
</select>
</div>
<div style="flex:1;">
<label class="cu-label">Place / City</label>
<select id="cuFilterPlace" class="input-field" onchange="loadCleanup()">
<option value="">All Places</option>
</select>
</div>
<div style="flex:1;">
<label class="cu-label">Make</label>
<select id="cuFilterMake" class="input-field" onchange="loadCleanup()">
<option value="">All Makes</option>
</select>
</div>
<div style="flex-shrink:0;display:flex;align-items:flex-end;">
<div id="cuCount" class="cu-count">0 assets</div>
</div>
</div>
</div>
</div>
<!-- Bulk Edit Bar -->
<div id="cuBulkBar" class="card" style="display:none;">
<div class="card-title">✏️ Bulk Edit <span id="cuBulkCount"></span></div>
<div class="cu-bulk-grid">
<div><label class="cu-label">Make</label><input id="cuBulkMake" class="input-field" placeholder="e.g. Crane"></div>
<div><label class="cu-label">Model</label><input id="cuBulkModel" class="input-field" placeholder="e.g. 5800"></div>
<div><label class="cu-label">Building</label><input id="cuBulkBuilding" class="input-field" placeholder="e.g. B7"></div>
<div><label class="cu-label">Floor</label><input id="cuBulkFloor" class="input-field" placeholder="e.g. FL 1"></div>
<div><label class="cu-label">Room</label><input id="cuBulkRoom" class="input-field" placeholder="e.g. 101"></div>
<div><label class="cu-label">Company</label><input id="cuBulkCompany" class="input-field" placeholder="Override company"></div>
<div><label class="cu-label">Place</label><input id="cuBulkPlace" class="input-field" placeholder="Override place/city"></div>
<div><label class="cu-label">Location Area</label><input id="cuBulkLocation" class="input-field" placeholder="e.g. Cafeteria"></div>
</div>
<div class="cu-name-preview" id="cuNamePreview">📝 Name preview will appear here</div>
<div class="cu-bulk-actions">
<button class="btn btn-sm btn-accent" onclick="applyBulkEdit()">💾 Apply to <span id="cuBulkApplyCount">0</span> Assets</button>
<button class="btn btn-sm btn-outline" onclick="clearBulkEdit()">✕ Clear Fields</button>
</div>
</div>
<!-- Results List -->
<div id="cuResults" class="card">
<div class="card-title">Results</div>
<div id="cuResultsList">
<div style="font-size:13px;color:var(--text2);">Select filters above to begin.</div>
</div>
<div id="cuPagination" class="cu-pagination"></div>
</div>
</div>
</div>
<!-- ═══ MODAL ═══ -->
<div id="modalOverlay" class="modal-overlay" onclick="closeModal(false)">
<div class="modal" onclick="event.stopPropagation()">
<div id="modalTitle" class="modal-title"></div>
<div id="modalBody" class="modal-body"></div>
<div id="modalActions" class="modal-actions"></div>
</div>
</div>
<!-- ═══ HELP MODAL ═══ -->
<div id="helpModal" class="modal-overlay" style="z-index:600;" onclick="closeHelpModal()">
<div class="modal" onclick="event.stopPropagation()" style="max-width:560px;max-height:80vh;overflow-y:auto;">
<div class="modal-title" style="font-size:18px;">❓ Help &amp; Quick Tips</div>
<div class="modal-body" style="font-size:13px;color:var(--text2);"></div>
<div class="modal-actions">
<button class="btn btn-primary" onclick="closeHelpModal()" style="flex:1;">Got it</button>
</div>
</div>
</div>
<!-- ═══ TOAST ═══ -->
<div id="toast" class="toast"></div>
<script>
// ═════════════════════════════════════════════════════════════════════════════
// API WRAPPER
// ═════════════════════════════════════════════════════════════════════════════
async function api(url, opts = {}) {
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,
authToken: null,
currentPage: 'dashboard',
settingsCache: {},
};
function isLoggedIn() { return !!AppState.authToken; }
function isAdmin() { return AppState.currentUser && AppState.currentUser.role === 'admin'; }
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('sideAvatar').textContent = initial;
document.getElementById('sideName').textContent = name;
document.getElementById('sideRole').textContent = role;
const logoutBtn = document.getElementById('logoutBtn');
if (logoutBtn) logoutBtn.style.display = isLoggedIn() ? 'block' : 'none';
}
// ── Sidebar toggle (mobile) ──
function toggleSidebar() {
document.getElementById('sidebar').classList.toggle('open');
document.getElementById('sidebarOverlay').classList.toggle('open');
}
// ═════════════════════════════════════════════════════════════════════════════
// TOAST & MODAL
// ═════════════════════════════════════════════════════════════════════════════
function showToast(msg, isError = false) {
const t = document.getElementById('toast');
t.textContent = msg;
t.className = 'toast' + (isError ? ' error' : '');
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 4000);
}
let modalResolve = null;
function showModal(title, body, confirmText = 'Confirm', confirmClass = 'btn-danger') {
return new Promise((resolve) => {
modalResolve = resolve;
document.getElementById('modalTitle').textContent = title;
document.getElementById('modalBody').innerHTML = body;
document.getElementById('modalActions').innerHTML = `
<button class="btn btn-outline" onclick="closeModal(false)">Cancel</button>
<button class="btn ${confirmClass}" onclick="closeModal(true)">${confirmText}</button>
`;
document.getElementById('modalOverlay').classList.add('open');
});
}
function closeModal(confirmed = false) {
document.getElementById('modalOverlay').classList.remove('open');
if (modalResolve) { modalResolve(confirmed); modalResolve = null; }
}
// ── Help Modal ──
function openHelpModal() {
const body = document.querySelector('#helpModal .modal-body');
body.innerHTML = `
<div style="margin-bottom:14px;">
<div style="font-weight:700;font-size:14px;color:var(--text);margin-bottom:6px;">⌨️ Keyboard Shortcuts</div>
<div style="display:grid;grid-template-columns:auto 1fr;gap:4px 12px;font-size:13px;">
<span style="background:var(--card2);padding:1px 6px;border-radius:4px;font-family:monospace;font-size:11px;">Ctrl+K</span><span>Focus asset search</span>
<span style="background:var(--card2);padding:1px 6px;border-radius:4px;font-family:monospace;font-size:11px;">Ctrl+/</span><span>Focus asset search</span>
<span style="background:var(--card2);padding:1px 6px;border-radius:4px;font-family:monospace;font-size:11px;">Esc</span><span>Close modals / cancel inline edit</span>
<span style="background:var(--card2);padding:1px 6px;border-radius:4px;font-family:monospace;font-size:11px;">Enter</span><span>Save inline edit cell</span>
</div>
</div>
<div style="margin-bottom:14px;">
<div style="font-weight:700;font-size:14px;color:var(--text);margin-bottom:6px;">✏️ Inline Editing (Assets)</div>
<ul style="margin:0;padding-left:18px;list-style:disc;">
<li>Click any cell in the assets table to start editing</li>
<li>Press <strong>Enter</strong> to save the cell</li>
<li>Press <strong>Esc</strong> to cancel editing</li>
<li>Click column headers to sort (ascending / descending)</li>
<li>Empty cells have a <span style="background:var(--amber-bg);color:var(--amber);padding:0 6px;border-radius:4px;">■</span> amber left border</li>
<li>Use the <strong>Batch Lookup</strong> button to find business names from addresses</li>
</ul>
</div>
<div style="margin-bottom:14px;">
<div style="font-weight:700;font-size:14px;color:var(--text);margin-bottom:6px;">📥 Cantaloupe Sync (Import)</div>
<ul style="margin:0;padding-left:18px;list-style:disc;">
<li>Upload an Excel file exported from Cantaloupe</li>
<li>Review <strong>pending batches</strong> — approve or reject changes</li>
<li>Use <strong>Review Changes</strong> to see per-field diffs before approving</li>
<li>Blank-fill changes (populating empty fields) are auto-selected for convenience</li>
<li>Machine replacements need manual approval per asset</li>
</ul>
</div>
<div>
<div style="font-weight:700;font-size:14px;color:var(--text);margin-bottom:6px;">📸 EXIF Scanner</div>
<ul style="margin:0;padding-left:18px;list-style:disc;">
<li>Drop photos or click to upload — GPS data is read from EXIF</li>
<li>Choose OCR engine: Tesseract (fast, local), LLM, or Google Gemini</li>
<li><strong>Sticker mode</strong> scans a 4-digit sticker on the machine</li>
<li>Bulk process extracts Machine IDs and matches them to assets</li>
<li>Use <strong>Push GPS</strong> to write GPS coordinates to matched assets</li>
</ul>
</div>
`;
document.getElementById('helpModal').style.display = 'flex';
document.getElementById('helpModal').classList.add('open');
}
function closeHelpModal() {
document.getElementById('helpModal').classList.remove('open');
document.getElementById('helpModal').style.display = 'none';
}
// ═════════════════════════════════════════════════════════════════════════════
// PAGE ROUTING
// ═════════════════════════════════════════════════════════════════════════════
function switchPage(page) {
// Check for dirty inline edits before navigating
const editingCells = document.querySelectorAll('.ae-cell.editing');
if (editingCells.length > 0) {
if (!confirm('You have unsaved inline edits. Discard them and navigate away?')) {
return;
}
// Force-cancel all edits by re-rendering
if (cachedAssets.length > 0) renderAssetTable();
}
AppState.currentPage = page;
document.querySelectorAll('.page').forEach(p => p.classList.add('hidden'));
const target = document.getElementById('page' + page.charAt(0).toUpperCase() + page.slice(1));
if (target) target.classList.remove('hidden');
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
const navItem = document.querySelector(`.nav-item[data-page="${page}"]`);
if (navItem) navItem.classList.add('active');
// Close sidebar on mobile after nav
document.getElementById('sidebar')?.classList.remove('open');
document.getElementById('sidebarOverlay')?.classList.remove('open');
// Lazy load
if (page === 'dashboard') loadDashboard();
else if (page === 'assets') loadAssets();
else if (page === 'settings') renderAllSettings();
else if (page === 'users') loadUsers();
else if (page === 'customers') loadCustomers();
else if (page === 'activity') loadActivity();
else if (page === 'cantaloupe') loadCantaloupeSync();
else if (page === 'replacements') loadReplacements();
else if (page === 'exifscanner') loadExifScanner();
else if (page === 'route') initRoutePage();
else if (page === 'reports') loadReports();
else if (page === 'workorders') loadWorkOrders();
else if (page === 'msfssync') loadMsfsSync();
else if (page === 'cleanup') {
loadCleanupFilterOptions();
loadCleanup();
}
}
function closeDetail() {
if (AppState._prevPage) switchPage(AppState._prevPage);
else switchPage('dashboard');
}
// ═════════════════════════════════════════════════════════════════════════════
// AUTH
// ═════════════════════════════════════════════════════════════════════════════
async function doLogin() {
const username = document.getElementById('loginUser').value.trim();
const password = document.getElementById('loginPass').value;
const loginBtn = document.getElementById('loginBtn');
const rememberMe = document.getElementById('loginRemember').checked;
if (!username || !password) {
showLoginError('Please enter username and password');
return;
}
loginBtn.disabled = true;
loginBtn.textContent = 'Signing in...';
try {
const data = await api('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, remember_me: rememberMe }),
});
AppState.currentUser = { id: data.id, username: data.username, role: data.role };
AppState.authToken = data.token;
localStorage.setItem('adminToken', data.token);
const userInfo = JSON.stringify(AppState.currentUser);
if (rememberMe) {
localStorage.setItem('adminUser', userInfo);
sessionStorage.removeItem('adminUser');
} else {
sessionStorage.setItem('adminUser', userInfo);
localStorage.removeItem('adminUser');
}
document.getElementById('loginOverlay').classList.add('hidden');
updateUserUI();
switchPage('dashboard');
} catch (e) {
showLoginError(e.message);
} finally {
loginBtn.disabled = false;
loginBtn.textContent = 'Sign In';
}
}
function showLoginError(msg) {
const el = document.getElementById('loginError');
el.textContent = msg;
el.classList.add('show');
}
function doLogout() {
AppState.currentUser = null;
AppState.authToken = null;
localStorage.removeItem('adminToken');
document.getElementById('loginOverlay').classList.remove('hidden');
updateUserUI();
// Close sidebar on mobile
document.getElementById('sidebar')?.classList.remove('open');
document.getElementById('sidebarOverlay')?.classList.remove('open');
}
async function initAuth() {
// Try to restore session from stored token — admin server may not have /auth/me,
// so we just try a lightweight API call to verify the token is still valid.
const savedToken = localStorage.getItem('adminToken');
if (savedToken) {
AppState.authToken = savedToken;
try {
// Verify token by fetching a protected endpoint that returns user info
const users = await api('/api/users?limit=1');
// If we got here, token is valid — try localStorage first, then sessionStorage
const savedUser = localStorage.getItem('adminUser') || sessionStorage.getItem('adminUser');
if (savedUser) {
AppState.currentUser = JSON.parse(savedUser);
document.getElementById('loginOverlay').classList.add('hidden');
updateUserUI();
switchPage('dashboard');
return;
}
} catch (e) { /* token expired or admin server without /auth/me */ }
}
document.getElementById('loginOverlay').classList.remove('hidden');
document.getElementById('loginUser').focus();
}
// ═════════════════════════════════════════════════════════════════════════════
// DASHBOARD
// ═════════════════════════════════════════════════════════════════════════════
async function loadDashboard() {
// Show skeleton loading placeholders
document.getElementById('dashStats').innerHTML =
'<div class="skeleton-card skeleton-stat"><div class="skeleton-circle"></div><div class="skeleton-bar w60" style="margin:0 auto"></div><div class="skeleton-bar w40" style="margin:4px auto 0"></div></div>'.repeat(8);
document.getElementById('dashCategoryBars').innerHTML =
'<div class="skeleton-card"><div class="skeleton-bar w80"></div><div class="skeleton-bar w60"></div><div class="skeleton-bar w40"></div></div>';
document.getElementById('dashMakeBars').innerHTML =
'<div class="skeleton-card"><div class="skeleton-bar w80"></div><div class="skeleton-bar w60"></div><div class="skeleton-bar w40"></div></div>';
document.getElementById('dashStatusBars').innerHTML =
'<div class="skeleton-card"><div class="skeleton-bar w80"></div><div class="skeleton-bar w60"></div></div>';
document.getElementById('dashActivity').innerHTML =
'<div class="skeleton-card"><div class="skeleton-bar w80"></div><div class="skeleton-bar w60"></div><div class="skeleton-bar w40"></div></div>';
try {
const [stats, activity] = await Promise.all([
api('/api/stats'),
api('/api/activity?limit=10'),
]);
renderDashStats(stats || {});
renderDashCategoryBars(stats || {});
renderDashMakeBars(stats || {});
renderDashStatusBars(stats || {});
renderDashActivity(activity || []);
} catch (e) {
document.getElementById('dashStats').innerHTML =
`<div class="card" style="grid-column:1/-1;text-align:center;color:var(--red);">Failed to load dashboard — check your connection and try again. (${esc(e.message)})</div>`;
}
}
function renderDashStats(stats) {
const total = stats.total_assets || 0;
const active = stats.by_status?.active || 0;
const maint = stats.by_status?.maintenance || 0;
const retired = stats.by_status?.retired || 0;
const checkins = stats.total_checkins || 0;
const customers = stats.total_customers || 0;
const locations = stats.total_locations || 0;
const users = stats.total_users || 0;
document.getElementById('dashStats').innerHTML = `
<div class="stat-card"><div class="stat-value">${total}</div><div class="stat-label">Total Assets</div></div>
<div class="stat-card"><div class="stat-value">${active}</div><div class="stat-label">Active</div></div>
<div class="stat-card"><div class="stat-value" style="color:var(--amber)">${maint}</div><div class="stat-label">Maintenance</div></div>
<div class="stat-card"><div class="stat-value" style="color:var(--red)">${retired}</div><div class="stat-label">Retired</div></div>
<div class="stat-card"><div class="stat-value">${checkins}</div><div class="stat-label">Check-Ins</div></div>
<div class="stat-card"><div class="stat-value">${customers}</div><div class="stat-label">Customers</div></div>
<div class="stat-card"><div class="stat-value">${locations}</div><div class="stat-label">Locations</div></div>
<div class="stat-card"><div class="stat-value">${users}</div><div class="stat-label">Users</div></div>
`;
}
function renderBar(elId, items, labelKey, valueKey, max) {
const el = document.getElementById(elId);
if (!items || items.length === 0) {
el.innerHTML = '<div class="empty-state"><div class="es-icon">📭</div>No data available — try different filters or check back later</div>';
return;
}
const mx = max || Math.max(...items.map(i => i[valueKey]));
el.innerHTML = items.map(i => {
const pct = mx > 0 ? (i[valueKey] / mx * 100) : 0;
return `<div class="stat-bar">
<span class="sb-label">${esc(i[labelKey] || '')}</span>
<span class="sb-track"><span class="sb-fill" style="width:${pct}%"></span></span>
<span class="sb-count">${i[valueKey]}</span>
</div>`;
}).join('');
}
function renderDashCategoryBars(stats) {
const items = stats.by_category ? Object.entries(stats.by_category).map(([k, v]) => ({ name: k, count: v })) : [];
renderBar('dashCategoryBars', items, 'name', 'count');
}
function renderDashMakeBars(stats) {
const items = stats.by_make ? Object.entries(stats.by_make).map(([k, v]) => ({ name: k, count: v })) : [];
renderBar('dashMakeBars', items, 'name', 'count');
}
function renderDashStatusBars(stats) {
const items = stats.by_status ? Object.entries(stats.by_status).map(([k, v]) => ({ name: k, count: v })) : [];
renderBar('dashStatusBars', items, 'name', 'count');
}
function renderDashActivity(items) {
const el = document.getElementById('dashActivity');
if (!items || items.length === 0) {
el.innerHTML = '<div class="empty-state"><div class="es-icon">📭</div>No recent activity — check your filters</div>';
return;
}
el.innerHTML = items.map(a => {
const icon = getActIcon(a.action);
let details = a.details || a.action || '';
// Try to pretty-print JSON details
try {
const parsed = JSON.parse(details);
if (typeof parsed === 'object' && parsed !== null) {
const parts = [];
if (parsed.assets_updated !== undefined) parts.push(`${parsed.assets_updated} asset(s) updated`);
if (parsed.fields_applied !== undefined) parts.push(`${parsed.fields_applied} field(s) applied`);
if (parsed.assets_created !== undefined) parts.push(`${parsed.assets_created} created`);
if (parts.length > 0) details = parts.join(', ');
else details = JSON.stringify(parsed).replace(/[{}"]/g, '').replace(/,/g, ', ');
}
} catch(e) { /* not JSON, show as-is */ }
return `<div class="activity-item">
<div class="act-icon ${icon}">${getActEmoji(a.action)}</div>
<div class="act-info">
<div class="act-text">${esc(details)} <span class="act-user">${esc(a.user_name || '')}</span></div>
<div class="act-time">${timeAgo(a.created_at)}</div>
</div>
</div>`;
}).join('');
}
// ═════════════════════════════════════════════════════════════════════════════
// ASSETS — Sortable Table View with Inline Editing
// ═════════════════════════════════════════════════════════════════════════════
let assetSortField = 'name';
let assetSortDir = 'asc';
let cachedAssets = [];
let assetSettingsCache = {};
const ASSET_DROPDOWN_FIELDS = {
category: { api: '/api/settings/categories', labelKey: 'name', valueKey: 'name' },
status: { static: ['active', 'maintenance', 'retired'] },
make: { api: '/api/settings/makes', labelKey: 'name', valueKey: 'name' },
model: { api: '/api/settings/models', labelKey: 'name', valueKey: 'name' },
};
const ASSET_DATE_FIELDS = ['install_date', 'dex_report_date', 'pulled_date'];
const ASSET_CHECK_FIELDS = ['deployed'];
const ASSET_TEXT_FIELDS = ['serial_number', 'name', 'company', 'address', 'building_name', 'building_number', 'floor', 'room', 'place', 'location_area'];
async function loadAssetSettings() {
if (assetSettingsCache._loaded) return;
try {
const [catRes, makeRes] = await Promise.all([
api('/api/settings/categories'),
api('/api/settings/makes'),
]);
assetSettingsCache = {
categories: catRes || [],
makes: makeRes || [],
_loaded: true,
};
} catch (e) {
console.warn('Failed to load asset settings:', e);
assetSettingsCache = { categories: [], makes: [], _loaded: true };
}
}
async function loadAssets() {
const el = document.getElementById('assetsContent');
const search = document.getElementById('assetSearch')?.value?.trim() || '';
const status = document.getElementById('assetStatusFilter')?.value || '';
// Show loading spinner
el.innerHTML = '<div class="loading-row"><div class="spinner"></div> Loading assets...</div>';
await loadAssetSettings();
let url = '/api/assets?limit=5000';
if (search) url += '&q=' + encodeURIComponent(search);
if (status) url += '&status=' + encodeURIComponent(status);
try {
const resp = await api(url);
const data = typeof resp === 'object' && resp.assets ? resp : { assets: resp, total: resp?.length || 0 };
cachedAssets = data.assets || [];
document.getElementById('assetBadgeNav').textContent = data.total || cachedAssets.length;
renderAssetTable();
} catch (e) {
el.innerHTML = `<div class="card"><div class="empty-state"><div class="es-icon">⚠️</div>Failed to load assets — check your connection and try refreshing the page. (${esc(e.message)})</div></div>`;
}
}
function toggleAssetSort(field) {
if (assetSortField === field) assetSortDir = assetSortDir === 'asc' ? 'desc' : 'asc';
else { assetSortField = field; assetSortDir = 'asc'; }
renderAssetTable();
}
// ── Inline Editing ──
function startEdit(assetId, field, currentVal) {
const cell = document.querySelector(`[data-edit-id="${assetId}"][data-field="${field}"]`);
if (!cell) return;
if (cell.classList.contains('editing')) return;
cell.classList.add('editing');
const safeVal = currentVal == null ? '' : String(currentVal);
if (field === 'deployed') {
cell.innerHTML = `<select class="ae-select" data-field="${field}" data-id="${assetId}">
<option value="0" ${safeVal !== '1' ? 'selected' : ''}>✗ No</option>
<option value="1" ${safeVal === '1' ? 'selected' : ''}>✓ Yes</option>
</select>`;
const sel = cell.querySelector('select');
sel.focus();
sel.onchange = () => saveCell(assetId, field, sel.value);
sel.onblur = () => setTimeout(() => { if (!cell.contains(document.activeElement)) renderAssetTable(); }, 200);
return;
}
if (ASSET_DATE_FIELDS.includes(field)) {
const dateVal = safeVal.substring(0, 10);
cell.innerHTML = `<input type="date" class="ae-input" value="${dateVal}" data-field="${field}" data-id="${assetId}" style="width:130px;">`;
const inp = cell.querySelector('input');
inp.focus();
inp.onchange = () => saveCell(assetId, field, inp.value);
inp.onblur = () => setTimeout(() => { if (!cell.contains(document.activeElement)) renderAssetTable(); }, 200);
inp.onkeydown = (e) => { if (e.key === 'Escape') renderAssetTable(); };
return;
}
// Dropdown fields
if (ASSET_DROPDOWN_FIELDS[field]) {
const cfg = ASSET_DROPDOWN_FIELDS[field];
let options = [];
if (cfg.static) {
options = cfg.static;
} else if (cfg.api) {
const list = cfg.api.includes('categories') ? assetSettingsCache.categories : assetSettingsCache.makes;
options = list.map(item => item[cfg.valueKey]);
}
cell.innerHTML = `<select class="ae-select" data-field="${field}" data-id="${assetId}">
<option value="">—</option>
${options.map(o => `<option value="${esc(o)}" ${safeVal === o ? 'selected' : ''}>${esc(o)}</option>`).join('')}
</select>`;
const sel = cell.querySelector('select');
sel.focus();
sel.onchange = () => saveCell(assetId, field, sel.value);
sel.onblur = () => setTimeout(() => { if (!cell.contains(document.activeElement)) renderAssetTable(); }, 200);
sel.onkeydown = (e) => { if (e.key === 'Escape') renderAssetTable(); };
return;
}
// Text fields
cell.innerHTML = `<input type="text" class="ae-input" value="${esc(safeVal)}" data-field="${field}" data-id="${assetId}" style="width:100%;min-width:60px;">`;
const inp = cell.querySelector('input');
inp.focus();
inp.select();
inp.onchange = () => saveCell(assetId, field, inp.value);
inp.onblur = () => setTimeout(() => { if (!cell.contains(document.activeElement)) renderAssetTable(); }, 200);
inp.onkeydown = (e) => {
if (e.key === 'Enter') { inp.blur(); }
if (e.key === 'Escape') renderAssetTable();
};
}
async function saveCell(assetId, field, value) {
const cell = document.querySelector(`[data-edit-id="${assetId}"][data-field="${field}"]`);
if (!cell) return;
// Add saving visual indicator
cell.classList.add('saving');
// Optimistic update
const asset = cachedAssets.find(a => a.id === assetId);
if (asset) {
if (field === 'deployed') {
asset[field] = value === '1' ? 1 : 0;
} else {
asset[field] = value;
}
}
cell.classList.remove('editing');
renderAssetTable();
showToast(`Saving ${field}...`);
try {
const payload = {};
payload[field] = value;
const updated = await api(`/api/assets/${assetId}`, {
method: 'PUT',
body: JSON.stringify(payload),
headers: { 'Content-Type': 'application/json' },
});
if (asset && updated) Object.assign(asset, updated);
// Remove saving class on success
document.querySelectorAll(`[data-edit-id="${assetId}"][data-field="${field}"]`).forEach(el => el.classList.remove('saving'));
showToast(`${field} saved`);
} catch (e) {
// Remove saving class on error
document.querySelectorAll(`[data-edit-id="${assetId}"][data-field="${field}"]`).forEach(el => el.classList.remove('saving'));
showToast(`✗ Failed to save ${field}: ${e.message} — check your connection and try again`, true);
if (asset) renderAssetTable();
}
}
function renderAssetCell(asset, field) {
const val = asset[field];
const isEmpty = val === null || val === undefined || val === '';
const emptyCls = isEmpty ? 'ae-empty' : '';
if (field === 'deployed') {
return `<span class="${emptyCls}">${val ? '✓' : '✗'}</span>`;
}
if (field === 'status') {
const statusClass = val === 'active' ? 'status-active' : val === 'maintenance' ? 'status-maintenance' : 'status-retired';
return `<span class="status-dot ${statusClass}"></span><span class="${emptyCls}">${esc(val || '—')}</span>`;
}
if (field === 'address') {
return `<span class="${emptyCls}" title="${esc(val || '')}">${esc((val || '').substring(0, 22))}</span>`;
}
if (field === 'building_name') {
const parts = [asset.building_name, asset.building_number].filter(Boolean);
return `<span class="${emptyCls}">${esc(parts.join(' ') || '—')}</span>`;
}
if (field === 'room') {
return `<span class="${emptyCls}">${esc(asset.room || asset.place || '—')}</span>`;
}
const display = val == null || val === '' ? '—' : String(val).substring(0, 25);
return `<span class="${emptyCls}">${esc(display)}</span>`;
}
function renderAssetTable() {
const el = document.getElementById('assetsContent');
const sorted = [...cachedAssets].sort((a, b) => {
let va = (a[assetSortField] || '').toString().toLowerCase();
let vb = (b[assetSortField] || '').toString().toLowerCase();
if (assetSortField === 'machine_id' || assetSortField === 'id') {
va = Number(a[assetSortField]) || 0;
vb = Number(b[assetSortField]) || 0;
return assetSortDir === 'asc' ? va - vb : vb - va;
}
if (va < vb) return assetSortDir === 'asc' ? -1 : 1;
if (va > vb) return assetSortDir === 'asc' ? 1 : -1;
return 0;
});
if (sorted.length === 0) {
el.innerHTML = '<div class="card"><div class="empty-state"><div class="es-icon">📦</div>No assets found — add one via the <strong>+</strong> button, or clear your search filters.</div></div>';
document.getElementById('assetPagination').innerHTML = '';
return;
}
const sortArrow = (f) => assetSortField === f ? (assetSortDir === 'asc' ? ' ▲' : ' ▼') : '';
const INLINE_FIELDS = [
'machine_id', 'serial_number', 'name', 'company', 'category', 'status',
'make', 'model', 'address', 'building_name', 'room', 'floor', 'location_area',
'install_date', 'dex_report_date', 'deployed', 'pulled_date',
];
const HEADER_LABELS = {
machine_id: 'ID', serial_number: 'Serial', name: 'Name', company: 'Company',
category: 'Cat', status: 'Status', make: 'Make', model: 'Model',
address: 'Address', building_name: 'Bldg', room: 'Room/Place', floor: 'Floor',
location_area: 'Area', install_date: 'Install', dex_report_date: 'DEX Date',
deployed: 'Dep', pulled_date: 'Pulled',
};
const WO_STATUS_COLORS = {
'690970003': '#4ade80',
'690970004': '#7c8cf8',
'690970001': '#fbbf24',
'690970002': '#22d3ee',
'690970000': '#5b5f73',
'690970005': '#f87171',
'690959000': '#8b8fa3',
};
const rows = sorted.map(a => {
return `<tr>
${INLINE_FIELDS.map(f => {
let cls = 'text-xs ae-cell';
if (f === 'name') cls += ' ae-name';
if (f === 'machine_id') cls += ' ae-id';
const val = a[f];
const isEmpty = val === null || val === undefined || val === '';
if (isEmpty) cls += ' ae-empty-cell';
const q = JSON.stringify(val == null ? '' : String(val));
return `<td class="${cls}" data-edit-id="${a.id}" data-field="${f}" onclick="startEdit(${a.id},'${f}',${q})">${renderAssetCell(a, f)}</td>`;
}).join('')}
<td class="text-xs" style="text-align:center;padding:4px 4px;border-bottom:1px solid rgba(255,255,255,0.04);">
<span style="cursor:pointer;font-size:14px;" onclick="event.stopPropagation();lookupBusinessName(${a.id})" title="Find business name from address">🔍</span>
</td>
<td class="text-xs" style="text-align:center;padding:4px 6px;border-bottom:1px solid rgba(255,255,255,0.04);white-space:nowrap;">
<span style="cursor:pointer;font-size:16px;" onclick="event.stopPropagation();showAssetWorkOrders('${esc(String(a.machine_id))}','${esc(String(a.name || a.machine_id))}')" title="View work orders for this asset">🔧</span>
</td>
</tr>`;
}).join('');
el.innerHTML = `<div style="margin-bottom:6px;font-size:11px;color:var(--text3);display:flex;gap:12px;">
<span>💡 Click any cell to edit</span>
<span style="background:var(--amber-bg);color:var(--amber);padding:0 6px;border-radius:4px;">■</span> = empty field
</div>
<div class="table-wrap" style="max-height:calc(100vh - 200px);overflow-y:auto;">
<table class="data-table" style="font-size:11px;">
<thead style="position:sticky;top:0;background:var(--card);z-index:2;">
<tr>
${INLINE_FIELDS.map(f => `<th onclick="toggleAssetSort('${f}')" style="cursor:pointer;white-space:nowrap;">${HEADER_LABELS[f]}${sortArrow(f)}</th>`).join('')}
<th style="white-space:nowrap;width:36px;">🔍</th>
<th style="white-space:nowrap;width:36px;">🔧</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>`;
document.getElementById('assetPagination').innerHTML = `<span class="text-muted" style="font-size:12px;padding:8px 12px;display:block;">${sorted.length} asset${sorted.length !== 1 ? 's' : ''} · click cells to edit, Enter to save, Esc to cancel</span>`;
}
// ═════════════════════════════════════════════════════════════════════════════
// BUSINESS NAME LOOKUP (Google Maps)
// ═════════════════════════════════════════════════════════════════════════════
async function lookupBusinessName(assetId) {
const asset = cachedAssets.find(a => a.id === assetId);
if (!asset) return;
const addr = asset.address?.trim();
if (!addr) { showToast('No address to look up for this asset', true); return; }
showToast(`🔍 Looking up "${addr}"...`);
try {
const result = await api('/api/lookup-business', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address: addr }),
});
const name = result.business_name;
if (!name) {
showToast('No business name found at this address', true);
return;
}
showToast(`Found: "${name}" — Click "Apply" to save`, false, 8000);
// Show a subtle inline prompt to apply
const cell = document.querySelector(`[data-edit-id="${assetId}"][data-field="company"]`);
if (cell) {
cell.innerHTML += ` <span style="cursor:pointer;color:var(--green);font-weight:600;font-size:10px;" onclick="applyBusinessName(${assetId},'${esc(name)}')" title="Apply this name">[Apply: ${esc(name)}]</span>`;
}
} catch (e) {
showToast(`✗ Lookup failed: ${e.message}`, true);
}
}
async function applyBusinessName(assetId, name) {
const asset = cachedAssets.find(a => a.id === assetId);
if (!asset) return;
// Save to company field
try {
const updated = await api(`/api/assets/${assetId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ company: name }),
});
if (asset) asset.company = name;
showToast(`✓ Company set to "${name}"`);
renderAssetTable();
} catch (e) {
showToast(`✗ Failed to save: ${e.message}`, true);
}
}
async function batchLookupAll() {
const withAddr = cachedAssets.filter(a => a.address?.trim() && !a.company?.trim());
if (withAddr.length === 0) {
const anyWithAddr = cachedAssets.filter(a => a.address?.trim());
if (anyWithAddr.length === 0) { showToast('No assets with addresses found', true); return; }
showToast('All assets already have company names set', true);
return;
}
const batchSize = Math.min(withAddr.length, 50);
showToast(`🔍 Looking up ${batchSize} address${batchSize > 1 ? 'es' : ''} (showing first 50)...`, false, 10000);
const el = document.getElementById('assetsContent');
// Add results panel
let resultsPanel = document.getElementById('lookupResultsPanel');
if (!resultsPanel) {
resultsPanel = document.createElement('div');
resultsPanel.id = 'lookupResultsPanel';
resultsPanel.className = 'card';
resultsPanel.style.marginTop = '12px';
el.parentNode.insertBefore(resultsPanel, el.nextSibling);
}
resultsPanel.innerHTML = `<div class="card-title">🔍 Business Name Lookup Results</div>
<div style="padding:12px;text-align:center;color:var(--text2);">Looking up ${batchSize} addresses...</div>`;
// Batch call — only pass assets without company names, limited to 50
try {
const assets = withAddr.slice(0, 50).map(a => ({ id: a.id, address: a.address }));
const resp = await api('/api/lookup-business/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ assets }),
});
const results = resp.results || [];
const found = results.filter(r => r.business_name);
const notFound = results.filter(r => !r.business_name);
resultsPanel.innerHTML = `<div class="card-title">🔍 Business Name Lookup Results</div>
<div style="margin-bottom:8px;display:flex;gap:12px;flex-wrap:wrap;">
<span style="color:var(--green);font-weight:600;">✓ ${found.length} found</span>
<span style="color:var(--text2);">✗ ${notFound.length} not found</span>
<span style="color:var(--text2);">· ${results.length} total looked up</span>
</div>
${found.length > 0 ? `
<div style="max-height:400px;overflow-y:auto;border:1px solid var(--border);border-radius:var(--radius-sm);">
<table class="data-table" style="font-size:12px;">
<thead><tr>
<th>Business Name</th>
<th>Address</th>
<th>Current Company</th>
<th></th>
</tr></thead>
<tbody>
${found.map(r => {
const asset = cachedAssets.find(a => a.id === r.id);
const currentCompany = asset?.company || '';
const needsUpdate = currentCompany !== r.business_name;
return `<tr style="${needsUpdate ? 'background:var(--accent-bg);' : ''}">
<td><strong>${esc(r.business_name)}</strong></td>
<td style="color:var(--text2);font-size:11px;">${esc(r.address)}</td>
<td style="color:${currentCompany ? 'var(--text)' : 'var(--amber)'};">${esc(currentCompany || '—empty—')}</td>
<td>${needsUpdate ? `<button class="btn btn-xs btn-green" onclick="applyBusinessName(${r.id},'${esc(r.business_name)}')">Apply</button>` : '✅'}</td>
</tr>`;
}).join('')}
</tbody>
</table>
</div>
` : ''}
${notFound.length > 0 ? `
<details style="margin-top:8px;">
<summary style="cursor:pointer;color:var(--text2);font-size:12px;">
${notFound.length} address${notFound.length > 1 ? 'es' : ''} with no result
</summary>
<div style="font-size:11px;color:var(--text3);max-height:200px;overflow-y:auto;margin-top:4px;">
${notFound.map(r => {
const asset = cachedAssets.find(a => a.id === r.id);
return `<div style="padding:4px 0;border-bottom:1px solid rgba(255,255,255,0.03);">
<span style="color:var(--text);">${asset?.name || 'ID ' + r.id}:</span> ${esc(r.address)}
</div>`;
}).join('')}
</div>
</details>
` : ''}
<div style="margin-top:10px;display:flex;gap:8px;">
<button class="btn btn-sm btn-primary" onclick="document.getElementById('lookupResultsPanel').remove()">Dismiss</button>
<button class="btn btn-sm btn-outline" onclick="applyAllFoundNames()">Apply All (${found.filter(r => {
const a = cachedAssets.find(x => x.id === r.id);
return a && a.company !== r.business_name;
}).length})</button>
</div>`;
showToast(`✓ Lookup complete: ${found.length} found, ${notFound.length} not found`);
} catch (e) {
showToast(`✗ Batch lookup failed: ${e.message}`, true);
resultsPanel.innerHTML += `<div style="color:var(--red);padding:8px;">Error: ${esc(e.message)}</div>`;
}
}
async function applyAllFoundNames() {
const panel = document.getElementById('lookupResultsPanel');
if (!panel) return;
// Parse the results table
const rows = panel.querySelectorAll('table tbody tr');
let count = 0;
for (const row of rows) {
const applyBtn = row.querySelector('.btn-green');
if (applyBtn) applyBtn.click();
count++;
// Small delay to not overwhelm
if (count % 5 === 0) await new Promise(r => setTimeout(r, 100));
}
showToast(`Applied ${count} names`);
}
// ── Batch Status Update ──
async function showBatchStatusModal() {
const confirmed = await showModal(
'🏷️ Batch Status Update',
`<div style="font-size:13px;color:var(--text2);margin-bottom:12px;">
Select assets by ID (from the current search results) and choose a new status.
</div>
<div style="margin-bottom:12px;">
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px;color:var(--text);">Asset IDs (comma-separated)</label>
<input id="batchStatusIds" type="text" class="input-field" placeholder="e.g. 1, 5, 23, 44" style="width:100%;box-sizing:border-box;">
</div>
<div style="margin-bottom:12px;">
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px;color:var(--text);">New Status</label>
<select id="batchStatusValue" class="input-field" style="width:100%;box-sizing:border-box;">
<option value="active">✅ Active</option>
<option value="maintenance" selected>🔄 Maintenance</option>
<option value="retired">⛔ Retired</option>
</select>
</div>
<div id="batchStatusResult" style="font-size:13px;"></div>`,
'Apply',
'btn-accent'
);
if (!confirmed) return;
const idsInput = document.getElementById('batchStatusIds');
const statusSelect = document.getElementById('batchStatusValue');
const resultEl = document.getElementById('batchStatusResult');
const ids = (idsInput.value || '').split(',').map(s => parseInt(s.trim())).filter(n => !isNaN(n) && n > 0);
if (ids.length === 0) {
resultEl.innerHTML = '<span style="color:var(--red);">❌ No valid asset IDs entered</span>';
return;
}
const status = statusSelect.value;
resultEl.innerHTML = '<span style="color:var(--text2);">⏳ Updating...</span>';
try {
const resp = await api('/api/assets/batch-status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids, status }),
});
resultEl.innerHTML = `<span style="color:var(--green);">✅ ${resp.updated} asset(s) set to "${status}"</span>`;
loadAssets(); // Refresh the asset table
} catch (e) {
resultEl.innerHTML = `<span style="color:var(--red);">❌ ${esc(e.message)}</span>`;
}
}
// ═════════════════════════════════════════════════════════════════════════════
async function loadSettingsCache() {
try {
const [cat, makes, models, kn, kt, bt] = 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'),
]);
AppState.settingsCache = {
categories: cat || [],
makes: makes || [],
models: models || [],
key_names: kn || [],
key_types: kt || [],
badge_types: bt || [],
_loaded: true,
};
} catch (e) {
showToast('Failed to load settings: ' + e.message, true);
}
}
async function renderAllSettings() {
await loadSettingsCache();
renderSettingsList('settingsCategories', 'categories', cat => cat.icon ? cat.icon + ' ' : '📁 ');
renderMakesModels();
renderSettingsList('settingsKeyNames', 'key_names');
renderSettingsList('settingsKeyTypes', 'key_types');
renderSettingsList('settingsBadgeTypes', 'badge_types');
}
function renderSettingsList(elId, entity, iconFn) {
const el = document.getElementById(elId);
const items = AppState.settingsCache[entity] || [];
if (items.length === 0) {
el.innerHTML = '<div class="text-muted text-sm" style="padding:8px 0;">No items</div>';
return;
}
el.innerHTML = items.map(item => {
const icon = iconFn ? iconFn(item) : '•';
return `<div class="settings-item">
<span class="si-icon">${icon}</span>
<span class="si-name">${esc(item.name || '')}</span>
<span class="si-actions">
<button class="si-btn" onclick="showSettingsEditForm('${entity}', ${item.id})">✏️</button>
<button class="si-btn delete" onclick="deleteSettingsItem('${entity}', ${item.id})">🗑</button>
</span>
</div>`;
}).join('');
}
function renderMakesModels() {
const el = document.getElementById('settingsMakes');
const makes = AppState.settingsCache.makes || [];
const models = AppState.settingsCache.models || [];
if (makes.length === 0) {
el.innerHTML = '<div class="text-muted text-sm" style="padding:8px 0;">No makes</div>';
return;
}
el.innerHTML = makes.map(make => {
const mkModels = models.filter(m => m.make_id === make.id);
return `<div class="settings-item make-item" onclick="toggleMakeModels(this)">
<span class="si-chevron">▶</span>
<span class="si-name">${esc(make.name)}</span>
<span class="si-meta">${mkModels.length} models</span>
<span class="si-actions">
<button class="si-btn" onclick="event.stopPropagation();showSettingsEditForm('makes', ${make.id})">✏️</button>
<button class="si-btn delete" onclick="event.stopPropagation();deleteSettingsItem('makes', ${make.id})">🗑</button>
</span>
</div>
<div class="models-sublist">${mkModels.map(m => `
<div class="settings-item">
<span class="si-icon">📦</span>
<span class="si-name">${esc(m.name)}</span>
<span class="si-actions">
<button class="si-btn" onclick="showSettingsEditForm('models', ${m.id})">✏️</button>
<button class="si-btn delete" onclick="deleteSettingsItem('models', ${m.id})">🗑</button>
</span>
</div>`
).join('')}
<div class="add-model-row">
<input id="newModel_${make.id}" class="input-field" placeholder="New model name..." style="margin-bottom:0;font-size:12px;padding:6px 10px;">
<button class="btn btn-sm btn-primary" onclick="addModel(${make.id})">Add</button>
</div>
</div>`;
}).join('');
}
function toggleMakeModels(el) {
el.classList.toggle('expanded');
const sub = el.nextElementSibling;
if (sub && sub.classList.contains('models-sublist')) {
sub.classList.toggle('open');
}
}
async function addModel(makeId) {
const input = document.getElementById('newModel_' + makeId);
const name = input.value.trim();
if (!name) return;
try {
await api('/api/settings/models', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, make_id: makeId }),
});
input.value = '';
showToast('Model added');
renderAllSettings();
} catch (e) { showToast(e.message, true); }
}
function showSettingsAddForm(entity) {
const labels = { categories: 'Category', makes: 'Make', key_names: 'Key Name', key_types: 'Key Type', badge_types: 'Badge Type' };
const label = labels[entity] || entity;
const isCat = entity === 'categories';
const emojis = ['🪑','🔌','🍽️','⚙️','📦','☕','🥤','🍪','🧊','🛒','🖥️','🔧','📋','🏗️','🧰','🛠️','📎','🗂️','📁','🏪'];
document.getElementById('modalTitle').textContent = `Add ${label}`;
document.getElementById('modalBody').innerHTML = `
<div class="form-group">
<label class="form-label">Name</label>
<input id="modalFormName" class="input-field" placeholder="${label} name">
</div>
${isCat ? `
<div class="form-group">
<label class="form-label">Icon</label>
<div class="emoji-picker" id="emojiPicker">
${emojis.map(e => `<button class="emoji-opt" onclick="pickEmoji(this,'${e}')">${e}</button>`).join('')}
</div>
<input id="modalFormIcon" class="input-field" placeholder="Or type an emoji" value="📁">
</div>` : ''}
`;
document.getElementById('modalActions').innerHTML = `
<button class="btn btn-outline" onclick="closeModal(false)">Cancel</button>
<button class="btn btn-primary" onclick="saveSettingsAdd('${entity}')">Save</button>
`;
document.getElementById('modalOverlay').classList.add('open');
}
function pickEmoji(el, emoji) {
document.querySelectorAll('.emoji-opt').forEach(e => e.classList.remove('selected'));
el.classList.add('selected');
document.getElementById('modalFormIcon').value = emoji;
}
async function saveSettingsAdd(entity) {
const name = document.getElementById('modalFormName').value.trim();
if (!name) { showToast('Name is required', true); return; }
const body = { name };
if (entity === 'categories') {
body.icon = document.getElementById('modalFormIcon').value.trim() || '📁';
}
try {
await api(`/api/settings/${entity}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
closeModal(false);
showToast('Created');
await renderAllSettings();
} catch (e) { showToast(e.message, true); }
}
function showSettingsEditForm(entity, id) {
const items = AppState.settingsCache[entity] || [];
const item = items.find(i => i.id === id);
if (!item) return;
const labels = { categories: 'Category', makes: 'Make', models: 'Model', key_names: 'Key Name', key_types: 'Key Type', badge_types: 'Badge Type' };
const label = labels[entity] || entity;
const isCat = entity === 'categories';
const emojis = ['🪑','🔌','🍽️','⚙️','📦','☕','🥤','🍪','🧊','🛒','🖥️','🔧','📋','🏗️','🧰','🛠️','📎','🗂️','📁','🏪'];
document.getElementById('modalTitle').textContent = `Edit ${label}`;
document.getElementById('modalBody').innerHTML = `
<div class="form-group">
<label class="form-label">Name</label>
<input id="modalFormName" class="input-field" value="${esc(item.name || '')}">
</div>
${isCat ? `
<div class="form-group">
<label class="form-label">Icon</label>
<div class="emoji-picker" id="emojiPicker">
${emojis.map(e => `<button class="emoji-opt ${e === item.icon ? 'selected' : ''}" onclick="pickEmoji(this,'${e}')">${e}</button>`).join('')}
</div>
<input id="modalFormIcon" class="input-field" value="${esc(item.icon || '📁')}">
</div>` : ''}
${entity === 'models' ? `
<div class="form-group">
<label class="form-label">Make</label>
<select id="modalFormMakeId" class="input-field">
${(AppState.settingsCache.makes || []).map(m =>
`<option value="${m.id}" ${m.id === item.make_id ? 'selected' : ''}>${esc(m.name)}</option>`
).join('')}
</select>
</div>` : ''}
`;
document.getElementById('modalActions').innerHTML = `
<button class="btn btn-outline" onclick="closeModal(false)">Cancel</button>
<button class="btn btn-primary" onclick="saveSettingsEdit('${entity}', ${id})">Save</button>
`;
document.getElementById('modalOverlay').classList.add('open');
}
async function saveSettingsEdit(entity, id) {
const name = document.getElementById('modalFormName').value.trim();
if (!name) { showToast('Name is required', true); return; }
const body = { name };
if (entity === 'categories') {
body.icon = document.getElementById('modalFormIcon').value.trim() || '📁';
}
if (entity === 'models') {
body.make_id = parseInt(document.getElementById('modalFormMakeId').value);
}
try {
await api(`/api/settings/${entity}/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
closeModal(false);
showToast('Updated');
await renderAllSettings();
} catch (e) { showToast(e.message, true); }
}
async function deleteSettingsItem(entity, id) {
const confirmed = await showModal('Delete', 'Are you sure you want to delete this item?', 'Delete');
if (!confirmed) return;
try {
await api(`/api/settings/${entity}/${id}`, { method: 'DELETE' });
showToast('Deleted');
await renderAllSettings();
} catch (e) { showToast(e.message, true); }
}
// ═════════════════════════════════════════════════════════════════════════════
// USERS
// ═════════════════════════════════════════════════════════════════════════════
let cachedUsers = [];
async function loadUsers() {
try {
cachedUsers = await api('/api/users') || [];
renderUsers();
} catch (e) {
document.getElementById('usersContent').innerHTML =
`<div class="card" style="text-align:center;color:var(--red);">Failed to load users: ${esc(e.message)}</div>`;
}
}
function renderRoleBadges(roleStr) {
if (!roleStr) return '<span class="role-badge technician">technician</span>';
const roles = roleStr.split(',').map(r => r.trim()).filter(Boolean);
return roles.map(r => `<span class="role-badge ${r}">${r}</span>`).join(' ');
}
function renderUsers() {
const el = document.getElementById('usersContent');
if (cachedUsers.length === 0) {
el.innerHTML = '<div class="card"><div class="empty-state"><div class="es-icon">👥</div>No users found — add one via the <strong>+ Add User</strong> button above.</div></div>';
return;
}
el.innerHTML = `<div class="table-wrap"><table class="data-table">
<thead><tr>
<th>ID</th>
<th>Username</th>
<th>Role</th>
<th>Created</th>
<th class="td-actions">Actions</th>
</tr></thead>
<tbody>${cachedUsers.map(u => `<tr>
<td>${u.id}</td>
<td><strong>${esc(u.username)}</strong></td>
<td>${renderRoleBadges(u.role)}</td>
<td class="text-muted">${formatDate(u.created_at)}</td>
<td class="td-actions">
<button class="btn btn-xs btn-outline" onclick="showEditUserForm(${u.id})">✏️</button>
<button class="btn btn-xs btn-danger" onclick="deleteUser(${u.id})">🗑</button>
</td>
</tr>`).join('')}</tbody>
</table></div>`;
document.getElementById('userBadgeNav').textContent = cachedUsers.length;
}
function showAddUserForm() {
document.getElementById('modalTitle').textContent = 'Add User';
document.getElementById('modalBody').innerHTML = `
<div class="form-group">
<label class="form-label">Username</label>
<input id="modalFormUser" class="input-field" placeholder="Username">
</div>
<div class="form-group">
<label class="form-label">Password</label>
<input id="modalFormPass" class="input-field" type="password" placeholder="Password">
</div>
<div class="form-group">
<label class="form-label">Roles</label>
<div class="checkbox-group">
<label class="checkbox-label"><input type="checkbox" class="role-cb" value="technician" checked> Technician</label>
<label class="checkbox-label"><input type="checkbox" class="role-cb" value="admin"> Admin</label>
<label class="checkbox-label"><input type="checkbox" class="role-cb" value="readonly"> Read Only</label>
</div>
</div>
`;
document.getElementById('modalActions').innerHTML = `
<button class="btn btn-outline" onclick="closeModal(false)">Cancel</button>
<button class="btn btn-primary" onclick="saveAddUser()">Save</button>
`;
document.getElementById('modalOverlay').classList.add('open');
}
function getSelectedRoles() {
return Array.from(document.querySelectorAll('.role-cb:checked')).map(cb => cb.value).join(',');
}
async function saveAddUser() {
const username = document.getElementById('modalFormUser').value.trim();
const password = document.getElementById('modalFormPass').value;
const role = getSelectedRoles();
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 }),
});
closeModal(false);
showToast('User created');
loadUsers();
} catch (e) { showToast(e.message, true); }
}
function showEditUserForm(userId) {
const user = cachedUsers.find(u => u.id === userId);
if (!user) return;
document.getElementById('modalTitle').textContent = `Edit User: ${esc(user.username)}`;
document.getElementById('modalBody').innerHTML = `
<div class="form-group">
<label class="form-label">Roles</label>
<div class="checkbox-group">
<label class="checkbox-label"><input type="checkbox" class="role-cb" value="technician" ${(user.role||'').split(',').map(r=>r.trim()).includes('technician') ? 'checked' : ''}> Technician</label>
<label class="checkbox-label"><input type="checkbox" class="role-cb" value="admin" ${(user.role||'').split(',').map(r=>r.trim()).includes('admin') ? 'checked' : ''}> Admin</label>
<label class="checkbox-label"><input type="checkbox" class="role-cb" value="readonly" ${(user.role||'').split(',').map(r=>r.trim()).includes('readonly') ? 'checked' : ''}> Read Only</label>
</div>
</div>
<div class="form-group">
<label class="form-label">New Password <span class="text-muted">(leave blank to keep)</span></label>
<input id="modalFormPass" class="input-field" type="password" placeholder="New password">
</div>
`;
document.getElementById('modalActions').innerHTML = `
<button class="btn btn-outline" onclick="closeModal(false)">Cancel</button>
<button class="btn btn-primary" onclick="saveEditUser(${userId})">Save</button>
`;
document.getElementById('modalOverlay').classList.add('open');
}
async function saveEditUser(userId) {
const role = getSelectedRoles();
const password = document.getElementById('modalFormPass').value;
const body = { role };
if (password) body.password = password;
try {
await api(`/api/users/${userId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
closeModal(false);
showToast('User updated');
loadUsers();
} catch (e) { showToast(e.message, true); }
}
async function deleteUser(userId) {
const user = cachedUsers.find(u => u.id === userId);
const confirmed = await showModal('Delete User',
`Are you sure you want to delete <strong>${esc(user ? user.username : 'this user')}</strong>?`,
'Delete');
if (!confirmed) return;
try {
await api(`/api/users/${userId}`, { method: 'DELETE' });
showToast('User deleted');
loadUsers();
} catch (e) { showToast(e.message, true); }
}
// ═════════════════════════════════════════════════════════════════════════════
// CUSTOMERS & LOCATIONS
// ═════════════════════════════════════════════════════════════════════════════
let cachedCustomers = [];
let cachedLocations = [];
let selectedCustomerId = null;
async function loadCustomers() {
try {
const q = document.getElementById('custSearch').value.trim();
const [customers, locations] = await Promise.all([
api('/api/customers'),
api('/api/locations'),
]);
cachedCustomers = customers || [];
cachedLocations = locations || [];
renderCustomers();
} catch (e) {
document.getElementById('customersContent').innerHTML =
`<div class="card" style="text-align:center;color:var(--red);">Failed to load: ${esc(e.message)}</div>`;
}
}
function renderCustomers() {
const el = document.getElementById('customersContent');
const q = document.getElementById('custSearch').value.trim().toLowerCase();
let filtered = cachedCustomers;
if (q) filtered = filtered.filter(c => c.name.toLowerCase().includes(q));
document.getElementById('custBadgeNav').textContent = cachedCustomers.length;
if (filtered.length === 0) {
el.innerHTML = '<div class="card"><div class="empty-state"><div class="es-icon">🏢</div>No customers found — add one via the <strong>+ Add Customer</strong> button, or clear your search.</div></div>';
return;
}
el.innerHTML = filtered.map(c => {
const locs = cachedLocations.filter(l => l.customer_id === c.id);
return `<div class="card" style="cursor:pointer;" onclick="showCustomerDetail(${c.id})">
<div style="display:flex;align-items:center;justify-content:space-between;">
<div>
<strong>${esc(c.name)}</strong>
<span class="text-muted text-sm"> — ${locs.length} location${locs.length !== 1 ? 's' : ''}</span>
</div>
<div class="flex-row gap-sm">
<button class="btn btn-xs btn-outline" onclick="event.stopPropagation();showEditCustomerForm(${c.id})">✏️</button>
<button class="btn btn-xs btn-danger" onclick="event.stopPropagation();deleteCustomer(${c.id})">🗑</button>
</div>
</div>
</div>`;
}).join('');
}
function showAddCustomerForm() {
document.getElementById('modalTitle').textContent = 'Add Customer';
document.getElementById('modalBody').innerHTML = `
<div class="form-group">
<label class="form-label">Customer Name</label>
<input id="modalCustName" class="input-field" placeholder="Customer name">
</div>
<div class="card-title" style="margin-top:12px;">Contact Info</div>
<div id="contactsContainer"></div>
<button class="btn btn-sm btn-outline btn-block" onclick="addContactRow()">+ Add Contact</button>
`;
document.getElementById('modalActions').innerHTML = `
<button class="btn btn-outline" onclick="closeModal(false)">Cancel</button>
<button class="btn btn-primary" onclick="saveAddCustomer()">Save</button>
`;
document.getElementById('modalOverlay').classList.add('open');
}
let contactCount = 0;
function addContactRow(name, phone, email) {
contactCount++;
const el = document.getElementById('contactsContainer');
el.insertAdjacentHTML('beforeend', `
<div class="contact-row" id="cr_${contactCount}">
<button class="cr-remove" onclick="document.getElementById('cr_${contactCount}').remove()">✕</button>
<input class="input-field contact-name" placeholder="Contact name" value="${esc(name || '')}" style="margin-bottom:4px;">
<div class="form-row">
<input class="input-field contact-phone" placeholder="Phone" value="${esc(phone || '')}">
<input class="input-field contact-email" placeholder="Email" value="${esc(email || '')}">
</div>
</div>
`);
}
async function saveAddCustomer() {
const name = document.getElementById('modalCustName').value.trim();
if (!name) { showToast('Customer name is required', true); return; }
const contacts = [];
document.querySelectorAll('.contact-row').forEach(row => {
const cname = row.querySelector('.contact-name').value.trim();
const phone = row.querySelector('.contact-phone').value.trim();
const email = row.querySelector('.contact-email').value.trim();
if (cname || phone || email) contacts.push({ name: cname, phone, email });
});
try {
await api('/api/customers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, contacts }),
});
closeModal(false);
showToast('Customer created');
loadCustomers();
} catch (e) { showToast(e.message, true); }
}
function showEditCustomerForm(custId) {
const cust = cachedCustomers.find(c => c.id === custId);
if (!cust) return;
document.getElementById('modalTitle').textContent = `Edit Customer: ${esc(cust.name)}`;
document.getElementById('modalBody').innerHTML = `
<div class="form-group">
<label class="form-label">Customer Name</label>
<input id="modalCustName" class="input-field" value="${esc(cust.name || '')}">
</div>
<div class="card-title" style="margin-top:12px;">Contacts</div>
<div id="contactsContainer"></div>
<button class="btn btn-sm btn-outline btn-block" onclick="addContactRow()">+ Add Contact</button>
`;
// Add existing contacts
contactCount = 0;
if (cust.contacts) cust.contacts.forEach(c => addContactRow(c.name, c.phone, c.email));
document.getElementById('modalActions').innerHTML = `
<button class="btn btn-outline" onclick="closeModal(false)">Cancel</button>
<button class="btn btn-primary" onclick="saveEditCustomer(${custId})">Save</button>
`;
document.getElementById('modalOverlay').classList.add('open');
}
async function saveEditCustomer(custId) {
const name = document.getElementById('modalCustName').value.trim();
if (!name) { showToast('Customer name is required', true); return; }
const contacts = [];
document.querySelectorAll('.contact-row').forEach(row => {
const cname = row.querySelector('.contact-name').value.trim();
const phone = row.querySelector('.contact-phone').value.trim();
const email = row.querySelector('.contact-email').value.trim();
if (cname || phone || email) contacts.push({ name: cname, phone, email });
});
try {
await api(`/api/customers/${custId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, contacts }),
});
closeModal(false);
showToast('Customer updated');
loadCustomers();
} catch (e) { showToast(e.message, true); }
}
async function deleteCustomer(custId) {
const cust = cachedCustomers.find(c => c.id === custId);
const confirmed = await showModal('Delete Customer',
`Are you sure? This will also remove all locations under <strong>${esc(cust ? cust.name : 'this customer')}</strong>.`,
'Delete');
if (!confirmed) return;
try {
await api(`/api/customers/${custId}`, { method: 'DELETE' });
showToast('Customer deleted');
if (selectedCustomerId === custId) closeCustomerDetail();
loadCustomers();
} catch (e) { showToast(e.message, true); }
}
function showCustomerDetail(custId) {
selectedCustomerId = custId;
const cust = cachedCustomers.find(c => c.id === custId);
if (!cust) return;
document.getElementById('customersContent').classList.add('hidden');
document.getElementById('customerDetail').classList.remove('hidden');
const locs = cachedLocations.filter(l => l.customer_id === custId);
document.getElementById('customerDetailContent').innerHTML = `
<div class="card">
<div class="card-header">
<span class="card-title">${esc(cust.name)}</span>
<div class="flex-row gap-sm">
<button class="btn btn-sm btn-outline" onclick="showEditCustomerForm(${custId})">✏️ Edit</button>
<button class="btn btn-sm btn-outline" onclick="showAddLocationForm(${custId})">+ Add Location</button>
</div>
</div>
${cust.contacts && cust.contacts.length > 0 ? `
<div style="margin-bottom:10px;">
<div class="card-title" style="margin-bottom:6px;">Contacts</div>
${cust.contacts.map(c => `
<div style="display:flex;gap:8px;padding:4px 0;font-size:13px;">
<span>${esc(c.name || '')}</span>
${c.phone ? `<span class="text-muted">📞 ${esc(c.phone)}</span>` : ''}
${c.email ? `<span class="text-muted">✉️ ${esc(c.email)}</span>` : ''}
</div>
`).join('')}
</div>` : ''}
<div class="card-title" style="margin-bottom:6px;">
Locations (${locs.length})
</div>
${locs.length === 0 ? '<div class="text-muted text-sm">No locations</div>' :
`<div class="table-wrap"><table class="data-table" style="font-size:12px;">
<thead><tr>
<th>Name</th>
<th>Address</th>
<th>Building</th>
<th>Floor</th>
<th class="td-actions">Actions</th>
</tr></thead>
<tbody>${locs.map(l => `<tr>
<td><strong>${esc(l.name || '')}</strong></td>
<td class="text-muted">${esc(l.address || '')}</td>
<td class="text-muted">${esc(l.building_name || '')}</td>
<td class="text-muted">${esc(l.floor || '')}</td>
<td class="td-actions">
<button class="btn btn-xs btn-outline" onclick="showEditLocationForm(${l.id})">✏️</button>
<button class="btn btn-xs btn-danger" onclick="deleteLocation(${l.id})">🗑</button>
</td>
</tr>`).join('')}
</tbody></table></div>`
}
</div>
`;
}
function closeCustomerDetail() {
selectedCustomerId = null;
document.getElementById('customersContent').classList.remove('hidden');
document.getElementById('customerDetail').classList.add('hidden');
}
function showAddLocationForm(custId) {
document.getElementById('modalTitle').textContent = 'Add Location';
document.getElementById('modalBody').innerHTML = `
<div class="form-group">
<label class="form-label">Location Name</label>
<input id="modalLocName" class="input-field" placeholder="Location name">
</div>
<div class="form-group">
<label class="form-label">Address</label>
<input id="modalLocAddr" class="input-field" placeholder="Address">
</div>
<div class="form-row">
<div class="form-group" style="flex:1;">
<label class="form-label">Building Name</label>
<input id="modalLocBuilding" class="input-field" placeholder="Building">
</div>
<div class="form-group" style="flex:1;">
<label class="form-label">Floor</label>
<input id="modalLocFloor" class="input-field" placeholder="Floor">
</div>
</div>
<div class="form-row">
<div class="form-group" style="flex:1;">
<label class="form-label">Site Hours</label>
<input id="modalLocHours" class="input-field" placeholder="e.g. 8am-5pm">
</div>
<div class="form-group" style="flex:1;">
<label class="form-label">Trailer #</label>
<input id="modalLocTrailer" class="input-field" placeholder="Trailer number">
</div>
</div>
<div class="form-group">
<label class="form-label">Access Notes</label>
<textarea id="modalLocAccess" class="input-field" placeholder="Access notes"></textarea>
</div>
<div class="form-group">
<label class="form-label">Walking Directions</label>
<textarea id="modalLocDirections" class="input-field" placeholder="Walking directions"></textarea>
</div>
<div class="form-row">
<div class="form-group" style="flex:1;">
<label class="form-label">Latitude</label>
<input id="modalLocLat" class="input-field" type="number" step="any" placeholder="Lat">
</div>
<div class="form-group" style="flex:1;">
<label class="form-label">Longitude</label>
<input id="modalLocLng" class="input-field" type="number" step="any" placeholder="Lng">
</div>
</div>
`;
document.getElementById('modalActions').innerHTML = `
<button class="btn btn-outline" onclick="closeModal(false)">Cancel</button>
<button class="btn btn-primary" onclick="saveAddLocation(${custId})">Save</button>
`;
document.getElementById('modalOverlay').classList.add('open');
}
async function saveAddLocation(custId) {
const name = document.getElementById('modalLocName').value.trim();
if (!name) { showToast('Location name is required', true); return; }
const body = {
customer_id: custId,
name,
address: document.getElementById('modalLocAddr').value.trim(),
building_name: document.getElementById('modalLocBuilding').value.trim(),
floor: document.getElementById('modalLocFloor').value.trim(),
site_hours: document.getElementById('modalLocHours').value.trim(),
trailer_number: document.getElementById('modalLocTrailer').value.trim(),
access_notes: document.getElementById('modalLocAccess').value.trim(),
walking_directions: document.getElementById('modalLocDirections').value.trim(),
latitude: parseFloat(document.getElementById('modalLocLat').value) || null,
longitude: parseFloat(document.getElementById('modalLocLng').value) || null,
};
try {
await api('/api/locations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
closeModal(false);
showToast('Location created');
loadCustomers();
if (selectedCustomerId) showCustomerDetail(selectedCustomerId);
} catch (e) { showToast(e.message, true); }
}
function showEditLocationForm(locId) {
const loc = cachedLocations.find(l => l.id === locId);
if (!loc) return;
document.getElementById('modalTitle').textContent = 'Edit Location';
document.getElementById('modalBody').innerHTML = `
<div class="form-group">
<label class="form-label">Location Name</label>
<input id="modalLocName" class="input-field" value="${esc(loc.name || '')}">
</div>
<div class="form-group">
<label class="form-label">Address</label>
<input id="modalLocAddr" class="input-field" value="${esc(loc.address || '')}">
</div>
<div class="form-row">
<div class="form-group" style="flex:1;">
<label class="form-label">Building Name</label>
<input id="modalLocBuilding" class="input-field" value="${esc(loc.building_name || '')}">
</div>
<div class="form-group" style="flex:1;">
<label class="form-label">Floor</label>
<input id="modalLocFloor" class="input-field" value="${esc(loc.floor || '')}">
</div>
</div>
<div class="form-row">
<div class="form-group" style="flex:1;">
<label class="form-label">Site Hours</label>
<input id="modalLocHours" class="input-field" value="${esc(loc.site_hours || '')}">
</div>
<div class="form-group" style="flex:1;">
<label class="form-label">Trailer #</label>
<input id="modalLocTrailer" class="input-field" value="${esc(loc.trailer_number || '')}">
</div>
</div>
<div class="form-group">
<label class="form-label">Access Notes</label>
<textarea id="modalLocAccess" class="input-field">${esc(loc.access_notes || '')}</textarea>
</div>
<div class="form-group">
<label class="form-label">Walking Directions</label>
<textarea id="modalLocDirections" class="input-field">${esc(loc.walking_directions || '')}</textarea>
</div>
<div class="form-row">
<div class="form-group" style="flex:1;">
<label class="form-label">Latitude</label>
<input id="modalLocLat" class="input-field" type="number" step="any" value="${loc.latitude || ''}">
</div>
<div class="form-group" style="flex:1;">
<label class="form-label">Longitude</label>
<input id="modalLocLng" class="input-field" type="number" step="any" value="${loc.longitude || ''}">
</div>
</div>
`;
document.getElementById('modalActions').innerHTML = `
<button class="btn btn-outline" onclick="closeModal(false)">Cancel</button>
<button class="btn btn-primary" onclick="saveEditLocation(${locId})">Save</button>
`;
document.getElementById('modalOverlay').classList.add('open');
}
async function saveEditLocation(locId) {
const name = document.getElementById('modalLocName').value.trim();
if (!name) { showToast('Location name is required', true); return; }
const body = {
name,
address: document.getElementById('modalLocAddr').value.trim(),
building_name: document.getElementById('modalLocBuilding').value.trim(),
floor: document.getElementById('modalLocFloor').value.trim(),
site_hours: document.getElementById('modalLocHours').value.trim(),
trailer_number: document.getElementById('modalLocTrailer').value.trim(),
access_notes: document.getElementById('modalLocAccess').value.trim(),
walking_directions: document.getElementById('modalLocDirections').value.trim(),
latitude: parseFloat(document.getElementById('modalLocLat').value) || null,
longitude: parseFloat(document.getElementById('modalLocLng').value) || null,
};
try {
await api(`/api/locations/${locId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
closeModal(false);
showToast('Location updated');
loadCustomers();
if (selectedCustomerId) showCustomerDetail(selectedCustomerId);
} catch (e) { showToast(e.message, true); }
}
async function deleteLocation(locId) {
const confirmed = await showModal('Delete Location', 'Are you sure you want to delete this location?', 'Delete');
if (!confirmed) return;
try {
await api(`/api/locations/${locId}`, { method: 'DELETE' });
showToast('Location deleted');
loadCustomers();
if (selectedCustomerId) showCustomerDetail(selectedCustomerId);
} catch (e) { showToast(e.message, true); }
}
// ═════════════════════════════════════════════════════════════════════════════
// ACTIVITY LOG
// ═════════════════════════════════════════════════════════════════════════════
let activityOffset = 0;
const ACTIVITY_LIMIT = 50;
async function loadActivity() {
activityOffset = 0;
await _loadActivity();
}
async function loadActivityPage(dir) {
activityOffset = Math.max(0, activityOffset + dir * ACTIVITY_LIMIT);
await _loadActivity();
}
async function _loadActivity() {
const userId = document.getElementById('actUserFilter').value;
const typeFilter = document.getElementById('actTypeFilter').value;
const dateFrom = document.getElementById('actDateFrom').value;
const dateTo = document.getElementById('actDateTo').value;
try {
let url = `/api/activity?limit=${ACTIVITY_LIMIT}&offset=${activityOffset}`;
if (userId) url += `&user_id=${userId}`;
if (typeFilter) url += `&entity_type=${typeFilter}`;
if (dateFrom) url += `&date_from=${dateFrom}`;
if (dateTo) url += `&date_to=${dateTo}`;
const items = await api(url) || [];
renderActivity(items);
// Also load user filter options
await loadActivityUserFilter();
} catch (e) {
document.getElementById('activityList').innerHTML =
`<div style="color:var(--red);padding:12px;text-align:center;">Failed: ${esc(e.message)}</div>`;
}
}
function renderActivity(items) {
const el = document.getElementById('activityList');
if (items.length === 0) {
el.innerHTML = '<div class="empty-state"><div class="es-icon">📋</div>No activity logged yet. Activity appears here when admins make changes.</div>';
document.getElementById('activityPagination').innerHTML = '';
return;
}
el.innerHTML = items.map(a => {
const icon = getActIcon(a.action);
return `<div class="activity-item">
<div class="act-icon ${icon}">${getActEmoji(a.action)}</div>
<div class="act-info">
<div class="act-text">${esc(a.details || a.action || '')}</div>
<div class="act-time">${esc(a.user_name || '')} · ${timeAgo(a.created_at)}</div>
</div>
</div>`;
}).join('');
renderActivityPagination(items.length);
}
function renderActivityPagination(count) {
const el = document.getElementById('activityPagination');
el.innerHTML = `
<button class="btn btn-sm btn-outline" onclick="loadActivityPage(-1)" ${activityOffset <= 0 ? 'disabled style="opacity:0.4"' : ''}>← Previous</button>
<span class="text-sm text-muted" style="align-self:center;">Offset ${activityOffset}</span>
<button class="btn btn-sm btn-outline" onclick="loadActivityPage(1)" ${count < ACTIVITY_LIMIT ? 'disabled style="opacity:0.4"' : ''}>Next →</button>
`;
}
async function loadActivityUserFilter() {
const sel = document.getElementById('actUserFilter');
const currentVal = sel.value;
try {
const users = await api('/api/users') || [];
sel.innerHTML = '<option value="">All users</option>' +
users.map(u => `<option value="${u.id}" ${u.id == currentVal ? 'selected' : ''}>${esc(u.username)}</option>`).join('');
} catch (e) { /* silent */ }
}
// ═════════════════════════════════════════════════════════════════════════════
// EXPORT & ADMIN
// ═════════════════════════════════════════════════════════════════════════════
async function confirmResetDb() {
const confirmed = await showModal('⚠️ Reset Database',
'This will delete ALL data (assets, check-ins, customers, locations, users, activity log) and reinitialize with defaults.<br><br><strong style="color:var(--red)">This cannot be undone.</strong>',
'Reset Everything', 'btn-danger');
if (!confirmed) return;
try {
const result = await api('/api/reset-database', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Confirm-Reset': 'yes' },
});
showToast(result.message || 'Database reset');
document.getElementById('resetResult').textContent = 'Reset at ' + new Date().toLocaleString();
} catch (e) {
document.getElementById('resetResult').innerHTML =
`<span class="text-danger">Reset failed: ${esc(e.message)}</span>`;
}
}
// ── Clear Asset GPS ──────────────────────────────────────────────────────
async function clearAssetGps() {
const input = document.getElementById('clearGpsAssetId').value.trim();
if (!input) {
showToast('Enter an asset ID or machine ID', true);
return;
}
const resultEl = document.getElementById('clearGpsResult');
resultEl.style.display = 'none';
try {
// Try numeric asset ID first, otherwise search by machine_id
let asset;
const idNum = parseInt(input);
if (!isNaN(idNum) && idNum > 0) {
try {
asset = await api('/api/assets/' + idNum);
} catch (e) {
// Fall through to machine_id search
}
}
if (!asset) {
asset = await api('/api/assets/search?machine_id=' + encodeURIComponent(input));
}
if (!asset || !asset.id) {
resultEl.style.display = '';
resultEl.innerHTML = '<span style="color:var(--red);">Asset not found</span>';
return;
}
const confirmed = await showModal(
'Clear GPS Data',
`<strong>${esc(asset.name)}</strong> (#${asset.id})<br>
Machine ID: ${esc(asset.machine_id)}<br>
Current: lat=${asset.latitude || 'none'}, lng=${asset.longitude || 'none'}<br><br>
Remove GPS coordinates from this asset?`,
'Clear GPS', 'btn-danger'
);
if (!confirmed) return;
await api('/api/assets/' + asset.id + '/gps', { method: 'DELETE' });
resultEl.style.display = '';
resultEl.innerHTML = '<span style="color:var(--green);">✅ GPS data cleared for ' + esc(asset.name) + ' (#' + asset.id + ')</span>';
document.getElementById('clearGpsAssetId').value = '';
showToast('GPS data cleared');
} catch (e) {
resultEl.style.display = '';
const errMsg = (e && typeof e.message === 'string') ? e.message
: (typeof e === 'string') ? e
: (e && e.detail && typeof e.detail === 'string') ? e.detail
: 'Unknown error';
resultEl.innerHTML = '<span style="color:var(--red);">' + esc(errMsg) + '</span>';
}
}
// ═════════════════════════════════════════════════════════════════════════════
// CANTALOUPE SYNC
// ═════════════════════════════════════════════════════════════════════════════
let csBatches = [];
let csLatestBatch = null;
async function loadCantaloupeSync() {
try {
const [batches] = await Promise.all([
api('/api/admin/cantaloupe/batches?limit=20'),
]);
csBatches = batches || [];
// Find the latest batch
csLatestBatch = csBatches.length > 0 ? csBatches[0] : null;
renderCsOverview();
renderCsPendingReview();
renderCsHistory();
} catch (e) {
document.getElementById('csOverviewContent').innerHTML =
`<div style="color:var(--red);text-align:center;padding:12px;">Failed to load: ${esc(e.message)}</div>`;
document.getElementById('csHistoryContent').innerHTML = '';
}
}
// ── Section 1: Overview ──
function renderCsOverview() {
const el = document.getElementById('csOverviewContent');
if (!csLatestBatch) {
el.innerHTML = '<div class="empty-state"><div class="es-icon">📥</div>No imports yet. Upload an Excel file to import asset data.</div>';
return;
}
const b = csLatestBatch;
const ds = b.diff_summary || {};
const statusTag = csStatusBadge(b.status);
const lastSync = formatDate(b.created_at);
const rowCount = b.row_count || 0;
const dsInfo = (ds.new_assets || ds.changed_assets || ds.removed_assets)
? `<div class="cs-diff-summary">
${ds.new_assets && ds.new_assets.length > 0 ? `<div class="cs-diff-badge add"><div class="csd-val">${ds.new_assets.length}</div><div class="csd-label">New</div></div>` : ''}
${ds.changed_assets && ds.changed_assets.length > 0 ? `<div class="cs-diff-badge chg"><div class="csd-val">${ds.changed_assets.length}</div><div class="csd-label">Changed</div></div>` : ''}
${ds.removed_assets && ds.removed_assets.length > 0 ? `<div class="cs-diff-badge rem"><div class="csd-val">${ds.removed_assets.length}</div><div class="csd-label">Removed</div></div>` : ''}
${ds.unchanged_assets && ds.unchanged_assets.length > 0 ? `<div class="cs-diff-badge unc"><div class="csd-val">${ds.unchanged_assets.length}</div><div class="csd-label">Unchanged</div></div>` : ''}
</div>` : '';
el.innerHTML = `
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
<span style="font-size:14px;font-weight:600;">Batch #${b.id}</span>
${statusTag}
<span class="text-muted">${lastSync}</span>
<span class="text-muted">${rowCount} rows</span>
${b.error_message ? `<span class="text-danger text-sm">⚠ ${esc(b.error_message)}</span>` : ''}
</div>
${dsInfo}
`;
}
function csStatusBadge(status) {
const map = {
pending: '<span class="status-tag" style="background:var(--amber-bg);color:var(--amber);">PENDING</span>',
approved: '<span class="status-tag active">APPROVED</span>',
rejected: '<span class="status-tag" style="background:var(--red-bg);color:var(--red);">REJECTED</span>',
error: '<span class="status-tag" style="background:var(--red-bg);color:var(--red);">ERROR</span>',
};
return map[status] || `<span class="status-tag">${esc(status)}</span>`;
}
// ── Section 2: Pending Review ──
function renderCsPendingReview() {
const card = document.getElementById('csPendingReview');
const el = document.getElementById('csPendingContent');
// Find a pending batch
const pending = csBatches.find(b => b.status === 'pending');
if (!pending) {
card.classList.add('hidden');
return;
}
card.classList.remove('hidden');
const ds = pending.diff_summary || {};
const newCount = (ds.new_assets || []).length;
const chgCount = (ds.changed_assets || []).length;
const remCount = (ds.removed_assets || []).length;
el.innerHTML = `
<div style="margin-bottom:10px;">
<strong>Batch #${pending.id}</strong>
<span class="text-muted"> · ${formatDate(pending.created_at)} · ${pending.row_count} rows</span>
</div>
<div class="cs-diff-summary">
<div class="cs-diff-badge add"><div class="csd-val">${newCount}</div><div class="csd-label">New</div></div>
<div class="cs-diff-badge chg"><div class="csd-val">${chgCount}</div><div class="csd-label">Changed</div></div>
<div class="cs-diff-badge rem"><div class="csd-val">${remCount}</div><div class="csd-label">Removed</div></div>
</div>
<div id="csFieldReviewArea" style="margin-top:12px;display:none;"></div>
<div class="cs-actions" style="gap:8px;flex-wrap:wrap;">
<button class="btn btn-accent btn-sm" onclick="toggleFieldReview(${pending.id})">📋 Review Changes</button>
<button class="btn btn-green" onclick="approveBatch(${pending.id})">✓ Approve All</button>
<button class="btn btn-danger" onclick="rejectBatch(${pending.id})">✕ Reject</button>
</div>
`;
// Pre-fetch field changes data for the review button (don't render old detail tables)
}
async function loadPendingBatchDetail(batchId) {
const el = document.getElementById('csPendingDetail');
try {
const batch = await api(`/api/admin/cantaloupe/batches/${batchId}`);
renderBatchDiffDetail(el, batch);
} catch (e) {
el.innerHTML = `<div class="text-danger text-sm">Failed to load detail: ${esc(e.message)}</div>`;
}
}
function renderBatchDiffDetail(el, batch) {
const dr = batch.diff_rows || {};
const newItems = dr.new || [];
const removedItems = dr.removed || [];
const changedItems = dr.changed || [];
let html = '';
// New assets table
if (newItems.length > 0) {
html += `<div class="card-title" style="margin-top:8px;color:var(--green);">+ New Assets (${newItems.length})</div>`;
html += `<table class="cs-changes-table"><thead><tr><th>Machine ID</th><th>Name</th><th>Customer</th><th>Location</th></tr></thead><tbody>`;
html += newItems.map(r => `<tr>
<td><code class="mono">${esc(r.machine_id)}</code></td>
<td>${esc(r.name || '—')}</td>
<td>${esc(r.customer || '—')}</td>
<td>${esc(r.location || '—')}</td>
</tr>`).join('');
html += `</tbody></table>`;
}
// Changed assets table
if (changedItems.length > 0) {
html += `<div class="card-title" style="margin-top:8px;color:var(--amber);">~ Changed Assets (${changedItems.length})</div>`;
html += `<table class="cs-changes-table"><thead><tr><th>Machine ID</th><th>Name</th><th>Field</th><th>Old Value</th><th></th><th>New Value</th></tr></thead><tbody>`;
html += changedItems.map(r => {
const changes = r.changes || [];
const firstRow = `<td rowspan="${changes.length || 1}"><code class="mono">${esc(r.machine_id)}</code></td><td rowspan="${changes.length || 1}">${esc(r.name || '—')}</td>`;
if (changes.length === 0) {
return `<tr>${firstRow}<td colspan="4" class="text-muted">No field-level changes</td></tr>`;
}
return changes.map((ch, i) => {
const row = i === 0 ? firstRow : '';
return `<tr>${row}
<td style="font-weight:600;">${esc(ch.field)}</td>
<td class="cs-old">${esc(String(ch.old_value ?? '—'))}</td>
<td class="cs-arrow">→</td>
<td class="cs-new">${esc(String(ch.new_value ?? '—'))}</td>
</tr>`;
}).join('');
}).join('');
html += `</tbody></table>`;
}
// Removed assets table
if (removedItems.length > 0) {
html += `<div class="card-title" style="margin-top:8px;color:var(--red);"> Removed Assets (${removedItems.length})</div>`;
html += `<table class="cs-changes-table"><thead><tr><th>Machine ID</th><th>Name</th></tr></thead><tbody>`;
html += removedItems.map(r => `<tr>
<td><code class="mono">${esc(r.machine_id)}</code></td>
<td>${esc(r.name || '—')}</td>
</tr>`).join('');
html += `</tbody></table>`;
}
if (newItems.length === 0 && changedItems.length === 0 && removedItems.length === 0) {
html = '<div class="text-muted text-sm">No changes detected.</div>';
}
// Replacement candidates section
const replacements = (batch.diff_summary && batch.diff_summary.replacements) || [];
if (replacements.length > 0) {
const unresolved = replacements.filter(r => !r.status).length;
html += `<div class="cs-repl-section">
<h4>🔄 Machine Replacements Detected (${replacements.length})${unresolved > 0 ? ` — <span style="color:var(--amber)">${unresolved} unresolved</span>` : ' — <span style="color:var(--green)">all resolved</span>'}</h4>
<div>`;
replacements.forEach(r => {
const resolved = !!r.status;
const statusLabel = r.status === 'approved' ? '✓ Approved' : r.status === 'rejected' ? '✗ Rejected' : '';
html += `<div class="cs-repl-card${resolved ? ' resolved' : ''}" id="csReplCard_${batch.id}_${r.id}">
<div class="cs-repl-info">
<div class="cs-repl-old">🏷️ Old: <b>${esc(r.old_machine_id)}</b> — ${esc(r.old_asset_name || '—')}</div>
<div class="cs-repl-new">🆕 New: <b>${esc(r.new_machine_id)}</b> — ${esc(r.new_asset_name || '—')}</div>
<div class="cs-repl-addr">📍 ${esc(r.location_address || '—')} · ${esc(r.customer_name || '—')}</div>
</div>
<span class="cs-repl-conf ${r.confidence || 'medium'}">${(r.confidence || 'medium').toUpperCase()}</span>`;
if (resolved) {
html += `<span class="cs-repl-status ${r.status}">${statusLabel}</span>`;
} else {
html += `<div class="cs-repl-actions">
<button class="btn btn-green btn-sm" onclick="resolveReplacement(${batch.id}, '${esc(r.new_machine_id)}', '${esc(r.old_machine_id)}', 'approve')">✓ Approve</button>
<button class="btn btn-danger btn-sm" onclick="resolveReplacement(${batch.id}, '${esc(r.new_machine_id)}', '${esc(r.old_machine_id)}', 'reject')">✗ Reject</button>
</div>`;
}
html += `</div>`;
});
html += `</div>`;
if (unresolved > 0) {
html += `<div class="cs-repl-resolve-all">
<button class="btn btn-accent btn-sm" onclick="approveAllReplacements(${batch.id})">✓ Approve All</button>
</div>`;
}
html += `</div>`;
}
el.innerHTML = html;
}
// ── Section 3: Import History ──
function renderCsHistory() {
const el = document.getElementById('csHistoryContent');
if (csBatches.length === 0) {
el.innerHTML = '<div class="empty-state"><div class="es-icon">📭</div>No import history</div>';
return;
}
el.innerHTML = csBatches.map(b => {
const ds = b.diff_summary || {};
const newCount = (ds.new_assets || []).length;
const chgCount = (ds.changed_assets || []).length;
const remCount = (ds.removed_assets || []).length;
const replCount = (ds.replacements || []).length;
const replUnresolved = (ds.replacements || []).filter(r => !r.status).length;
const summaryParts = [];
if (newCount) summaryParts.push(`+${newCount}`);
if (chgCount) summaryParts.push(`~${chgCount}`);
if (remCount) summaryParts.push(`${remCount}`);
if (replCount) summaryParts.push(`🔄${replCount}${replUnresolved > 0 ? `<span style="color:var(--amber)">⚠${replUnresolved}</span>` : ''}`);
return `
<div class="cs-batch-row" onclick="toggleBatchHistory(${b.id})" id="csBatchRow_${b.id}">
<span class="csb-status">${csStatusBadge(b.status)}</span>
<span class="csb-time">${formatDate(b.created_at)}</span>
<span class="csb-rows">${b.row_count} rows</span>
<span class="text-muted text-sm" style="flex:1;">${summaryParts.join(' ')}</span>
<span class="csb-chevron">▶</span>
</div>
<div class="cs-batch-detail" id="csBatchDetail_${b.id}">
<div class="loading-row"><div class="spinner"></div> Loading...</div>
</div>`;
}).join('');
}
async function toggleBatchHistory(batchId) {
const row = document.getElementById('csBatchRow_' + batchId);
const detail = document.getElementById('csBatchDetail_' + batchId);
if (!row || !detail) return;
const isExpanded = row.classList.contains('expanded');
if (isExpanded) {
row.classList.remove('expanded');
detail.classList.remove('open');
return;
}
row.classList.add('expanded');
detail.classList.add('open');
// Fetch detail if not already loaded
if (detail.dataset.loaded !== '1') {
try {
const batch = await api(`/api/admin/cantaloupe/batches/${batchId}`);
// Render into a temporary container, then swap
const tmp = document.createElement('div');
renderBatchDiffDetail(tmp, batch);
detail.innerHTML = tmp.innerHTML;
detail.dataset.loaded = '1';
} catch (e) {
detail.innerHTML = `<div class="text-danger text-sm">Failed to load: ${esc(e.message)}</div>`;
}
}
}
// ── Upload Excel File ──
let _csUploading = false;
async function uploadSyncFile(event) {
const file = event.target.files?.[0];
if (!file) return;
if (_csUploading) return;
_csUploading = true;
const fd = new FormData();
fd.append('file', file);
try {
const result = await api('/api/admin/cantaloupe/sync', { method: 'POST', body: fd });
showToast('Upload processed — batch #' + result.id);
await loadCantaloupeSync();
} catch (e) {
showToast('Upload failed: ' + e.message, true);
} finally {
_csUploading = false;
event.target.value = ''; // reset so same file can be re-picked
}
}
// ── Approve / Reject ──
async function approveBatch(batchId) {
// Check for unresolved replacements
let unresolved = 0;
try {
const batch = await api(`/api/admin/cantaloupe/batches/${batchId}`);
unresolved = batch.unresolved_replacements || 0;
} catch (e) { /* ignore, proceed */ }
let warning = '';
if (unresolved > 0) {
warning = `\n\n⚠️ ${unresolved} replacement(s) are unresolved. Resolve them first or force-approve.`;
}
const confirmed = await showModal('Approve Batch',
`Apply all ${csLatestBatch && csLatestBatch.id === batchId ? 'pending ' : ''}changes from batch #${batchId} to live data?${warning}`,
unresolved > 0 ? 'Force Approve' : 'Approve', unresolved > 0 ? 'btn-amber' : 'btn-green');
if (!confirmed) return;
try {
const url = `/api/admin/cantaloupe/batches/${batchId}/approve${unresolved > 0 ? '?force=true' : ''}`;
const result = await api(url, { method: 'POST' });
showToast('Batch approved — ' + (result.applied_stats ? JSON.stringify(result.applied_stats) : 'done'));
await loadCantaloupeSync();
} catch (e) {
showToast('Approve failed: ' + e.message, true);
}
}
async function rejectBatch(batchId) {
const confirmed = await showModal('Reject Batch',
`Discard batch #${batchId}? This cannot be undone.`,
'Reject', 'btn-danger');
if (!confirmed) return;
try {
await api(`/api/admin/cantaloupe/batches/${batchId}/reject`, { method: 'POST' });
showToast('Batch rejected');
await loadCantaloupeSync();
} catch (e) {
showToast('Reject failed: ' + e.message, true);
}
}
// ── Per-Field Review (geotagger-style) ──
let _csFieldReviewState = null; // { batchId, changes, selected: Set('mid:field') }
let _csFieldReviewOpen = false;
async function toggleFieldReview(batchId) {
const area = document.getElementById('csFieldReviewArea');
if (!area) return;
if (_csFieldReviewOpen) {
area.style.display = 'none';
_csFieldReviewOpen = false;
return;
}
_csFieldReviewState = { batchId, changes: [], selected: new Set() };
try {
const data = await api(`/api/admin/cantaloupe/batches/${batchId}/field-changes`);
_csFieldReviewState.changes = data.field_changes || [];
// Auto-select blank-fill changes (old_value was empty/null)
for (const ch of _csFieldReviewState.changes) {
if (ch.is_blank_fill) {
_csFieldReviewState.selected.add(ch.machine_id + ':' + ch.field);
}
}
renderFieldChanges(area);
area.style.display = 'block';
_csFieldReviewOpen = true;
} catch (e) {
showToast('Failed to load field changes: ' + e.message, true);
}
}
function renderFieldChanges(area) {
const state = _csFieldReviewState;
if (!state || !state.changes.length) {
area.innerHTML = '<div class="text-muted text-sm" style="padding:12px;text-align:center;">No field-level changes to review.</div>';
return;
}
let html = '<div style="margin-bottom:8px;">';
html += '<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px;margin-bottom:8px;">';
html += `<span style="font-weight:600;font-size:13px;">🧩 ${state.changes.length} Field Change(s)</span>`;
html += '<div style="display:flex;gap:6px;">';
html += '<button class="btn btn-sm btn-accent" onclick="selectAllBlankFills()">✅ Auto-Select Blanks</button>';
html += '<button class="btn btn-sm" onclick="toggleAllFieldChanges()">Toggle All</button>';
html += '</div></div>';
// Group by machine_id for display
const grouped = {};
for (const ch of state.changes) {
const key = ch.machine_id + '|' + ch.name;
if (!grouped[key]) grouped[key] = { machine_id: ch.machine_id, name: ch.name, changes: [] };
grouped[key].changes.push(ch);
}
html += '<div style="display:flex;flex-direction:column;gap:8px;max-height:400px;overflow-y:auto;padding-right:4px;">';
for (const [key, asset] of Object.entries(grouped)) {
const assetChanges = asset.changes;
const allSelected = assetChanges.every(c => state.selected.has(c.machine_id + ':' + c.field));
const anySelected = assetChanges.some(c => state.selected.has(c.machine_id + ':' + c.field));
html += `<div style="background:var(--card2);border:1px solid var(--border);border-radius:8px;padding:8px;transition:opacity 0.2s;" id="fcAsset_${esc(asset.machine_id)}">`;
html += `<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
<input type="checkbox" ${allSelected ? 'checked' : ''} onchange="toggleAssetChanges('${esc(asset.machine_id)}', this.checked)" style="accent-color:var(--green);">
<span style="font-weight:600;font-size:13px;"><code class="mono">${esc(asset.machine_id)}</code> — ${esc(asset.name || '—')}</span>
<span style="font-size:11px;color:var(--text2);">${assetChanges.length} change(s)</span>
</div>`;
for (const ch of assetChanges) {
const selKey = ch.machine_id + ':' + ch.field;
const checked = state.selected.has(selKey);
const isBlank = ch.is_blank_fill;
html += `<div style="display:flex;align-items:center;gap:8px;padding:4px 8px;margin:2px 0;border-radius:4px;${isBlank ? 'background:rgba(72,199,142,0.08);border-left:3px solid var(--green);' : ''}">`;
html += `<input type="checkbox" ${checked ? 'checked' : ''} id="fcb_${selKey}" onchange="toggleFieldChange('${esc(ch.machine_id)}', '${esc(ch.field)}', this.checked)" style="accent-color:var(--green);flex-shrink:0;">`;
html += `<span style="font-weight:600;font-size:12px;min-width:110px;flex-shrink:0;">${esc(ch.field)}</span>`;
html += `<span class="cs-old" style="font-size:11px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(String(ch.old_value ?? '—'))}</span>`;
html += `<span style="color:var(--text2);padding:0 4px;">→</span>`;
html += `<span class="cs-new" style="font-size:11px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(String(ch.new_value ?? '—'))}</span>`;
if (isBlank) html += `<span style="font-size:10px;color:var(--green);flex-shrink:0;background:rgba(72,199,142,0.12);padding:2px 6px;border-radius:4px;">BLANK FILL</span>`;
html += `</div>`;
}
html += '</div>';
}
html += '</div>'; // close scroll container
// Sticky apply bar at bottom
const selectedCount = state.selected.size;
html += `<div style="position:sticky;bottom:0;background:var(--card);border-top:1px solid var(--border);padding:10px 0;margin-top:8px;display:flex;align-items:center;justify-content:space-between;z-index:10;">
<span style="font-size:12px;"><span id="csFieldCount" style="font-weight:700;">${selectedCount}</span> field change(s) selected</span>
<div style="display:flex;gap:8px;">
<button class="btn btn-sm btn-green" onclick="applyFieldChanges(${_csFieldReviewState.batchId})" id="csApplyFieldBtn"${selectedCount === 0 ? ' disabled style="opacity:0.5;"' : ''}>✓ Apply Selected</button>
</div>
</div>`;
area.innerHTML = html;
}
function toggleAssetChanges(machineId, checked) {
const state = _csFieldReviewState;
if (!state) return;
for (const ch of state.changes) {
if (ch.machine_id === machineId) {
const key = machineId + ':' + ch.field;
if (checked) state.selected.add(key);
else state.selected.delete(key);
}
}
// Refresh all checkboxes within this asset card
for (const ch of state.changes) {
if (ch.machine_id === machineId) {
const key = machineId + ':' + ch.field;
const cb = document.getElementById('fcb_' + key);
if (cb) cb.checked = checked;
}
}
updateFieldCount();
}
function toggleFieldChange(machineId, field, checked) {
const state = _csFieldReviewState;
if (!state) return;
const key = machineId + ':' + field;
if (checked) state.selected.add(key);
else state.selected.delete(key);
updateFieldCount();
}
function toggleAllFieldChanges() {
const state = _csFieldReviewState;
if (!state) return;
const allSelected = state.changes.every(c => state.selected.has(c.machine_id + ':' + c.field));
for (const ch of state.changes) {
const key = ch.machine_id + ':' + ch.field;
if (allSelected) state.selected.delete(key);
else state.selected.add(key);
}
renderFieldChanges(document.getElementById('csFieldReviewArea'));
}
function selectAllBlankFills() {
const state = _csFieldReviewState;
if (!state) return;
for (const ch of state.changes) {
if (ch.is_blank_fill) {
state.selected.add(ch.machine_id + ':' + ch.field);
}
}
renderFieldChanges(document.getElementById('csFieldReviewArea'));
}
function updateFieldCount() {
const state = _csFieldReviewState;
const countEl = document.getElementById('csFieldCount');
const btn = document.getElementById('csApplyFieldBtn');
if (!state) return;
const count = state.selected.size;
if (countEl) countEl.textContent = count;
if (btn) {
btn.disabled = count === 0;
btn.style.opacity = count === 0 ? '0.5' : '1';
}
}
async function applyFieldChanges(batchId) {
const state = _csFieldReviewState;
if (!state || state.selected.size === 0) {
showToast('No field changes selected', true);
return;
}
const changes = [];
for (const key of state.selected) {
// key is 'machineId:field'
const pipeIdx = key.indexOf(':');
if (pipeIdx > 0) {
changes.push({
machine_id: key.substring(0, pipeIdx),
field: key.substring(pipeIdx + 1),
});
}
}
const confirmed = await showModal('Apply Changes',
`Apply ${changes.length} selected field change(s) to live data? Blank-fill changes are pre-selected.`,
'Apply', 'btn-green');
if (!confirmed) return;
try {
const result = await api(`/api/admin/cantaloupe/batches/${batchId}/apply-field-changes`, {
method: 'POST',
body: JSON.stringify({ changes }),
headers: { 'Content-Type': 'application/json' },
});
showToast(result.message || 'Field changes applied');
// Close review area, refresh
const area = document.getElementById('csFieldReviewArea');
if (area) area.style.display = 'none';
_csFieldReviewOpen = false;
await loadCantaloupeSync();
} catch (e) {
showToast('Apply failed: ' + e.message, true);
}
}
// ── Replacement Resolution ──
async function resolveReplacement(batchId, newMid, oldMid, action) {
const label = action === 'approve' ? 'Approve' : 'Reject';
try {
const body = JSON.stringify({
replacements: [{ new_machine_id: newMid, old_machine_id: oldMid, action }]
});
const result = await api(`/api/admin/cantaloupe/batches/${batchId}/replacements`, {
method: 'POST',
body,
headers: { 'Content-Type': 'application/json' },
});
showToast(`${label}d replacement: ${oldMid}${newMid}`);
// Reload the batch detail inline
const detail = document.getElementById('csBatchDetail_' + batchId);
if (detail && detail.dataset.loaded === '1') {
const batch = await api(`/api/admin/cantaloupe/batches/${batchId}`);
const tmp = document.createElement('div');
renderBatchDiffDetail(tmp, batch);
detail.innerHTML = tmp.innerHTML;
}
// Refresh the batch list to update summary counts
await loadCantaloupeSync();
} catch (e) {
showToast(`${label} failed: ` + e.message, true);
}
}
async function approveAllReplacements(batchId) {
const batch = await api(`/api/admin/cantaloupe/batches/${batchId}`);
const replacements = (batch.diff_summary && batch.diff_summary.replacements) || [];
const unresolved = replacements.filter(r => !r.status);
if (unresolved.length === 0) {
showToast('All replacements already resolved');
return;
}
const confirmed = await showModal('Approve All',
`Approve all ${unresolved.length} unresolved replacement(s) in batch #${batchId}?`,
'Approve All', 'btn-green');
if (!confirmed) return;
try {
const body = JSON.stringify({
replacements: unresolved.map(r => ({
new_machine_id: r.new_machine_id,
old_machine_id: r.old_machine_id,
action: 'approve',
}))
});
await api(`/api/admin/cantaloupe/batches/${batchId}/replacements`, {
method: 'POST',
body,
headers: { 'Content-Type': 'application/json' },
});
showToast(`Approved ${unresolved.length} replacement(s)`);
// Reload detail
const detail = document.getElementById('csBatchDetail_' + batchId);
if (detail && detail.dataset.loaded === '1') {
const updated = await api(`/api/admin/cantaloupe/batches/${batchId}`);
const tmp = document.createElement('div');
renderBatchDiffDetail(tmp, updated);
detail.innerHTML = tmp.innerHTML;
}
await loadCantaloupeSync();
} catch (e) {
showToast('Approve all failed: ' + e.message, true);
}
}
// ═════════════════════════════════════════════════════════════════════════════
// HELPERS
// ═════════════════════════════════════════════════════════════════════════════
function esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function formatDate(iso) {
if (!iso) return '—';
// Handle space-separated datetime (SQLite format): "2026-05-21 23:16:21"
let normalized = iso;
if (!iso.includes('T') && iso.includes(' ')) {
normalized = iso.replace(' ', 'T');
} else if (!iso.includes('T')) {
normalized = iso + 'T00:00:00';
}
const d = new Date(normalized);
if (isNaN(d.getTime())) return iso;
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
}
function timeAgo(iso) {
if (!iso) return '';
const d = new Date(iso + (iso.includes('T') ? '' : 'Z'));
if (isNaN(d.getTime())) return iso;
const now = new Date();
const sec = Math.floor((now - d) / 1000);
if (sec < 60) return 'just now';
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
const day = Math.floor(hr / 24);
if (day < 7) return `${day}d ago`;
return formatDate(iso);
}
function getActIcon(action) {
if (!action) return 'other';
const a = action.toLowerCase();
if (a.includes('create') || a.includes('add')) return 'created';
if (a.includes('update') || a.includes('edit')) return 'updated';
if (a.includes('delete') || a.includes('remove')) return 'deleted';
if (a.includes('check') || a.includes('login')) return 'checked';
if (a.includes('user')) return 'user';
if (a.includes('customer')) return 'customer';
if (a.includes('asset')) return 'asset';
return 'other';
}
function getActEmoji(action) {
if (!action) return '📌';
const a = action.toLowerCase();
if (a.includes('create') || a.includes('add')) return '';
if (a.includes('update') || a.includes('edit')) return '✏️';
if (a.includes('delete') || a.includes('remove')) return '🗑';
if (a.includes('check')) return '✅';
if (a.includes('login')) return '🔑';
if (a.includes('user')) return '👤';
if (a.includes('customer')) return '🏢';
if (a.includes('asset')) return '📦';
if (a.includes('geofence')) return '📍';
if (a.includes('visit')) return '🕐';
return '📌';
}
// ═════════════════════════════════════════════════════════════════════════════
// INIT
// ═════════════════════════════════════════════════════════════════════════════
document.addEventListener('DOMContentLoaded', () => {
initAuth();
// Enter key on login
document.getElementById('loginPass').addEventListener('keydown', e => {
if (e.key === 'Enter') doLogin();
});
document.getElementById('loginUser').addEventListener('keydown', e => {
if (e.key === 'Enter') document.getElementById('loginPass').focus();
});
// ── Keyboard shortcuts ──
// Ctrl+K or Ctrl+/ to focus asset search (when logged in and on assets page)
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === '/')) {
e.preventDefault();
if (!isLoggedIn()) return;
const searchInput = document.getElementById('assetSearch');
if (searchInput) {
switchPage('assets');
setTimeout(() => searchInput.focus(), 100);
}
}
// Escape to close modals
if (e.key === 'Escape') {
if (document.getElementById('modalOverlay').classList.contains('open')) {
closeModal(false);
}
if (document.getElementById('helpModal') && document.getElementById('helpModal').style.display !== 'none') {
closeHelpModal();
}
}
});
// Warn on page refresh/close if inline edits are in progress
window.addEventListener('beforeunload', (e) => {
const editingCells = document.querySelectorAll('.ae-cell.editing');
if (editingCells.length > 0) {
e.preventDefault();
e.returnValue = 'You have unsaved inline edits.';
}
});
});
// ═════════════════════════════════════════════════════════════════════════════
// EXIF SCANNER — State
// ═════════════════════════════════════════════════════════════════════════════
let exifAllPhotos = []; // {file, exif, hasGps, lat, lng, thumb}
let exifSelectedIdx = -1;
let exifBulkData = [];
let exifMap = null;
let exifLastPhotoId = null;
let exifLastPhotoGps = null;
function esc(s) {
const d = document.createElement('div');
d.textContent = String(s);
return d.innerHTML;
}
// ═════════════════════════════════════════════════════════════════════════════
// EXIF SCANNER — Page Init
// ═════════════════════════════════════════════════════════════════════════════
async function loadExifScanner() {
exifAllPhotos = [];
exifSelectedIdx = -1;
exifBulkData = [];
if (exifMap) { exifMap.remove(); exifMap = null; }
document.getElementById('exifGallery').innerHTML = '';
document.getElementById('exifBulkResults').innerHTML = '';
document.getElementById('exifDetail').style.display = 'none';
document.getElementById('exifBulkSection').style.display = 'none';
document.getElementById('exifSessionsSection').style.display = 'none';
document.getElementById('exifPrevSection').style.display = 'none';
document.getElementById('exifOcrOptions').style.display = 'none';
document.getElementById('exifSummary').style.display = 'none';
// Load sessions and previous photos in parallel
await Promise.all([
loadExifSessions(),
loadExifPrevPhotos(),
]);
}
async function loadExifSessions() {
try {
const data = await api('/api/admin/exif/sessions');
const sessions = data.sessions || [];
const list = document.getElementById('exifSessionsList');
const section = document.getElementById('exifSessionsSection');
if (sessions.length === 0) {
section.style.display = 'none';
return;
}
section.style.display = 'block';
document.getElementById('exifSessionCount').textContent = sessions.length;
list.innerHTML = sessions.map(s => {
const closed = s.closed_at ? `🔒 Closed ${new Date(s.closed_at).toLocaleDateString()}` : '🟢 Open';
return `<div style="padding:8px 10px;background:var(--card2);border-radius:var(--radius-sm);margin-bottom:6px;display:flex;align-items:center;gap:8px;cursor:pointer;" onclick="switchPage('exifscanner')">
<div style="flex:1;min-width:0;">
<div style="font-size:13px;font-weight:600;">${esc(s.name || 'Unnamed')}</div>
<div style="font-size:11px;color:var(--text2);">${s.building || ''}${s.floor ? ' • Floor ' + s.floor : ''}${s.photo_count || 0} photos, ${s.matched_count || 0} matched</div>
</div>
<span style="font-size:10px;padding:2px 8px;border-radius:10px;background:var(--accent-bg);color:var(--accent2);">${closed}</span>
</div>`;
}).join('');
} catch (e) {
/* session section stays hidden */
}
}
async function loadExifPrevPhotos() {
try {
const data = await api('/api/admin/exif/photos?limit=30');
const photos = data.photos || [];
const section = document.getElementById('exifPrevSection');
const div = document.getElementById('exifPrevResults');
if (!photos.length) {
section.style.display = 'none';
return;
}
section.style.display = 'block';
document.getElementById('exifPrevCount').textContent = photos.length;
div.innerHTML = photos.map((p, i) => renderExifPrevCard(p, i)).join('');
} catch (e) {
const section = document.getElementById('exifPrevSection');
section.style.display = 'none';
}
}
function renderExifPrevCard(p, idx) {
const hasGps = p.gps_lat && p.gps_lng;
const matched = p.machine_id ? 'matched' : 'no-match';
const gpsBadge = hasGps
? '<span class="status-tag" style="background:var(--green-bg);color:var(--green);font-size:10px;">📍 GPS</span>'
: '<span class="status-tag" style="background:var(--amber-bg);color:var(--amber);font-size:10px;">📍 No GPS</span>';
const matchBadge = matched === 'matched'
? `<span class="status-tag" style="background:var(--green-bg);color:var(--green);font-size:10px;">✓ ${esc(p.machine_id)}</span>`
: '<span class="status-tag" style="background:var(--card2);color:var(--text2);font-size:10px;">⏳ No match</span>';
const uploaderBadge = p.uploaded_by
? `<span class="status-tag" style="background:var(--accent-bg);color:var(--accent2);font-size:10px;">👤 ${esc(p.uploaded_by)}</span>`
: '';
const thumb = p.id ? `/api/admin/exif/photos/${p.id}/file` : '';
return `<div style="background:var(--card);border-radius:var(--radius-sm);padding:10px;margin-bottom:6px;border-left:3px solid ${hasGps ? 'var(--green)' : 'var(--amber)'};">\n <div style="display:flex;gap:8px;">\n ${thumb ? `<img src="${thumb}" style="width:48px;height:48px;border-radius:6px;object-fit:cover;flex-shrink:0;" onclick="window.open('${thumb}','_blank')">` : ''}\n <div style="flex:1;min-width:0;">\n <div style="font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${esc(p.orig_filename || 'photo.jpg')}</div>\n <div style="display:flex;gap:4px;margin-top:4px;flex-wrap:wrap;">\n ${gpsBadge} ${matchBadge} ${uploaderBadge}
</div>\n <div style="font-size:10px;color:var(--text3);margin-top:4px;">${p.created_at ? new Date(p.created_at).toLocaleString() : ''}</div>\n </div>\n </div>\n </div>`;
}
// ═════════════════════════════════════════════════════════════════════════════
// EXIF SCANNER — File handling
// ═════════════════════════════════════════════════════════════════════════════
function handleExifFiles(files) {
if (!files || !files.length) return;
exifAllPhotos = [];
exifSelectedIdx = -1;
exifBulkData = [];
if (exifMap) { exifMap.remove(); exifMap = null; }
for (let i = 0; i < files.length; i++) {
const file = files[i];
const reader = new FileReader();
reader.onload = (e) => {
// Check EXIF in browser first
let hasGps = false, lat = null, lng = null;
if (typeof exifr !== 'undefined' && exifr.parse) {
exifr.parse(file).then(exif => {
hasGps = !!(exif && exif.latitude && exif.longitude);
lat = hasGps ? exif.latitude : null;
lng = hasGps ? exif.longitude : null;
exifAllPhotos.push({ file, hasGps, lat, lng, thumb: e.target.result });
if (exifAllPhotos.length === files.length) {
renderExifGallery();
showExifOcrOptions();
}
}).catch(() => {
exifAllPhotos.push({ file, hasGps: false, lat: null, lng: null, thumb: e.target.result });
if (exifAllPhotos.length === files.length) {
renderExifGallery();
showExifOcrOptions();
}
});
} else {
exifAllPhotos.push({ file, hasGps: false, lat: null, lng: null, thumb: e.target.result });
if (exifAllPhotos.length === files.length) {
renderExifGallery();
showExifOcrOptions();
}
}
};
reader.readAsDataURL(file);
}
}
function renderExifGallery() {
const gallery = document.getElementById('exifGallery');
const total = exifAllPhotos.length;
const withGps = exifAllPhotos.filter(p => p.hasGps).length;
const noGps = total - withGps;
gallery.innerHTML = exifAllPhotos.map((p, i) => `
<div class="${p.hasGps ? 'exif-card-gps' : 'exif-card-nogps'}" style="position:relative;border-radius:var(--radius-xs);overflow:hidden;aspect-ratio:1;background:var(--card);border:2px solid ${p.hasGps ? 'var(--green)' : 'var(--border)'};cursor:pointer;${exifSelectedIdx === i ? 'border-color:var(--accent);' : ''}" onclick="showExifDetail(${i})">
<img src="${esc(p.thumb)}" style="width:100%;height:100%;object-fit:cover;display:block;">
<div style="position:absolute;bottom:0;left:0;right:0;padding:3px 5px;font-size:9px;font-weight:700;background:${p.hasGps ? 'rgba(74,222,128,0.85)' : 'rgba(251,191,36,0.85)'};color:#000;">📍 ${p.hasGps ? 'GPS' : 'No GPS'}</div>
</div>
`).join('');
gallery.style.display = 'grid';
// Update summary
document.getElementById('exifSummary').style.display = 'block';
document.getElementById('exifSumGps').textContent = withGps;
document.getElementById('exifSumNoGps').textContent = noGps;
document.getElementById('exifSumTotal').textContent = total;
document.getElementById('exifSumMatched').textContent = 0;
}
function showExifOcrOptions() {
const total = exifAllPhotos.length;
document.getElementById('exifOcrOptions').style.display = 'block';
document.getElementById('exifBulkBtn').style.display = total > 0 ? 'inline-flex' : 'none';
document.getElementById('exifFileCount').textContent = total;
}
function onExifOcrToggle() {
const engine = document.getElementById('exifOcrEngine').value;
const sticker = document.getElementById('exifStickerMode').checked;
const label = document.getElementById('exifModelLabel');
if (engine === 'llm' && sticker) label.textContent = '🏷️ sticker mode';
else if (engine === 'llm') label.textContent = 'mimo-v2-omni';
else if (engine === 'google' && sticker) label.textContent = '🏷️ gemini sticker';
else if (engine === 'google') label.textContent = 'gemini-2.5-flash';
else label.textContent = 'tesseract';
}
// ═════════════════════════════════════════════════════════════════════════════
// EXIF SCANNER — Bulk Process
// ═════════════════════════════════════════════════════════════════════════════
async function startExifBulkProcess() {
const btn = document.getElementById('exifBulkBtn');
btn.disabled = true;
btn.textContent = '⏳ Processing...';
try {
const formData = new FormData();
for (const p of exifAllPhotos) {
formData.append('files', p.file);
}
const engine = document.getElementById('exifOcrEngine').value;
const sticker = document.getElementById('exifStickerMode').checked;
let url = `/api/admin/exif/bulk-process?ocr_engine=${engine}`;
if (sticker) url += '&sticker_mode=true';
const resp = await fetch(url, {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + AppState.authToken },
body: formData,
});
if (!resp.ok) {
const err = await resp.text();
throw new Error(err);
}
const data = await resp.json();
exifBulkData = data.results || [];
renderExifBulkResults();
} catch (e) {
document.getElementById('exifBulkResults').innerHTML =
'<div style="text-align:center;padding:20px;color:var(--red);">❌ Bulk process failed: ' + esc(e.message) + '</div>';
} finally {
btn.disabled = false;
btn.textContent = '🔍 Bulk Process (' + exifAllPhotos.length + ')';
}
}
function renderExifBulkResults() {
const section = document.getElementById('exifBulkSection');
section.style.display = 'block';
const div = document.getElementById('exifBulkResults');
const items = exifBulkData;
const hasGps = items.filter(i => i.exif && i.exif.gps).length;
const matched = items.filter(i => i.asset).length;
const needsGps = items.filter(i => i.needs_gps).length;
const total = items.length;
document.getElementById('exifBulkSummary').textContent = `📍${hasGps}${matched} 📤${needsGps} 📷${total}`;
div.innerHTML = items.map((item, idx) => renderExifBulkCard(item, idx)).join('');
// Init map
initExifMap(items);
// Show batch bar if there are needs_gps items
const hasNeedsGps = items.some(i => i.needs_gps);
if (hasNeedsGps || items.length > 0) {
const bar = document.getElementById('exifBatchBar');
bar.style.display = 'block';
bar.style.marginTop = '8px';
}
}
function renderExifBulkCard(item, idx) {
const gps = item.exif && item.exif.gps;
const asset = item.asset;
const matchBadge = asset
? `<span class="status-tag" style="background:var(--green-bg);color:var(--green);font-size:10px;">✓ ${esc(asset.name || asset.machine_id)}</span>`
: (item.machine_id
? `<span class="status-tag" style="background:var(--amber-bg);color:var(--amber);font-size:10px;">🔍 ${esc(item.machine_id)}</span>`
: '<span class="status-tag" style="background:var(--card2);color:var(--text2);font-size:10px;">⏳ No match</span>');
const gpsBadge = gps
? `<span class="status-tag" style="background:var(--green-bg);color:var(--green);font-size:10px;">📍 ${gps.lat.toFixed(5)}, ${gps.lng.toFixed(5)}</span>`
: '<span class="status-tag" style="background:var(--amber-bg);color:var(--amber);font-size:10px;">📍 No GPS</span>';
const dupBadge = item.duplicate
? '<span class="status-tag" style="background:var(--red-bg);color:var(--red);font-size:10px;">🔁 Duplicate</span>'
: '';
return `<div style="background:var(--card2);border-radius:var(--radius-sm);padding:10px;margin-bottom:6px;border-left:3px solid ${gps ? 'var(--green)' : 'var(--amber)'};">
<div style="font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px;">${esc(item.filename)}</div>
<div style="display:flex;gap:4px;flex-wrap:wrap;margin-bottom:4px;">
${gpsBadge} ${matchBadge} ${dupBadge}
</div>
${item.needs_gps ? `<div style="display:flex;gap:4px;margin-top:4px;">
<button class="btn btn-xs" id="exifPushBtn${idx}" style="background:var(--green);color:#000;" onclick="exifPushGpsToAsset(${idx})">📤 Push GPS</button>
</div>` : ''}
</div>`;
}
function initExifMap(items) {
const container = document.getElementById('exifMapContainer');
const points = items.filter(i => i.exif && i.exif.gps);
if (!points.length) { container.style.display = 'none'; return; }
container.style.display = 'block';
// Load Leaflet dynamically if not loaded
if (typeof L === 'undefined') {
const script = document.createElement('script');
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
script.onload = () => initExifMapInner(points);
document.head.appendChild(script);
return;
}
initExifMapInner(points);
}
function initExifMapInner(points) {
if (exifMap) exifMap.remove();
const container = document.getElementById('exifMapContainer');
const center = { lat: points[0].exif.gps.lat, lng: points[0].exif.gps.lng };
exifMap = L.map(container).setView([center.lat, center.lng], 16);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap',
maxZoom: 19,
}).addTo(exifMap);
const bounds = [];
points.forEach((item, idx) => {
const gps = item.exif.gps;
const color = item.asset ? '#4ade80' : '#5b6ef7';
const marker = L.circleMarker([gps.lat, gps.lng], {
radius: 8, fillColor: color, color: '#fff', weight: 2, fillOpacity: 0.8,
}).addTo(exifMap);
marker.bindPopup(`<b>${esc(item.filename)}</b><br>📍 ${gps.lat.toFixed(5)}, ${gps.lng.toFixed(5)}${item.asset ? '<br>✓ ' + esc(item.asset.name) : ''}`);
bounds.push([gps.lat, gps.lng]);
});
if (bounds.length > 1) exifMap.fitBounds(bounds, { padding: [30, 30] });
}
// ═════════════════════════════════════════════════════════════════════════════
// EXIF SCANNER — Detail View
// ═════════════════════════════════════════════════════════════════════════════
function showExifDetail(idx) {
exifSelectedIdx = idx;
renderExifGallery();
const photo = exifAllPhotos[idx];
const detail = document.getElementById('exifDetail');
const preview = document.getElementById('exifDetailPreview');
const content = document.getElementById('exifDetailContent');
detail.style.display = 'block';
preview.src = photo.thumb;
let html = '';
if (photo.lat && photo.lng) {
html += `<div class="gps-coords" style="font-family:monospace;font-size:16px;font-weight:700;color:var(--green);text-align:center;padding:12px;background:var(--card2);border-radius:var(--radius-sm);margin:8px 0;">
📍 ${photo.lat.toFixed(6)}, ${photo.lng.toFixed(6)}
</div>`;
} else {
html += '<div style="text-align:center;padding:12px;color:var(--amber);font-size:13px;">⚠️ No GPS data found in this photo</div>';
}
html += `<div style="margin-top:8px;display:flex;gap:6px;">
<button class="btn btn-sm btn-primary" onclick="exifUploadSelected(${idx})">🔍 Upload & Analyze</button>
</div>`;
content.innerHTML = html;
}
async function exifUploadSelected(idx) {
const photo = exifAllPhotos[idx];
const engine = document.getElementById('exifOcrEngine').value;
const sticker = document.getElementById('exifStickerMode').checked;
let url = `/api/admin/exif/analyze?ocr_engine=${engine}`;
if (sticker) url += '&sticker_mode=true';
const formData = new FormData();
formData.append('file', photo.file);
try {
const resp = await fetch(url, {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + AppState.authToken },
body: formData,
});
const data = await resp.json();
exifLastPhotoId = data.photo_id;
exifLastPhotoGps = data.exif && data.exif.gps ? { lat: data.exif.gps.lat, lng: data.exif.gps.lng } : null;
showExifServerResults(data);
} catch (e) {
document.getElementById('exifDetailContent').innerHTML +=
`<div style="text-align:center;padding:12px;color:var(--red);">❌ Analysis failed: ${esc(e.message)}</div>`;
}
}
function showExifServerResults(data) {
const content = document.getElementById('exifDetailContent');
let html = '<div style="margin-top:12px;padding-top:12px;border-top:1px solid var(--border);">';
html += '<div style="font-size:13px;font-weight:700;margin-bottom:8px;">🖥️ Server Results</div>';
// EXIF data
const exif = data.exif || {};
if (exif.has_exif) {
html += '<div style="font-size:12px;font-weight:600;color:var(--text2);margin-bottom:4px;">📷 EXIF Tags</div>';
const tags = exif.tags || {};
const tagEntries = Object.entries(tags).slice(0, 15);
for (const [k, v] of tagEntries) {
html += `<div style="display:flex;justify-content:space-between;padding:3px 0;border-bottom:1px solid rgba(255,255,255,0.04);font-size:12px;">
<span style="color:var(--text2);margin-right:8px;">${esc(k)}</span>
<span style="text-align:right;word-break:break-all;color:var(--text);">${esc(v)}</span>
</div>`;
}
}
if (exif.gps) {
html += `<div style="margin:8px 0;padding:8px;background:var(--card2);border-radius:var(--radius-sm);font-family:monospace;font-size:14px;font-weight:700;color:var(--green);text-align:center;">
📍 ${exif.gps.lat.toFixed(6)}, ${exif.gps.lng.toFixed(6)}
</div>`;
}
// OCR results
const ocr = data.ocr || {};
if (ocr.raw_text) {
html += '<div style="margin-top:8px;"><span class="card-title">🔎 OCR</span>';
html += `<span class="status-tag" style="font-size:10px;margin-left:4px;background:var(--accent-bg);color:var(--accent2);">${esc(ocr.engine || '?')}</span>`;
html += `<div style="background:var(--card2);border-radius:var(--radius-sm);padding:8px;font-family:monospace;font-size:11px;white-space:pre-wrap;max-height:100px;overflow-y:auto;color:var(--text2);margin-top:4px;">${esc(ocr.raw_text)}</div></div>`;
}
if (ocr.match_5dash6 || ocr.match_5plus) {
html += `<div style="margin-top:6px;"><span class="status-tag" style="background:var(--green-bg);color:var(--green);font-size:11px;">✓ ${esc(ocr.match_5dash6 || ocr.match_5plus)}</span></div>`;
}
// Machine match
const asset = data.asset;
if (asset) {
html += `<div style="margin-top:8px;padding:8px;background:var(--green-bg);border-radius:var(--radius-sm);">
<div style="font-size:12px;font-weight:700;color:var(--green);">✓ Matched: ${esc(asset.name || asset.machine_id)}</div>
<div style="font-size:11px;color:var(--green);opacity:0.8;">${asset.building_name || ''}${asset.floor ? ' Floor ' + asset.floor : ''}${asset.room ? ' • ' + asset.room : ''}</div>
</div>`;
} else if (data.machine_id) {
html += `<div style="margin-top:8px;padding:8px;background:var(--amber-bg);border-radius:var(--radius-sm);">
<div style="font-size:12px;font-weight:700;color:var(--amber);">🔍 Machine ID: ${esc(data.machine_id)} (not found in DB)</div>
</div>`;
}
html += '</div>';
content.innerHTML += html;
}
// ═════════════════════════════════════════════════════════════════════════════
// EXIF SCANNER — GPS Push
// ═════════════════════════════════════════════════════════════════════════════
async function exifPushGpsToAsset(idx) {
const item = exifBulkData[idx];
if (!item || !item.asset || !item.needs_gps) return;
const btn = document.getElementById('exifPushBtn' + idx);
btn.disabled = true;
btn.textContent = '⏳...';
btn.style.background = 'var(--card2)';
const gps = item.exif.gps;
try {
const data = await api('/api/admin/exif/push-gps', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
asset_id: item.asset.id,
latitude: gps.lat,
longitude: gps.lng,
}),
});
if (data.updated) {
btn.textContent = '✓ Pushed!';
btn.style.background = 'var(--green)';
btn.style.color = '#000';
} else {
btn.textContent = data.reason || 'Failed';
btn.style.background = 'var(--red)';
}
} catch (e) {
btn.textContent = 'Error';
btn.style.background = 'var(--red)';
}
}
// ═════════════════════════════════════════════════════════════════════════════
// EXIF SCANNER — Batch bar
// ═════════════════════════════════════════════════════════════════════════════
async function exifBatchLookup() {
// Simple: find first unmatched photo and try the typed machine ID
const machineId = document.getElementById('exifBatchMachineId').value.trim();
if (!machineId) return;
try {
const data = await api('/api/admin/exif/lookup?machine_id=' + encodeURIComponent(machineId));
if (data.found) {
showToast('✓ Found: ' + data.asset.name);
} else {
showToast(data.reason || 'Not found', true);
}
} catch (e) {
showToast('Lookup failed: ' + e.message, true);
}
}
async function exifBatchPushGps() {
const machineId = document.getElementById('exifBatchMachineId').value.trim();
if (!machineId) return;
// Find all needs_gps items
const toPush = exifBulkData.filter(i => i.needs_gps && i.asset && i.exif && i.exif.gps);
if (!toPush.length) {
showToast('No GPS-ready items to push', true);
return;
}
let pushed = 0;
for (const item of toPush) {
try {
const data = await api('/api/admin/exif/push-gps', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
asset_id: item.asset.id,
latitude: item.exif.gps.lat,
longitude: item.exif.gps.lng,
}),
});
if (data.updated) pushed++;
} catch (e) { /* skip */ }
}
showToast(`📤 GPS pushed to ${pushed} / ${toPush.length} assets`);
}
// ═════════════════════════════════════════════════════════════════════════════
// EXIF SCANNER — Sessions
// ═════════════════════════════════════════════════════════════════════════════
async function exifCreateSession() {
const name = document.getElementById('exifSessionName').value.trim();
if (!name) {
showToast('Enter a session name', true);
return;
}
try {
await api('/api/admin/exif/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
showToast('✅ Session created');
document.getElementById('exifSessionName').value = '';
await loadExifSessions();
} catch (e) {
showToast('Failed: ' + e.message, true);
}
}
// ═════════════════════════════════════════════════════════════════════════════
// EXIF SCANNER — Lightbox
// ═════════════════════════════════════════════════════════════════════════════
var exifLightboxEl = null;
function openExifLightbox(src) {
if (!exifLightboxEl) {
exifLightboxEl = document.createElement('div');
exifLightboxEl.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.95);z-index:10000;display:flex;align-items:center;justify-content:center;cursor:zoom-out;';
exifLightboxEl.onclick = closeExifLightbox;
const img = document.createElement('img');
img.style.cssText = 'max-width:95vw;max-height:95vh;object-fit:contain;border-radius:4px;';
img.id = 'exifLightboxImg';
exifLightboxEl.appendChild(img);
document.body.appendChild(exifLightboxEl);
}
document.getElementById('exifLightboxImg').src = src;
exifLightboxEl.style.display = 'flex';
}
function closeExifLightbox() {
if (exifLightboxEl) exifLightboxEl.style.display = 'none';
}
// ═════════════════════════════════════════════════════════════════════════════
// REPLACEMENTS
// ═════════════════════════════════════════════════════════════════════════════
let cachedReplacements = [];
function escHtml(s) {
if (!s) return '';
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
async function loadReplacements() {
const body = document.getElementById('repBody');
const empty = document.getElementById('repEmpty');
const error = document.getElementById('repError');
const count = document.getElementById('repCount');
body.innerHTML = '<tr><td colspan="10" style="text-align:center;padding:32px;color:var(--text2);">Loading...</td></tr>';
empty.style.display = 'none';
error.style.display = 'none';
try {
const data = await api('/api/admin/replacements/history');
cachedReplacements = data.replacements || [];
renderReplacements(cachedReplacements);
const src = data.sources || {};
const srcParts = [];
if (src.sync_batch_inline) srcParts.push(`${src.sync_batch_inline} from sync`);
if (src.batch_crossref) srcParts.push(`${src.batch_crossref} from crossref`);
if (src.location_group) srcParts.push(`${src.location_group} from location data`);
const srcInfo = srcParts.length ? ` (${srcParts.join(', ')})` : '';
count.textContent = `${data.total} replacement event${data.total !== 1 ? 's' : ''}${srcInfo}`;
} catch (e) {
error.textContent = 'Error loading replacements: ' + (e.message || e);
error.style.display = 'block';
body.innerHTML = '';
}
}
function renderReplacements(reps) {
const body = document.getElementById('repBody');
const empty = document.getElementById('repEmpty');
if (!reps || reps.length === 0) {
body.innerHTML = '';
empty.style.display = 'block';
return;
}
empty.style.display = 'none';
body.innerHTML = reps.map(rep => {
const date = rep.batch_date ? new Date(rep.batch_date + 'Z').toLocaleDateString() : '—';
const oldStatus = rep.old_status || '';
const newStatus = rep.new_status || 'On';
const confidence = rep.confidence || 'low';
const oldStatusLabel = oldStatus ? statusBadge(oldStatus) : '';
const newStatusLabel = statusBadge(newStatus);
let confClass = 'badge badge-info';
if (confidence === 'high') confClass = 'badge badge-success';
else if (confidence === 'medium') confClass = 'badge badge-warning';
else if (confidence === 'low') confClass = 'badge badge-default';
const oldMid = rep.old_machine_id || '';
const newMid = rep.new_machine_id || '';
const loc = rep.location || '';
const oldName = rep.old_name || '';
const newName = rep.new_name || '';
return `<tr>
<td style="white-space:nowrap;">${date}</td>
<td><strong>${escHtml(loc)}</strong></td>
<td><a href="#" onclick="showAssetDetail('${escHtml(oldMid)}');return false;" style="font-family:monospace;">${escHtml(oldMid)}</a></td>
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escHtml(oldName)}">${escHtml(oldName)}</td>
<td>${oldStatusLabel}</td>
<td style="text-align:center;font-size:18px;color:var(--accent);">→</td>
<td><a href="#" onclick="showAssetDetail('${escHtml(newMid)}');return false;" style="font-family:monospace;font-weight:600;color:var(--green);">${escHtml(newMid)}</a></td>
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escHtml(newName)}">${escHtml(newName)}</td>
<td>${newStatusLabel}</td>
<td><span class="${confClass}">${confidence}</span></td>
</tr>`;
}).join('');
}
function statusBadge(status) {
const colors = {
'On': { bg: '#1a3a2a', color: '#4ade80' },
'Incompatible': { bg: '#3a1a1a', color: '#f87171' },
'Action Required': { bg: '#3a2a1a', color: '#fbbf24' },
'Off': { bg: '#2a2a2a', color: '#9ca3af' },
'retired': { bg: '#2a1a2a', color: '#c084fc' },
};
const c = colors[status] || { bg: '#2a2a2a', color: '#ccc' };
return `<span style="background:${c.bg};color:${c.color};padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;">${escHtml(status)}</span>`;
}
function filterReplacements() {
const q = document.getElementById('repSearch').value.toLowerCase().trim();
if (!q) {
renderReplacements(cachedReplacements);
return;
}
const filtered = cachedReplacements.filter(rep =>
(rep.location || '').toLowerCase().includes(q) ||
(rep.old_machine_id || '').includes(q) ||
(rep.new_machine_id || '').includes(q) ||
(rep.old_name || '').toLowerCase().includes(q) ||
(rep.new_name || '').toLowerCase().includes(q)
);
renderReplacements(filtered);
document.getElementById('repCount').textContent = `${filtered.length} of ${cachedReplacements.length} replacement events`;
}
function showAssetDetail(machineId) {
// Navigate to assets page with search
document.getElementById('assetSearch').value = machineId;
switchPage('assets');
// Trigger search after render
setTimeout(() => {
const input = document.getElementById('assetSearch');
if (input) input.value = machineId;
if (typeof doAssetSearch === 'function') doAssetSearch();
}, 100);
}
async function showAssetWorkOrders(machineId, assetName) {
// Switch to work orders tab and search by asset name
document.getElementById('woSearch').value = assetName;
document.getElementById('woStatusFilter').value = '';
switchPage('workorders');
woState.offset = 0;
setTimeout(() => {
const search = document.getElementById('woSearch');
if (search) search.value = assetName;
loadWorkOrders();
}, 100);
}
// ═════════════════════════════════════════════════════════════════════════════
// ROUTE PLANNER
// ═════════════════════════════════════════════════════════════════════════════
function initRoutePage() {
const iframe = document.getElementById('routeFrame');
if (!iframe) return;
// Force-reload on each visit so the route data is always fresh
if (iframe.dataset.loaded) {
iframe.src = iframe.src;
}
iframe.dataset.loaded = '1';
}
// ═════════════════════════════════════════════════════════════════════════════
// REPORTS — Work Order KPIs
// ═════════════════════════════════════════════════════════════════════════════
const STATUS_COLORS = {
'690970003': '#4ade80', // Completed
'690970004': '#7c8cf8', // Posted
'690970001': '#fbbf24', // Scheduled
'690970002': '#22d3ee', // In Progress
'690970000': '#5b5f73', // Unscheduled
'690970005': '#f87171', // Cancelled
'690959000': '#8b8fa3', // Postponed
};
const STATUS_LABELS = {
'690970003': 'Completed',
'690970004': 'Posted',
'690970001': 'Scheduled',
'690970002': 'In Progress',
'690970000': 'Unscheduled',
'690970005': 'Cancelled',
'690959000': 'Postponed',
};
// ── Date navigation state ──
let dateOffset = 0;
function getRepDateParams() {
const range = document.getElementById('repDateRange').value;
const offset = dateOffset;
const now = new Date();
// Shift reference point by offset (according to interval)
const ref = new Date(now);
if (range === 'today') {
ref.setDate(ref.getDate() + offset);
} else if (range === 'thisweek' || range === 'lastweek') {
ref.setDate(ref.getDate() + offset * 7);
} else if (range === 'thismonth') {
ref.setMonth(ref.getMonth() + offset);
} else if (range === 'last30') {
ref.setDate(ref.getDate() + offset * 30);
} else if (range === 'last90') {
ref.setDate(ref.getDate() + offset * 90);
}
function fmt(d) { return d.toISOString().slice(0, 10); }
let dateFrom = '', dateTo = '';
if (range === 'today') {
dateFrom = dateTo = fmt(ref);
} else if (range === 'thisweek' || range === 'lastweek') {
const dayOfWeek = ref.getDay();
const mon = new Date(ref);
mon.setDate(ref.getDate() - ((dayOfWeek + 6) % 7));
dateFrom = fmt(mon);
const sun = new Date(mon);
sun.setDate(mon.getDate() + 6);
dateTo = fmt(sun);
} else if (range === 'thismonth') {
dateFrom = ref.getFullYear() + '-' + String(ref.getMonth() + 1).padStart(2, '0') + '-01';
const lastDay = new Date(ref.getFullYear(), ref.getMonth() + 1, 0);
dateTo = fmt(lastDay);
} else if (range === 'last30') {
const d = new Date(ref);
d.setDate(ref.getDate() - 30);
dateFrom = fmt(d);
dateTo = fmt(ref);
} else if (range === 'last90') {
const d = new Date(ref);
d.setDate(ref.getDate() - 90);
dateFrom = fmt(d);
dateTo = fmt(ref);
} else if (range === 'custom') {
dateFrom = document.getElementById('repDateFrom').value;
dateTo = document.getElementById('repDateTo').value;
}
return { dateFrom, dateTo };
}
function shiftDateRange(delta) {
const range = document.getElementById('repDateRange').value;
if (range === 'all' || range === 'custom') return;
dateOffset += delta;
updateDateRangeLabel();
loadReports();
}
function updateDateRangeLabel() {
const range = document.getElementById('repDateRange').value;
const label = document.getElementById('dateRangeLabel');
if (range === 'all' || range === 'custom') {
label.textContent = '';
return;
}
const { dateFrom, dateTo } = getRepDateParams();
if (dateOffset === 0) {
const select = document.getElementById('repDateRange');
const text = select.options[select.selectedIndex].text;
label.textContent = '— ' + text;
} else if (dateFrom === dateTo) {
const prefix = dateOffset < 0 ? `${-dateOffset}d ago` : `${dateOffset}d ahead`;
label.textContent = `${dateFrom} (${prefix})`;
} else {
const prefix = dateOffset < 0 ? `${-dateOffset} back` : `${dateOffset} ahead`;
label.textContent = `${dateFrom}${dateTo} (${prefix})`;
}
}
function onRepFilterChange() {
const range = document.getElementById('repDateRange').value;
const fromInput = document.getElementById('repDateFrom');
const toInput = document.getElementById('repDateTo');
// Reset offset when dropdown changes
dateOffset = 0;
if (range === 'custom') {
fromInput.style.display = '';
toInput.style.display = '';
} else {
fromInput.style.display = 'none';
toInput.style.display = 'none';
}
updateDateRangeLabel();
loadReports();
}
// ── Multi-select technician helpers ──
function toggleTechPanel(event) {
event.stopPropagation();
const panel = document.getElementById('repTechPanel');
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
}
document.addEventListener('click', function(e) {
const panel = document.getElementById('repTechPanel');
const trigger = document.getElementById('repTechTrigger');
if (panel.style.display !== 'none' && !panel.contains(e.target) && !trigger.contains(e.target)) {
panel.style.display = 'none';
}
});
function onRepTechAll(checkbox) {
const checks = document.querySelectorAll('#repTechChecks input[type="checkbox"]');
checks.forEach(cb => { cb.checked = checkbox.checked; });
updateTechLabel();
loadReports();
}
function onRepTechCheck() {
// If all individual checkboxes are checked, also check "All"
const checks = document.querySelectorAll('#repTechChecks input[type="checkbox"]');
const allCb = document.getElementById('repTechPanel').querySelector('input[value=""]');
const allChecked = Array.from(checks).every(cb => cb.checked);
if (allCb) allCb.checked = allChecked;
updateTechLabel();
loadReports();
}
function updateTechLabel() {
const allCb = document.getElementById('repTechPanel').querySelector('input[value=""]');
const label = document.getElementById('repTechLabel');
if (allCb && allCb.checked) {
label.textContent = 'All Technicians';
} else {
const checked = document.querySelectorAll('#repTechChecks input:checked');
const names = Array.from(checked).map(cb => cb.value);
label.textContent = names.length ? names.join(', ') : 'None';
}
}
async function loadReports() {
updateDateRangeLabel();
const statsEl = document.getElementById('repStats');
const otEl = document.getElementById('repOverTime');
const bsEl = document.getElementById('repByStatus');
const wtEl = document.getElementById('repByWorkType');
const btEl = document.getElementById('repByTechnician');
const ttEl = document.getElementById('repTechTableWrap');
const prEl = document.getElementById('repByPriority');
const sbtEl = document.getElementById('repStatusByTech');
// Show skeletons
statsEl.innerHTML = '<div class="rs-card"><div class="skeleton-circle"></div><div class="skeleton-bar w60" style="margin:4px auto"></div></div>'.repeat(5);
otEl.innerHTML = '<div class="spinner" style="margin:40px auto;display:block;"></div>';
bsEl.innerHTML = '<div class="spinner" style="margin:30px auto;display:block;"></div>';
wtEl.innerHTML = '<div class="spinner" style="margin:30px auto;display:block;"></div>';
btEl.innerHTML = '<div class="spinner" style="margin:30px auto;display:block;"></div>';
ttEl.innerHTML = '<div class="spinner" style="margin:30px auto;display:block;"></div>';
prEl.innerHTML = '<div class="spinner" style="margin:30px auto;display:block;"></div>';
sbtEl.innerHTML = '<div class="spinner" style="margin:30px auto;display:block;"></div>';
// Load technicians list on first visit
const techChecks = document.getElementById('repTechChecks');
if (!techChecks.dataset.loaded) {
try {
const techResp = await fetch('/api/admin/reports/workorder-kpis');
if (techResp.ok) {
const techData = await techResp.json();
const techs = (techData.by_technician || []).map(t => t.name).filter(Boolean);
techs.forEach(name => {
const label = document.createElement('label');
label.className = 'tech-opt';
label.style.cssText = 'display:flex;align-items:center;gap:8px;padding:6px 12px;cursor:pointer;font-size:13px;';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.value = name;
cb.style.accentColor = '#6c5ce7';
cb.onchange = function() { onRepTechCheck(); };
label.appendChild(cb);
const span = document.createElement('span');
span.textContent = '👤 ' + name;
label.appendChild(span);
techChecks.appendChild(label);
});
techChecks.dataset.loaded = '1';
}
} catch (e) { /* non-fatal */ }
}
try {
const params = getRepDateParams();
const query = new URLSearchParams();
if (params.dateFrom) query.set('date_from', params.dateFrom);
if (params.dateTo) query.set('date_to', params.dateTo);
const techAll = document.getElementById('repTechPanel').querySelector('input[value=""]');
let technician = '';
if (techAll && !techAll.checked) {
const checked = document.querySelectorAll('#repTechChecks input:checked');
technician = Array.from(checked).map(cb => cb.value).join(',');
}
if (technician) query.set('technician', technician);
const status = document.getElementById('repStatus').value;
if (status) query.set('status', status);
const url = '/api/admin/reports/workorder-kpis?' + query.toString();
const resp = await fetch(url);
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const data = await resp.json();
renderRepStats(statsEl, data);
renderRepOverTime(otEl, data);
renderRepDonut(bsEl, data);
renderRepBarCharts(wtEl, data.by_work_type, 'name', 'count', 'Work Type');
renderRepBarCharts(btEl, data.by_technician, 'name', 'count', 'Technician');
renderTechTable(ttEl, data);
renderRepBarCharts(prEl, data.by_priority, 'name', 'count', 'Priority');
renderTechStackedBar(sbtEl, data);
} catch (e) {
const errHtml = `<div class="empty-state" style="padding:40px;">
<div class="es-icon">⚠️</div>
<div>Failed to load reports</div>
<div class="text-sm text-muted mt-8">${esc(e.message)}</div>
</div>`;
[otEl, bsEl, wtEl, btEl, ttEl, prEl, sbtEl].forEach(el => el.innerHTML = errHtml);
statsEl.innerHTML = errHtml;
}
}
function renderRepStats(el, data) {
const s = data.summary || {};
const total = s.total || 0;
const completedPosted = s.completed_posted || 0;
const inProgress = s.in_progress || 0;
const uniqueTechs = s.unique_techs || 0;
const avgDays = s.avg_days_to_complete != null ? s.avg_days_to_complete + 'd' : '—';
const completePct = total > 0 ? Math.round(completedPosted / total * 100) + '%' : '—';
el.innerHTML = `
<div class="rs-card"><div class="rs-value">${total.toLocaleString()}</div><div class="rs-label">Total WOs</div></div>
<div class="rs-card"><div class="rs-value green">${completedPosted.toLocaleString()}</div><div class="rs-label">Completed/Posted</div></div>
<div class="rs-card"><div class="rs-value">${completePct}</div><div class="rs-label">Completion Rate</div></div>
<div class="rs-card"><div class="rs-value amber">${inProgress}</div><div class="rs-label">In Progress</div></div>
<div class="rs-card"><div class="rs-value">${uniqueTechs}</div><div class="rs-label">Active Techs</div></div>
<div class="rs-card"><div class="rs-value">${avgDays}</div><div class="rs-label">Avg Days to Complete</div></div>
`;
}
function renderRepOverTime(el, data) {
const items = data.over_time || [];
if (!items.length) {
el.innerHTML = '<div class="empty-state"><div class="es-icon">📭</div><div>No time-series data — try a different date range</div></div>';
return;
}
const mx = Math.max(...items.map(i => i.count), 1);
const padding = 20;
const w = Math.max(items.length * 30, 300);
const h = 120;
// Build SVG polyline
const points = items.map((i, idx) => {
const x = padding + (idx / Math.max(items.length - 1, 1)) * (w - padding * 2);
const y = h - ((i.count / mx) * (h - padding));
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
// X-axis labels
const labelStep = Math.max(1, Math.floor(items.length / 8));
const labels = items.map((i, idx) => {
if (idx % labelStep !== 0 && idx !== items.length - 1) return '';
const x = padding + (idx / Math.max(items.length - 1, 1)) * (w - padding * 2);
return `<text x="${x.toFixed(1)}" y="${h - 4}" text-anchor="middle" fill="#5b5f73" font-size="9">${i.day.slice(5)}</text>`;
}).filter(Boolean).join('');
el.innerHTML = `<svg viewBox="0 0 ${w} ${h}" style="width:100%;height:100%;">
<polyline points="${points}" fill="none" stroke="#5b6ef7" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
<polygon points="${points} ${padding},${h} ${w - padding},${h}" fill="rgba(91,110,247,0.08)" stroke="none"/>
${labels}
</svg>`;
}
function renderRepDonut(el, data) {
const items = data.by_status || [];
if (!items.length) {
el.innerHTML = '<div class="empty-state"><div class="es-icon">📭</div><div>No status data</div></div>';
return;
}
const total = items.reduce((s, i) => s + i.count, 0);
const colors = items.map(i => STATUS_COLORS[i.code] || '#5b5f73');
let cumulative = 0;
const stops = items.map((item, idx) => {
const pct = (item.count / total) * 100;
const start = cumulative.toFixed(1);
cumulative += pct;
const end = Math.min(cumulative, 100).toFixed(1);
return `${colors[idx]} ${start}% ${end}%`;
}).join(', ');
const gradient = `conic-gradient(${stops})`;
el.innerHTML = `<div class="donut-wrapper">
<div class="donut-ring" style="background:${gradient};">
<div class="donut-hole">
<span class="dh-value">${total.toLocaleString()}</span>
<span class="dh-label">Total</span>
</div>
</div>
<div class="donut-legend">
${items.map(i => `<div class="dl-item">
<span class="dl-dot" style="background:${STATUS_COLORS[i.code] || '#5b5f73'}"></span>
<span class="dl-label">${esc(i.label)}</span>
<span class="dl-count">${i.count.toLocaleString()}</span>
</div>`).join('')}
</div>
</div>`;
}
function renderRepBarCharts(el, items, labelKey, valueKey, title) {
if (!items || !items.length) {
el.innerHTML = '<div class="empty-state"><div class="es-icon">📭</div><div>No data available</div></div>';
return;
}
const mx = Math.max(...items.map(i => i[valueKey]), 1);
const top = items.slice(0, 15);
el.innerHTML = top.map(i => {
const pct = (i[valueKey] / mx * 100);
const barColor = title === 'Technician' ? 'purple' :
title === 'Priority' ? 'orange' : 'accent';
return `<div class="stat-bar sb-bar-sm">
<span class="sb-label" title="${esc(i[labelKey])}">${esc(i[labelKey] || '')}</span>
<span class="sb-track"><span class="sb-fill ${barColor}" style="width:${pct}%"></span></span>
<span class="sb-count">${i[valueKey]}</span>
</div>`;
}).join('');
if (items.length > 15) {
el.innerHTML += `<div class="text-xs text-muted" style="text-align:center;padding-top:4px;">+${items.length - 15} more...</div>`;
}
}
function renderTechTable(el, data) {
const techs = data.technician_performance || [];
if (!techs.length) {
el.innerHTML = '<div class="empty-state"><div class="es-icon">📭</div><div>No technician data</div></div>';
return;
}
const maxTotal = Math.max(...techs.map(t => t.total), 1);
el.innerHTML = `<div class="table-wrap">
<table class="tech-table">
<thead>
<tr>
<th>Technician</th>
<th>Total</th>
<th>Completed</th>
<th>In Progress</th>
<th>Scheduled</th>
<th>Completion %</th>
</tr>
</thead>
<tbody>
${techs.map(t => {
const pct = t.total > 0 ? Math.round(t.completed / t.total * 100) : 0;
const barW = (t.total / maxTotal * 100);
return `<tr>
<td class="tt-name">${esc(t.name)}</td>
<td><span class="tt-bar" style="width:${barW}%;background:var(--accent);"></span> ${t.total}</td>
<td style="color:var(--green);font-weight:600;">${t.completed}</td>
<td style="color:#22d3ee;">${t.in_progress}</td>
<td style="color:var(--amber);">${t.scheduled}</td>
<td style="font-weight:600;">${pct}%</td>
</tr>`;
}).join('')}
</tbody>
</table>
</div>`;
}
function renderTechStackedBar(el, data) {
const statusByTech = data.status_by_tech || {};
const techNames = Object.keys(statusByTech);
if (!techNames.length) {
el.innerHTML = '<div class="empty-state"><div class="es-icon">📭</div><div>No status-by-tech data</div></div>';
return;
}
const allCodes = new Set();
const totals = {};
techNames.forEach(tech => {
const s = statusByTech[tech];
Object.keys(s).forEach(code => allCodes.add(code));
totals[tech] = Object.values(s).reduce((a, b) => a + b, 0);
});
const sorted = techNames.sort((a, b) => (totals[b] || 0) - (totals[a] || 0));
const maxTotal = Math.max(...Object.values(totals), 1);
const codes = Array.from(allCodes).sort((a, b) => (STATUS_LABELS[a] || a).localeCompare(STATUS_LABELS[b] || b));
el.innerHTML = `<div class="stack-legend">
${codes.map(c => `<span class="sl-item"><span class="sl-dot" style="background:${STATUS_COLORS[c] || '#5b5f73'}"></span>${STATUS_LABELS[c] || c}</span>`).join('')}
</div>
<div style="max-height:400px;overflow-y:auto;">
${sorted.map(tech => {
const techData = statusByTech[tech];
const techTotal = totals[tech] || 0;
let segHtml = '';
codes.forEach(c => {
const cnt = techData[c] || 0;
if (cnt > 0) {
segHtml += `<span class="st-seg" style="width:${(cnt / maxTotal * 100).toFixed(1)}%;background:${STATUS_COLORS[c] || '#5b5f73'}"></span>`;
}
});
return `<div class="stack-bar">
<span class="st-label" title="${esc(tech)}">${esc(tech)}</span>
<span class="st-track">${segHtml}</span>
<span class="st-count">${techTotal}</span>
</div>`;
}).join('')}
</div>`;
}
// ═════════════════════════════════════════════════════════════════════════════
// WORK ORDERS PAGE
// ═════════════════════════════════════════════════════════════════════════════
let woSearchTimeout = null;
let woState = { offset: 0, limit: 50, total: 0 };
const WO_STATUS_LABELS = {
'690970000': 'Unscheduled', '690970001': 'Scheduled',
'690970002': 'In Progress', '690970003': 'Completed',
'690970004': 'Posted', '690970005': 'Cancelled',
'690959000': 'Postponed',
};
const WO_STATUS_COLORS = {
'690970000': '#8b8fa3', '690970001': '#5b6ef7',
'690970002': '#fbbf24', '690970003': '#4ade80',
'690970004': '#22d3ee', '690970005': '#f87171',
'690959000': '#fb923c',
};
function scheduleWOSearch() {
clearTimeout(woSearchTimeout);
woSearchTimeout = setTimeout(() => { woState.offset = 0; loadWorkOrders(); }, 300);
}
function onWODateRangeChange() {
const range = document.getElementById('woDateRange').value;
document.getElementById('woDateFrom').style.display = range === 'custom' ? '' : 'none';
document.getElementById('woDateTo').style.display = range === 'custom' ? '' : 'none';
woState.offset = 0;
loadWorkOrders();
}
function getWODateParams() {
const range = document.getElementById('woDateRange').value;
const today = new Date();
const yyyy = d => d.toISOString().slice(0, 10);
let from = '', to = '';
if (range === 'today') { from = yyyy(today); to = yyyy(today); }
else if (range === 'thisweek') {
const start = new Date(today); start.setDate(today.getDate() - today.getDay());
from = yyyy(start); to = yyyy(today);
}
else if (range === 'thismonth') {
from = yyyy(new Date(today.getFullYear(), today.getMonth(), 1)); to = yyyy(today);
}
else if (range === 'custom') {
from = document.getElementById('woDateFrom').value;
to = document.getElementById('woDateTo').value;
}
return { from, to };
}
function renderActiveWOTags() {
const q = document.getElementById('woSearch').value.trim();
const status = document.getElementById('woStatusFilter').value;
const tech = document.getElementById('woTechFilter').value;
const range = document.getElementById('woDateRange').value;
const el = document.getElementById('woActiveTags');
const tags = [];
if (q) tags.push(`<span class="status-tag active" style="cursor:pointer" onclick="document.getElementById('woSearch').value='';scheduleWOSearch()">🔍 "${esc(q)}" ✕</span>`);
if (status) tags.push(`<span class="status-tag active" style="cursor:pointer;background:${WO_STATUS_COLORS[status] || 'var(--accent-bg)'};color:#000;" onclick="document.getElementById('woStatusFilter').value='';loadWorkOrders()">📋 ${WO_STATUS_LABELS[status] || status} ✕</span>`);
if (tech) tags.push(`<span class="status-tag active" style="cursor:pointer" onclick="document.getElementById('woTechFilter').value='';loadWorkOrders()">👤 ${esc(tech)} ✕</span>`);
if (range !== 'all') tags.push(`<span class="status-tag active" style="cursor:pointer" onclick="document.getElementById('woDateRange').value='all';onWODateRangeChange()">📅 ${range} ✕</span>`);
el.innerHTML = tags.join(' ');
}
async function loadWorkOrders() {
const q = document.getElementById('woSearch').value.trim();
const status = document.getElementById('woStatusFilter').value;
const tech = document.getElementById('woTechFilter').value;
const dates = getWODateParams();
renderActiveWOTags();
const params = new URLSearchParams({
q, status, tech,
date_from: dates.from, date_to: dates.to,
limit: woState.limit, offset: woState.offset,
});
try {
const data = await api('/api/admin/workorders/list?' + params.toString());
woState.total = data.total;
document.getElementById('woTotalCount').textContent = `${data.total} work orders found`;
renderWOCards(data.results || []);
renderWOPagination();
// Load tech filter dropdown if not loaded
const techSelect = document.getElementById('woTechFilter');
if (techSelect.options.length <= 1) {
try {
const tdata = await api('/api/admin/workorders/technicians');
(tdata.technicians || []).forEach(t => {
const opt = document.createElement('option');
opt.value = t; opt.textContent = t;
techSelect.appendChild(opt);
});
} catch(e) {}
}
} catch (e) {
document.getElementById('woCards').innerHTML =
`<div class="card" style="text-align:center;padding:40px;color:var(--red);">❌ ${esc(e.message)}</div>`;
}
}
function renderWOCards(orders) {
const el = document.getElementById('woCards');
if (!orders.length) {
el.innerHTML = '<div class="card" style="text-align:center;padding:40px;color:var(--text2);">🔧 No work orders found matching your filters.</div>';
return;
}
el.innerHTML = orders.map(wo => {
const statusColor = WO_STATUS_COLORS[wo.status_code] || '#5b5f73';
const techs = (wo.technicians || '').split(',').filter(Boolean);
return `<div class="card" style="padding:12px 14px;cursor:pointer;" onclick="showWODetail('${esc(wo.name)}')">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px;">
<div style="font-weight:600;font-size:14px;">${esc(wo.name)}</div>
<span class="status-tag" style="background:${statusColor}22;color:${statusColor};border:1px solid ${statusColor}44;">${esc(wo.status)}</span>
</div>
<div style="display:flex;gap:12px;margin-top:6px;font-size:12px;color:var(--text2);flex-wrap:wrap;">
${wo.account_display_name ? `<span>🏢 ${esc(wo.account_display_name)}</span>` : ''}
${wo.city ? `<span>📍 ${esc(wo.city)}${wo.state ? ', ' + esc(wo.state) : ''}</span>` : ''}
${wo.work_type ? `<span>🔧 ${esc(wo.work_type)}</span>` : ''}
${wo.priority ? `<span>⚡ ${esc(wo.priority)}</span>` : ''}
</div>
<div style="display:flex;gap:8px;margin-top:6px;font-size:11px;color:var(--text3);align-items:center;flex-wrap:wrap;">
${techs.length ? `<span>👤 ${techs.join(', ')}</span>` : ''}
${wo.created_on ? `<span>📅 Created: ${wo.created_on.slice(0,10)}</span>` : ''}
</div>
</div>`;
}).join('');
}
function renderWOPagination() {
const el = document.getElementById('woPagination');
const total = woState.total;
const limit = woState.limit;
const current = woState.offset;
const pages = Math.ceil(total / limit);
if (pages <= 1) { el.innerHTML = ''; return; }
const html = [];
if (current > 0) html.push(`<button class="btn btn-sm btn-outline" onclick="woState.offset=0;loadWorkOrders()">⏮</button>`);
if (current > 0) html.push(`<button class="btn btn-sm btn-outline" onclick="woState.offset=${Math.max(0,current-limit)};loadWorkOrders()">◀</button>`);
html.push(`<span style="font-size:13px;color:var(--text2);padding:0 8px;">${current/limit+1}/${pages}</span>`);
if (current + limit < total) html.push(`<button class="btn btn-sm btn-outline" onclick="woState.offset=${current+limit};loadWorkOrders()">▶</button>`);
if (current + limit < total) html.push(`<button class="btn btn-sm btn-outline" onclick="woState.offset=${(pages-1)*limit};loadWorkOrders()">⏭</button>`);
el.innerHTML = html.join('');
}
async function showWODetail(name) {
try {
const d = await api(`/api/admin/workorders/lookup?name=${encodeURIComponent(name)}`);
if (!d.found) { showToast('Work order not found', true); return; }
renderWODetailModal(d.data);
} catch (e) {
showToast(e.message, true);
}
}
function renderWODetailModal(data) {
const statusColor = WO_STATUS_COLORS[data.msdyn_systemstatus] || '#5b5f73';
const techs = (data.technicians || '').split(',').filter(Boolean);
const body = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<div style="font-size:16px;font-weight:700;">${esc(data.msdyn_name)}</div>
<span class="status-tag" style="background:${statusColor}22;color:${statusColor};border:1px solid ${statusColor}44;">${esc(data.status_label)}</span>
</div>
<table style="width:100%;font-size:13px;">
${data['msdyn_serviceaccount!name'] ? `<tr><td style="color:var(--text2);padding:3px 8px 3px 0;white-space:nowrap;vertical-align:top;">🏢 Account</td><td style="padding:3px 0;">${esc(data['msdyn_serviceaccount!name'])}</td></tr>` : ''}
${data.account_display_name && data.account_display_name !== data['msdyn_serviceaccount!name'] ? `<tr><td style="color:var(--text2);padding:3px 8px 3px 0;white-space:nowrap;vertical-align:top;">🏢 Display</td><td style="padding:3px 0;">${esc(data.account_display_name)}</td></tr>` : ''}
${techs.length ? `<tr><td style="color:var(--text2);padding:3px 8px 3px 0;white-space:nowrap;vertical-align:top;">👤 Technicians</td><td style="padding:3px 0;">${esc(techs.join(', '))}</td></tr>` : ''}
${data['msdyn_priority!name'] ? `<tr><td style="color:var(--text2);padding:3px 8px 3px 0;white-space:nowrap;vertical-align:top;">⚡ Priority</td><td style="padding:3px 0;">${esc(data['msdyn_priority!name'])}</td></tr>` : ''}
${data['msdyn_workordertype!name'] ? `<tr><td style="color:var(--text2);padding:3px 8px 3px 0;white-space:nowrap;vertical-align:top;">🔧 Work Type</td><td style="padding:3px 0;">${esc(data['msdyn_workordertype!name'])}</td></tr>` : ''}
${data['msdyn_primaryincidenttype!name'] ? `<tr><td style="color:var(--text2);padding:3px 8px 3px 0;white-space:nowrap;vertical-align:top;">🛠 Incident Type</td><td style="padding:3px 0;">${esc(data['msdyn_primaryincidenttype!name'])}</td></tr>` : ''}
${data['msdyn_serviceterritory!name'] ? `<tr><td style="color:var(--text2);padding:3px 8px 3px 0;white-space:nowrap;vertical-align:top;">📍 Territory</td><td style="padding:3px 0;">${esc(data['msdyn_serviceterritory!name'])}</td></tr>` : ''}
${data.msdyn_city ? `<tr><td style="color:var(--text2);padding:3px 8px 3px 0;white-space:nowrap;vertical-align:top;">🏙 City</td><td style="padding:3px 0;">${esc(data.msdyn_city)}${data.msdyn_stateorprovince ? ', ' + esc(data.msdyn_stateorprovince) : ''}</td></tr>` : ''}
${data.createdon ? `<tr><td style="color:var(--text2);padding:3px 8px 3px 0;white-space:nowrap;vertical-align:top;">📅 Created</td><td style="padding:3px 0;">${esc(data.createdon)}</td></tr>` : ''}
${data.msdyn_completedon ? `<tr><td style="color:var(--text2);padding:3px 8px 3px 0;white-space:nowrap;vertical-align:top;">✅ Completed</td><td style="padding:3px 0;">${esc(data.msdyn_completedon)}</td></tr>` : ''}
${data['msdyn_customerasset!name'] ? `<tr><td style="color:var(--text2);padding:3px 8px 3px 0;white-space:nowrap;vertical-align:top;">🏭 Asset</td><td style="padding:3px 0;"><a href="#" onclick="showAssetDetail('${esc(data['msdyn_customerasset!name'])}');closeModal();return false;" style="color:var(--accent2);text-decoration:underline;">${esc(data['msdyn_customerasset!name'])}</a></td></tr>` : ''}
${data.msdyn_workordersummary ? `<tr><td style="color:var(--text2);padding:3px 8px 3px 0;white-space:nowrap;vertical-align:top;">📝 Summary</td><td style="padding:3px 0;">${esc(data.msdyn_workordersummary)}</td></tr>` : ''}
${data.msdyn_instructions ? `<tr><td style="color:var(--text2);padding:3px 8px 3px 0;white-space:nowrap;vertical-align:top;">📋 Instructions</td><td style="padding:3px 0;white-space:pre-wrap;">${esc(data.msdyn_instructions)}</td></tr>` : ''}
${data.address1_line1 ? `<tr><td style="color:var(--text2);padding:3px 8px 3px 0;white-space:nowrap;vertical-align:top;">📍 Address</td><td style="padding:3px 0;">${esc(data.address1_line1)}${data.address1_city ? ', ' + esc(data.address1_city) : ''}${data.address1_stateorprovince ? ', ' + esc(data.address1_stateorprovince) : ''}</td></tr>` : ''}
${data.address1_postalcode ? `<tr><td style="color:var(--text2);padding:3px 8px 3px 0;white-space:nowrap;vertical-align:top;">📮 ZIP</td><td style="padding:3px 0;">${esc(data.address1_postalcode)}</td></tr>` : ''}
${data.telephone1 ? `<tr><td style="color:var(--text2);padding:3px 8px 3px 0;white-space:nowrap;vertical-align:top;">📞 Phone</td><td style="padding:3px 0;">${esc(data.telephone1)}</td></tr>` : ''}
</table>
`;
showModal(data.msdyn_name, body, 'Close', 'btn-outline');
}
// ═════════════════════════════════════════════════════════════════════════════
// CLEANUP PAGE
// ═════════════════════════════════════════════════════════════════════════════
let cleanupPage = 0;
const CLEANUP_PAGE_SIZE = 50;
function generateAssetName(make, model, company, building, floor, place) {
let parts = [];
const m = (make || '').trim();
const mo = (model || '').trim();
const c = (company || '').trim();
const b = (building || '').trim();
const f = (floor || '').trim();
const p = (place || '').trim();
if (m && mo) parts.push(m + ' ' + mo);
else if (m) parts.push(m);
else if (mo) parts.push(mo);
if (c) parts.push('@ ' + c);
let location = [];
if (b) location.push(b);
if (f) location.push(f);
if (location.length) parts.push('- ' + location.join(' '));
if (p) parts.push('- ' + p);
return parts.join(' ') || '(enter fields to generate name)';
}
function updateNamePreview() {
const make = document.getElementById('cuBulkMake')?.value || '';
const model = document.getElementById('cuBulkModel')?.value || '';
const company = document.getElementById('cuBulkCompany')?.value || '';
const building = document.getElementById('cuBulkBuilding')?.value || '';
const floor = document.getElementById('cuBulkFloor')?.value || '';
const place = document.getElementById('cuBulkPlace')?.value || '';
const preview = generateAssetName(make, model, company, building, floor, place);
document.getElementById('cuNamePreview').textContent = '📝 ' + preview;
}
// Wire up bulk edit inputs for live name preview
document.addEventListener('input', function(e) {
const ids = ['cuBulkMake','cuBulkModel','cuBulkCompany','cuBulkBuilding','cuBulkFloor','cuBulkPlace'];
if (ids.includes(e.target.id)) updateNamePreview();
});
async function loadCleanupFilterOptions() {
try {
const [companies, places, makes] = await Promise.all([
api('/api/assets?limit=1&company=x').then(() => fetch('/api/export/assets').then(r => r.text())).catch(() => null),
// Use distinct-query endpoints via the list API with aggregation
api('/api/assets?limit=1').then(() => {}).catch(() => {}),
]);
// Fetch distinct values via the list API — use limit=1 trick to just get distinct meta
const allData = await api('/api/assets?limit=5000&offset=0');
const assets = allData.assets || [];
const companySet = new Set();
const placeSet = new Set();
const makeSet = new Set();
assets.forEach(a => {
if (a.company) companySet.add(a.company);
if (a.place) placeSet.add(a.place);
if (a.make) makeSet.add(a.make);
});
function populateSelect(id, values) {
const sel = document.getElementById(id);
if (!sel) return;
const current = sel.value;
sel.innerHTML = '<option value="">All ' + (id === 'cuFilterCompany' ? 'Companies' : id === 'cuFilterPlace' ? 'Places' : 'Makes') + '</option>';
[...values].sort().forEach(v => {
const opt = document.createElement('option');
opt.value = v;
opt.textContent = v;
sel.appendChild(opt);
});
sel.value = current;
}
populateSelect('cuFilterCompany', companySet);
populateSelect('cuFilterPlace', placeSet);
populateSelect('cuFilterMake', makeSet);
} catch (e) {
console.warn('Failed to load cleanup filters:', e);
}
}
async function loadCleanup() {
const company = document.getElementById('cuFilterCompany')?.value || '';
const place = document.getElementById('cuFilterPlace')?.value || '';
const make = document.getElementById('cuFilterMake')?.value || '';
const params = new URLSearchParams();
params.set('limit', CLEANUP_PAGE_SIZE);
params.set('offset', cleanupPage * CLEANUP_PAGE_SIZE);
if (company) params.set('company', company);
if (place) params.set('place', place);
if (make) params.set('make', make);
const listEl = document.getElementById('cuResultsList');
const countEl = document.getElementById('cuCount');
const paginationEl = document.getElementById('cuPagination');
try {
listEl.innerHTML = '<div class="loading-row"><div class="spinner"></div> Loading...</div>';
const data = await api('/api/assets?' + params.toString());
const assets = data.assets || [];
const total = data.total || 0;
countEl.textContent = total + ' asset' + (total !== 1 ? 's' : '');
// Show/hide bulk bar
const bulkBar = document.getElementById('cuBulkBar');
if (assets.length > 0) {
bulkBar.style.display = 'block';
document.getElementById('cuBulkCount').textContent = '(' + total + ' matching)';
document.getElementById('cuBulkApplyCount').textContent = total;
// Pre-fill bulk fields from first asset's common values
if (assets.length > 1) {
const commonMake = assets.every(a => a.make === assets[0].make) ? assets[0].make : '';
const commonCompany = assets.every(a => a.company === assets[0].company) ? assets[0].company : '';
const commonPlace = assets.every(a => a.place === assets[0].place) ? assets[0].place : '';
['cuBulkMake','cuBulkModel','cuBulkCompany','cuBulkPlace','cuBulkBuilding','cuBulkFloor','cuBulkRoom','cuBulkLocation'].forEach(id => {
const el = document.getElementById(id);
if (el) {
if (!el.dataset.userSet) el.value = '';
}
});
}
} else {
bulkBar.style.display = 'none';
}
// Render list
if (assets.length === 0) {
listEl.innerHTML = '<div style="font-size:13px;color:var(--text2);padding:20px 0;text-align:center;">No assets match these filters.</div>';
paginationEl.innerHTML = '';
return;
}
let html = '<div style="font-size:11px;color:var(--text3);margin-bottom:6px;display:flex;justify-content:space-between;">' +
'<span>Showing ' + (data.offset + 1) + '' + Math.min(data.offset + assets.length, total) + ' of ' + total + '</span>' +
'<span>Click any value to edit inline</span></div>';
assets.forEach(a => {
const catIcon = categoryIcon(a.category);
html += '<div class="cu-asset-row" data-id="' + a.id + '">' +
'<input type="checkbox" class="cu-row-check" data-id="' + a.id + '" style="margin-right:6px;flex-shrink:0;" onchange="updateBulkSelection()">' +
'<div class="cu-ar-id">' + a.machine_id + '</div>' +
'<div class="cu-ar-name" title="' + esc(a.name) + '">' + catIcon + esc(a.name || '').slice(0, 40) + '</div>' +
'<div class="cu-ar-make"><input class="cu-inline-edit" value="' + esc(a.make || '') + '" placeholder="Make" onchange="cellEdit(' + a.id + ',\'make\',this.value)"></div>' +
'<div class="cu-ar-model"><input class="cu-inline-edit" value="' + esc(a.model || '') + '" placeholder="Model" onchange="cellEdit(' + a.id + ',\'model\',this.value)"></div>' +
'<div class="cu-ar-building"><input class="cu-inline-edit" value="' + esc(a.building_name || '') + '" placeholder="Building" onchange="cellEdit(' + a.id + ',\'building_name\',this.value)"></div>' +
'<div class="cu-ar-floor"><input class="cu-inline-edit" value="' + esc(a.floor || '') + '" placeholder="Floor" onchange="cellEdit(' + a.id + ',\'floor\',this.value)"></div>' +
'<div class="cu-ar-company" title="' + esc(a.company || '') + '">' + esc(a.company || '').slice(0, 30) + '</div>' +
'<div class="cu-ar-place">' + esc(a.place || '') + '</div>' +
'</div>';
});
listEl.innerHTML = html;
// Pagination
const totalPages = Math.ceil(total / CLEANUP_PAGE_SIZE);
let pagHtml = '';
if (cleanupPage > 0) pagHtml += '<button class="btn btn-sm btn-outline" onclick="cleanupPage--;loadCleanup()">◀ Prev</button>';
pagHtml += '<span style="font-size:12px;color:var(--text2);padding:0 8px;">Page ' + (cleanupPage + 1) + ' of ' + totalPages + '</span>';
if (cleanupPage < totalPages - 1) pagHtml += '<button class="btn btn-sm btn-outline" onclick="cleanupPage++;loadCleanup()">Next ▶</button>';
paginationEl.innerHTML = pagHtml;
} catch (e) {
listEl.innerHTML = '<div style="color:var(--red);">Error: ' + esc(e.message) + '</div>';
}
}
function updateBulkSelection() {
// Currently all loaded assets are included in batch (we use the filter params, not checkboxes)
}
async function cellEdit(assetId, field, value) {
try {
const body = {};
body[field] = value;
await api('/api/assets/' + assetId, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
} catch (e) {
showToast('Save failed: ' + e.message, true);
}
}
async function applyBulkEdit() {
const company = document.getElementById('cuFilterCompany')?.value || '';
const place = document.getElementById('cuFilterPlace')?.value || '';
const make = document.getElementById('cuFilterMake')?.value || '';
// Build fields object from non-empty bulk edit inputs
const fields = {};
const fieldMap = {
cuBulkMake: 'make', cuBulkModel: 'model', cuBulkBuilding: 'building_name',
cuBulkFloor: 'floor', cuBulkRoom: 'room', cuBulkCompany: 'company',
cuBulkPlace: 'place', cuBulkLocation: 'location_area',
};
Object.entries(fieldMap).forEach(([elId, dbField]) => {
const val = document.getElementById(elId)?.value?.trim() || '';
if (val) fields[dbField] = val;
});
if (Object.keys(fields).length === 0) {
showToast('Fill in at least one field to bulk-update', true);
return;
}
// Build name from the pattern if make/model are present
const bulkMake = fields.make || '';
const bulkModel = fields.model || '';
const bulkCompany = fields.company || '';
const bulkBuilding = fields.building_name || '';
const bulkFloor = fields.floor || '';
const bulkPlace = fields.place || '';
const newName = generateAssetName(bulkMake, bulkModel, bulkCompany, bulkBuilding, bulkFloor, bulkPlace);
if (newName && !newName.startsWith('(enter')) {
fields.name = newName;
}
// Get total count for this filter
const params = new URLSearchParams();
params.set('limit', 1);
if (company) params.set('company', company);
if (place) params.set('place', place);
if (make) params.set('make', make);
try {
const info = await api('/api/assets?' + params.toString());
const total = info.total || 0;
if (total === 0) {
showToast('No assets match current filters', true);
return;
}
const confirmed = await showModal('Apply Bulk Edit',
'Update <strong>' + total + '</strong> assets with:<br><br>' +
Object.entries(fields).map(([k, v]) => '<strong>' + k + '</strong>: ' + esc(v)).join('<br>') +
'<br><br>This will also update the <strong>name</strong> to: <code>' + esc(fields.name || '(unchanged)') + '</code>',
'Apply to All', 'btn-accent');
if (!confirmed) return;
// Fetch ALL asset IDs matching this filter
const allData = await api('/api/assets?' + params.toString().replace('limit=1', 'limit=5000'));
const ids = (allData.assets || []).map(a => a.id);
if (ids.length === 0) {
showToast('No asset IDs to update', true);
return;
}
// Batch update in chunks
const CHUNK = 500;
let updated = 0;
for (let i = 0; i < ids.length; i += CHUNK) {
const chunk = ids.slice(i, i + CHUNK);
await api('/api/assets/batch-update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: chunk, fields }),
});
updated += chunk.length;
}
showToast('✅ Updated ' + updated + ' assets', false);
loadCleanup();
} catch (e) {
showToast('❌ Bulk update failed: ' + e.message, true);
}
}
function clearBulkEdit() {
['cuBulkMake','cuBulkModel','cuBulkCompany','cuBulkBuilding','cuBulkFloor','cuBulkRoom','cuBulkPlace','cuBulkLocation'].forEach(id => {
const el = document.getElementById(id);
if (el) { el.value = ''; el.dataset.userSet = '0'; }
});
updateNamePreview();
}
// ── Helper: category icon ──
function categoryIcon(cat) {
const icons = { 'snack': '🍿', 'bev': '🥤', 'food': '🍔', 'coffee': '☕', 'ice': '🧊', 'other': '📦', 'equipment': '🔧', 'appliances': '🔌' };
return icons[(cat || '').toLowerCase()] || '📦';
}
// ═════════════════════════════════════════════════════════════════════════════
// MSFS ADB SYNC PAGE
// ═════════════════════════════════════════════════════════════════════════════
let syncPollInterval = null;
let syncTaskId = null;
async function loadMsfsSync() {
document.getElementById('msfsConnStatus').innerHTML =
'<div class="loading-row"><div class="spinner"></div> Checking ADB connection...</div>';
// Reset progress display
document.getElementById('msfsProgressCard').style.display = 'none';
document.getElementById('msfsProgressResult').style.display = 'none';
try {
const info = await api('/api/admin/msfs-sync/info');
renderMsfsConnStatus(info);
renderMsfsHistory(info);
} catch (e) {
document.getElementById('msfsConnStatus').innerHTML =
`<div style="color:var(--red);font-size:13px;">❌ ${esc(e.message)}</div>`;
}
}
function fmtDate(iso) {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return d.toLocaleDateString('en-US', {
month: 'short', day: 'numeric', year: 'numeric',
hour: 'numeric', minute: '2-digit'
});
}
function renderMsfsConnStatus(info) {
const el = document.getElementById('msfsConnStatus');
const adbOk = info.adb_connected;
const dbOk = info.db_file_exists;
const extOk = info.extraction_server !== false;
el.innerHTML = `
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px;">
<div style="padding:10px;border-radius:8px;background:var(--card2);text-align:center;">
<div style="font-size:28px;">${extOk ? (adbOk ? '✅' : '⚠️') : '❌'}</div>
<div style="font-size:11px;color:var(--text2);margin-top:4px;">ADB Device</div>
<div style="font-size:13px;font-weight:600;">${adbOk ? 'Connected' : (extOk ? 'Not connected' : 'Server offline')}</div>
</div>
<div style="padding:10px;border-radius:8px;background:var(--card2);text-align:center;">
<div style="font-size:28px;">${info.app_version ? '📱' : '❓'}</div>
<div style="font-size:11px;color:var(--text2);margin-top:4px;">App Version</div>
<div style="font-size:13px;font-weight:600;">${info.app_version || 'Unknown'}</div>
</div>
<div style="padding:10px;border-radius:8px;background:var(--card2);text-align:center;">
<div style="font-size:28px;">${dbOk ? '✅' : '❌'}</div>
<div style="font-size:11px;color:var(--text2);margin-top:4px;">Extraction DB</div>
<div style="font-size:13px;font-weight:600;">${dbOk ? info.db_file_size_gb + ' GB' : 'Missing'}</div>
</div>
<div style="padding:10px;border-radius:8px;background:var(--card2);text-align:center;">
<div style="font-size:28px;">📊</div>
<div style="font-size:11px;color:var(--text2);margin-top:4px;">Work Orders</div>
<div style="font-size:13px;font-weight:600;">${info.work_order_count || '?'}</div>
</div>
</div>
<div style="margin-top:8px;font-size:12px;color:var(--text3);">
${adbOk ? `<span>📱 Device: ${info.device_serial || 'connected'}</span>` : ''}
${info.last_pull_iso ? ` | 🕐 Last pull: ${fmtDate(info.last_pull_iso)}` : ''}
${info.db_file_modified ? ` | 🗃 DB modified: ${fmtDate(info.db_file_modified)}` : ''}
</div>
`;
}
function renderMsfsHistory(info) {
const el = document.getElementById('msfsHistory');
if (info.last_pull_iso) {
el.innerHTML = `<div style="font-size:13px;color:var(--text2);">
🕐 Last successful pull: ${fmtDate(info.last_pull_iso)}
${info.db_file_modified ? `<br>🗃 DB last modified: ${fmtDate(info.db_file_modified)}` : ''}
${info.db_file_size_gb ? `<br>💾 DB size: ${info.db_file_size_gb} GB` : ''}
</div>`;
} else {
el.innerHTML = '<div style="font-size:13px;color:var(--text2);">No syncs recorded yet.</div>';
}
}
async function startMsfsSync() {
document.getElementById('msfsSyncBtn').disabled = true;
document.getElementById('msfsProgressCard').style.display = '';
document.getElementById('msfsProgressResult').style.display = 'none';
document.getElementById('msfsProgressMsg').textContent = 'Starting sync...';
document.getElementById('msfsCancelBtn').style.display = '';
try {
const result = await api('/api/admin/msfs-sync/start', { method: 'POST' });
syncTaskId = result.task_id;
document.getElementById('msfsProgressMsg').textContent = result.message || 'Sync started';
// Start polling
if (syncPollInterval) clearInterval(syncPollInterval);
syncPollInterval = setInterval(pollMsfsTask, 3000);
} catch (e) {
document.getElementById('msfsProgressMsg').textContent = `${esc(e.message)}`;
document.getElementById('msfsSyncBtn').disabled = false;
document.getElementById('msfsCancelBtn').style.display = 'none';
}
}
async function pollMsfsTask() {
if (!syncTaskId) return;
try {
const task = await api(`/api/admin/msfs-sync/task/${syncTaskId}`);
document.getElementById('msfsProgressMsg').textContent =
`${task.phase || task.status}: ${task.message || ''}`;
if (task.status === 'completed') {
clearInterval(syncPollInterval);
syncPollInterval = null;
syncTaskId = null;
document.getElementById('msfsSyncBtn').disabled = false;
document.getElementById('msfsCancelBtn').style.display = 'none';
document.getElementById('msfsProgressResult').style.display = '';
document.getElementById('msfsProgressResult').innerHTML =
'<div style="padding:10px;background:var(--green-bg);border-radius:8px;color:var(--green);">✅ Sync completed successfully!</div>';
// Refresh status
setTimeout(loadMsfsSync, 2000);
} else if (task.status === 'failed') {
clearInterval(syncPollInterval);
syncPollInterval = null;
syncTaskId = null;
document.getElementById('msfsSyncBtn').disabled = false;
document.getElementById('msfsCancelBtn').style.display = 'none';
document.getElementById('msfsProgressResult').style.display = '';
document.getElementById('msfsProgressResult').innerHTML =
`<div style="padding:10px;background:var(--red-bg);border-radius:8px;color:var(--red);">❌ Sync failed: ${esc(task.message || 'Unknown error')}</div>`;
} else if (task.status === 'cancelled') {
clearInterval(syncPollInterval);
syncPollInterval = null;
syncTaskId = null;
document.getElementById('msfsSyncBtn').disabled = false;
document.getElementById('msfsCancelBtn').style.display = 'none';
}
} catch (e) {
// Keep polling
}
}
async function launchMsfsApp() {
try {
const result = await api('/api/admin/msfs-sync/launch-app', { method: 'POST' });
showToast('📱 App launched — navigating to Check for Updates...');
const taskId = result.task_id;
if (!taskId) {
showToast('⚠️ No task_id returned — check extraction server', true);
return;
}
syncTaskId = taskId;
document.getElementById('msfsLaunchBtn').disabled = true;
document.getElementById('msfsProgressCard').style.display = '';
document.getElementById('msfsProgressResult').style.display = 'none';
document.getElementById('msfsProgressMsg').textContent = 'Launching app...';
document.getElementById('msfsCancelBtn').style.display = '';
syncPollInterval = setInterval(async () => {
try {
const task = await api(`/api/admin/msfs-sync/task/${taskId}`);
document.getElementById('msfsProgressMsg').textContent = task.message || 'Working...';
if (task.status === 'completed') {
clearInterval(syncPollInterval);
syncPollInterval = null;
document.getElementById('msfsLaunchBtn').disabled = false;
document.getElementById('msfsCancelBtn').style.display = 'none';
document.getElementById('msfsProgressResult').style.display = '';
document.getElementById('msfsProgressResult').innerHTML =
`<div style="color:var(--green);font-size:13px;">✅ Updates check complete — phone finished syncing</div>`;
showToast('✅ Phone check-for-updates complete');
loadMsfsSync(); // refresh
} else if (task.status === 'warning') {
clearInterval(syncPollInterval);
syncPollInterval = null;
document.getElementById('msfsLaunchBtn').disabled = false;
document.getElementById('msfsCancelBtn').style.display = 'none';
document.getElementById('msfsProgressResult').style.display = '';
document.getElementById('msfsProgressResult').innerHTML =
`<div style="color:var(--amber);font-size:13px;">⚠️ ${esc(task.message)}</div>`;
showToast('⚠️ Check-for-updates completed with warnings', true);
loadMsfsSync();
} else if (task.status === 'failed') {
clearInterval(syncPollInterval);
syncPollInterval = null;
document.getElementById('msfsLaunchBtn').disabled = false;
document.getElementById('msfsCancelBtn').style.display = 'none';
document.getElementById('msfsProgressResult').style.display = '';
document.getElementById('msfsProgressResult').innerHTML =
`<div style="color:var(--red);font-size:13px;">❌ ${esc(task.message)}</div>`;
showToast(`${task.message}`, true);
}
} catch (e) {
// Keep polling
}
}, 2000);
} catch (e) {
showToast(e.message, true);
}
}
async function cancelMsfsSync() {
if (!syncTaskId) return;
try {
await api(`/api/admin/msfs-sync/cancel/${syncTaskId}`, { method: 'POST' });
showToast('Sync cancelled');
document.getElementById('msfsSyncBtn').disabled = false;
document.getElementById('msfsCancelBtn').style.display = 'none';
} catch (e) {
showToast(e.message, true);
}
}
function esc(s) { return String(s || '').replace(/[&<>"']/g, function(m) {
return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]; });
}
</script>
</body>
</html>