Files
canteen-admin-server/static/index.html
T
Leo 510bafc919 feat: multi-role support — comma-separated roles with checkbox UI
- Added validate_roles() and roles_contain() helpers for comma-separated roles
- Updated create_user/update_user to accept role combinations (e.g. 'admin,technician')
- Changed all exact role queries (='technician', IN('technician','admin')) to LIKE
- Replaced single <select> with checkboxes in add/edit user modals
- Added renderRoleBadges() to display multiple role badges per user
- Added checkbox-group/checkbox-label CSS styles
- Updated tech-photo-upload technician query to use LIKE
- Updated db reset auth check to use roles_contain()
- Set shawn's role to 'admin,technician'
- Cleaned up unused imports (hashlib, io, etc.)

Applies to canteen-admin-server, canteen-admin-server-dev, and tech-photo-upload
2026-05-25 23:11:00 -04:00

4028 lines
181 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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));
}
.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); }
.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; }
</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">
<button id="loginBtn" class="btn btn-primary btn-block" onclick="doLogin()">Sign In</button>
<div style="text-align:center;font-size:12px;color:var(--text3);margin-top:10px;">Default: admin / changeme</div>
</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')">
<span class="ni-icon">📈</span><span>Dashboard</span>
</button>
<button class="nav-item" data-page="assets" onclick="switchPage('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')">
<span class="ni-icon">⚙️</span><span>Settings</span>
</button>
<button class="nav-item" data-page="users" onclick="switchPage('users')">
<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')">
<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')">
<span class="ni-icon">📋</span><span>Activity Log</span>
</button>
<button class="nav-item" data-page="export" onclick="switchPage('export')">
<span class="ni-icon">📤</span><span>Export</span>
</button>
<button class="nav-item" data-page="cantaloupe" onclick="switchPage('cantaloupe')">
<span class="ni-icon">📥</span><span>Import</span>
</button>
<button class="nav-item" data-page="exifscanner" onclick="switchPage('exifscanner')">
<span class="ni-icon">📸</span><span>EXIF Scanner</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>
<!-- ── 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>
<!-- ── 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..." 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>
</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>
</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>
<!-- ═══ 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'), 3000);
}
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; }
}
// ═════════════════════════════════════════════════════════════════════════════
// PAGE ROUTING
// ═════════════════════════════════════════════════════════════════════════════
function switchPage(page) {
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 === 'exifscanner') loadExifScanner();
}
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;
if (!username || !password) {
showLoginError('Please enter username and password');
return;
}
try {
const data = await api('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
AppState.currentUser = { id: data.id, username: data.username, role: data.role };
AppState.authToken = data.token;
localStorage.setItem('adminToken', data.token);
sessionStorage.setItem('adminUser', JSON.stringify(AppState.currentUser));
document.getElementById('loginOverlay').classList.add('hidden');
updateUserUI();
switchPage('dashboard');
} catch (e) {
showLoginError(e.message);
}
}
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 — but we need user info.
// The login response stored user data, try sessionStorage fallback.
const savedUser = 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() {
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: ${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</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</div>';
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 || '')} <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 || '';
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: ${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;
// 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);
showToast(`${field} saved`);
} catch (e) {
showToast(`✗ Failed to save ${field}: ${e.message}`, 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</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 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';
return `<td class="${cls}" data-edit-id="${a.id}" data-field="${f}" onclick="startEdit(${a.id},'${f}',${JSON.stringify(val == null ? '' : String(val)).replace(/"/g,'&quot;')})">${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>
</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('')}
</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`);
}
// ═════════════════════════════════════════════════════════════════════════════
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</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</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 found</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();
});
});
// ═════════════════════════════════════════════════════════════════════════════
// 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';
}
</script>
</body>
</html>