4035 lines
180 KiB
HTML
4035 lines
180 KiB
HTML
<!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,'"')})">${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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
}
|
||
|
||
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 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)'};">
|
||
<div style="display:flex;gap:8px;">
|
||
${thumb ? `<img src="${thumb}" style="width:48px;height:48px;border-radius:6px;object-fit:cover;flex-shrink:0;" onclick="window.open('${thumb}','_blank')">` : ''}
|
||
<div style="flex:1;min-width:0;">
|
||
<div style="font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${esc(p.orig_filename || 'photo.jpg')}</div>
|
||
<div style="display:flex;gap:4px;margin-top:4px;flex-wrap:wrap;">
|
||
${gpsBadge} ${matchBadge}
|
||
</div>
|
||
<div style="font-size:10px;color:var(--text3);margin-top:4px;">${p.created_at ? new Date(p.created_at).toLocaleString() : ''}</div>
|
||
</div>
|
||
</div>
|
||
</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: '© 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>
|