be9c34f51d
- Fix backup.py PRESERVED_FIELDS column names (lat/lng -> latitude/longitude) to match actual DB schema (pre-existing bug) - Add backup_gps() and restore_gps() dedicated GPS-only functions - Add /api/gps/backup and /api/gps/restore API endpoints - Add /api/gps/restore/upload for restoring from uploaded backup files - Add /api/db/wipe endpoint to clear all asset records - Update UI with GPS-only Backup button, Restore GPS from File picker, and Wipe All Assets with double-confirmation dialog - Fix backup listing to scan both seed-data/ and project root
1084 lines
44 KiB
HTML
1084 lines
44 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 Seed Import</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;
|
|
--purple: #a78bfa;
|
|
--cyan: #22d3ee;
|
|
--radius: 14px;
|
|
--radius-sm: 10px;
|
|
}
|
|
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
html { overflow-x: hidden; background: var(--bg); color: var(--text); }
|
|
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
min-height: 100vh;
|
|
padding: 0;
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
.container {
|
|
max-width: 1100px;
|
|
margin: 0 auto;
|
|
padding: 24px 16px;
|
|
}
|
|
|
|
/* ─── Header ─────────────────────────────────────────────────────────── */
|
|
|
|
.header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 32px;
|
|
}
|
|
.header h1 {
|
|
font-size: 24px;
|
|
font-weight: 700;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
.header h1 span.icon { font-size: 28px; }
|
|
.header .badge {
|
|
font-size: 12px;
|
|
padding: 4px 10px;
|
|
border-radius: 20px;
|
|
background: var(--accent-bg);
|
|
color: var(--accent2);
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* ─── Cards ──────────────────────────────────────────────────────────── */
|
|
|
|
.card {
|
|
background: var(--card);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
padding: 24px;
|
|
margin-bottom: 20px;
|
|
}
|
|
.card h2 {
|
|
font-size: 16px;
|
|
margin-bottom: 16px;
|
|
color: var(--text2);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
.card h3 {
|
|
font-size: 14px;
|
|
margin-bottom: 8px;
|
|
color: var(--text);
|
|
}
|
|
|
|
/* ─── Upload zone ────────────────────────────────────────────────────── */
|
|
|
|
.upload-zone {
|
|
border: 2px dashed var(--border2);
|
|
border-radius: var(--radius);
|
|
padding: 48px 24px;
|
|
text-align: center;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
background: var(--card2);
|
|
}
|
|
.upload-zone:hover, .upload-zone.dragover {
|
|
border-color: var(--accent);
|
|
background: var(--accent-bg);
|
|
}
|
|
.upload-zone .icon { font-size: 48px; margin-bottom: 12px; }
|
|
.upload-zone .text { color: var(--text2); font-size: 15px; }
|
|
.upload-zone .sub { color: var(--text3); font-size: 13px; margin-top: 6px; }
|
|
.upload-zone input[type="file"] { display: none; }
|
|
|
|
/* ─── Buttons ────────────────────────────────────────────────────────── */
|
|
|
|
.btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 10px 20px;
|
|
border-radius: var(--radius-sm);
|
|
border: none;
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
.btn:disabled {
|
|
opacity: 0.4;
|
|
cursor: not-allowed;
|
|
}
|
|
.btn-primary {
|
|
background: var(--accent);
|
|
color: #fff;
|
|
}
|
|
.btn-primary:hover:not(:disabled) { background: var(--accent2); }
|
|
.btn-secondary {
|
|
background: var(--card2);
|
|
color: var(--text);
|
|
border: 1px solid var(--border2);
|
|
}
|
|
.btn-secondary:hover:not(:disabled) { border-color: var(--accent); }
|
|
.btn-danger {
|
|
background: var(--red-bg);
|
|
color: var(--red);
|
|
border: 1px solid var(--red);
|
|
}
|
|
.btn-sm {
|
|
padding: 5px 12px;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.btn-row {
|
|
display: flex;
|
|
gap: 10px;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
/* ─── Stats grid ─────────────────────────────────────────────────────── */
|
|
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
|
gap: 12px;
|
|
margin-bottom: 20px;
|
|
}
|
|
.stat-card {
|
|
background: var(--card2);
|
|
border-radius: var(--radius-sm);
|
|
padding: 16px;
|
|
text-align: center;
|
|
}
|
|
.stat-card .value {
|
|
font-size: 28px;
|
|
font-weight: 700;
|
|
color: var(--accent2);
|
|
}
|
|
.stat-card .value.green { color: var(--green); }
|
|
.stat-card .value.amber { color: var(--amber); }
|
|
.stat-card .value.red { color: var(--red); }
|
|
.stat-card .label {
|
|
font-size: 11px;
|
|
color: var(--text3);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
margin-top: 4px;
|
|
}
|
|
|
|
/* ─── Table ──────────────────────────────────────────────────────────── */
|
|
|
|
.table-wrap {
|
|
overflow-x: auto;
|
|
-webkit-overflow-scrolling: touch;
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-sm);
|
|
}
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 12px;
|
|
}
|
|
thead th {
|
|
position: sticky;
|
|
top: 0;
|
|
background: var(--card2);
|
|
color: var(--text3);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.3px;
|
|
font-size: 10px;
|
|
padding: 10px 8px;
|
|
text-align: left;
|
|
white-space: nowrap;
|
|
border-bottom: 2px solid var(--border);
|
|
}
|
|
tbody td {
|
|
padding: 8px;
|
|
border-bottom: 1px solid var(--border);
|
|
white-space: nowrap;
|
|
max-width: 200px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
tbody tr:hover { background: var(--card2); }
|
|
|
|
.tag {
|
|
display: inline-block;
|
|
padding: 2px 8px;
|
|
border-radius: 20px;
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
}
|
|
.tag-high { background: var(--red-bg); color: var(--red); }
|
|
.tag-mid { background: var(--amber-bg); color: var(--amber); }
|
|
.tag-low { background: var(--green-bg); color: var(--green); }
|
|
.tag-yes { background: var(--accent-bg); color: var(--accent2); }
|
|
.tag-disney { background: #1a1030; color: var(--purple); }
|
|
.badge-db {
|
|
font-size: 11px; padding: 4px 10px; border-radius: 20px;
|
|
background: var(--accent-bg); color: var(--accent2); font-weight: 600;
|
|
}
|
|
.badge-db.green { background: var(--green-bg); color: var(--green); }
|
|
.badge-db.amber { background: var(--amber-bg); color: var(--amber); }
|
|
.badge-db.red { background: var(--red-bg); color: var(--red); }
|
|
|
|
/* ─── Progress ───────────────────────────────────────────────────────── */
|
|
|
|
.progress-bar {
|
|
height: 6px;
|
|
background: var(--border);
|
|
border-radius: 3px;
|
|
overflow: hidden;
|
|
margin: 12px 0;
|
|
}
|
|
.progress-bar .fill {
|
|
height: 100%;
|
|
background: var(--accent);
|
|
border-radius: 3px;
|
|
transition: width 0.4s ease;
|
|
}
|
|
|
|
/* ─── Spinner ────────────────────────────────────────────────────────── */
|
|
|
|
.spinner {
|
|
display: inline-block;
|
|
width: 16px;
|
|
height: 16px;
|
|
border: 2px solid var(--border2);
|
|
border-top-color: var(--accent);
|
|
border-radius: 50%;
|
|
animation: spin 0.6s linear infinite;
|
|
}
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
|
|
/* ─── Markdown report ────────────────────────────────────────────────── */
|
|
|
|
.report-content {
|
|
font-family: monospace;
|
|
font-size: 13px;
|
|
line-height: 1.6;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
max-height: 600px;
|
|
overflow-y: auto;
|
|
background: var(--card2);
|
|
padding: 16px;
|
|
border-radius: var(--radius-sm);
|
|
border: 1px solid var(--border);
|
|
}
|
|
.report-content h1, .report-content h2, .report-content h3 {
|
|
color: var(--accent2);
|
|
font-family: -apple-system, sans-serif;
|
|
margin: 16px 0 8px;
|
|
}
|
|
.report-content h1 { font-size: 18px; }
|
|
.report-content h2 { font-size: 15px; }
|
|
.report-content h3 { font-size: 13px; }
|
|
.report-content code {
|
|
background: var(--border);
|
|
padding: 1px 5px;
|
|
border-radius: 4px;
|
|
font-size: 12px;
|
|
}
|
|
|
|
/* ─── Step indicators ────────────────────────────────────────────────── */
|
|
|
|
.steps {
|
|
display: flex;
|
|
gap: 0;
|
|
margin-bottom: 32px;
|
|
}
|
|
.step {
|
|
flex: 1;
|
|
text-align: center;
|
|
padding: 10px;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
color: var(--text3);
|
|
border-bottom: 3px solid var(--border);
|
|
transition: all 0.3s;
|
|
}
|
|
.step.active {
|
|
color: var(--accent2);
|
|
border-color: var(--accent);
|
|
}
|
|
.step.done {
|
|
color: var(--green);
|
|
border-color: var(--green);
|
|
}
|
|
|
|
/* ─── Toggle switches ────────────────────────────────────────────────── */
|
|
|
|
.toggle-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
margin: 12px 0;
|
|
}
|
|
.toggle-label {
|
|
font-size: 14px;
|
|
color: var(--text2);
|
|
}
|
|
.toggle-switch {
|
|
position: relative;
|
|
width: 44px;
|
|
height: 24px;
|
|
}
|
|
.toggle-switch input { display: none; }
|
|
.toggle-slider {
|
|
position: absolute;
|
|
inset: 0;
|
|
background: var(--border2);
|
|
border-radius: 12px;
|
|
cursor: pointer;
|
|
transition: 0.3s;
|
|
}
|
|
.toggle-slider::before {
|
|
content: "";
|
|
position: absolute;
|
|
width: 18px;
|
|
height: 18px;
|
|
left: 3px;
|
|
bottom: 3px;
|
|
background: var(--text);
|
|
border-radius: 50%;
|
|
transition: 0.3s;
|
|
}
|
|
input:checked + .toggle-slider {
|
|
background: var(--accent);
|
|
}
|
|
input:checked + .toggle-slider::before {
|
|
transform: translateX(20px);
|
|
}
|
|
|
|
/* ─── Mode selector ──────────────────────────────────────────────────── */
|
|
|
|
.mode-selector {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin: 16px 0;
|
|
}
|
|
.mode-option {
|
|
flex: 1;
|
|
padding: 16px;
|
|
border: 2px solid var(--border);
|
|
border-radius: var(--radius-sm);
|
|
cursor: pointer;
|
|
text-align: center;
|
|
transition: all 0.2s;
|
|
background: var(--card2);
|
|
}
|
|
.mode-option:hover { border-color: var(--accent); }
|
|
.mode-option.selected {
|
|
border-color: var(--accent);
|
|
background: var(--accent-bg);
|
|
}
|
|
.mode-option .title {
|
|
font-size: 15px;
|
|
font-weight: 600;
|
|
margin-bottom: 4px;
|
|
}
|
|
.mode-option .desc {
|
|
font-size: 12px;
|
|
color: var(--text3);
|
|
}
|
|
|
|
/* ─── Info banner ────────────────────────────────────────────────────── */
|
|
|
|
.info-banner {
|
|
background: var(--accent-bg);
|
|
border: 1px solid var(--accent);
|
|
border-radius: var(--radius-sm);
|
|
padding: 12px 16px;
|
|
font-size: 13px;
|
|
color: var(--accent2);
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
/* ─── Responsive ─────────────────────────────────────────────────────── */
|
|
|
|
@media (max-width: 600px) {
|
|
.container { padding: 16px 10px; }
|
|
.stats-grid { grid-template-columns: 1fr 1fr; }
|
|
.header h1 { font-size: 18px; }
|
|
.upload-zone { padding: 32px 16px; }
|
|
.mode-selector { flex-direction: column; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
|
|
<!-- Header -->
|
|
<div class="header">
|
|
<h1><span class="icon">🌱</span>Canteen Seed Import</h1>
|
|
<span class="badge" id="badgeStatus">Ready</span>
|
|
</div>
|
|
|
|
<!-- Dashboard Bar -->
|
|
<div class="card" id="cardDashboard" style="padding:16px;margin-bottom:16px">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;flex-wrap:wrap;gap:8px">
|
|
<div style="font-size:13px;font-weight:600;color:var(--text2);text-transform:uppercase;letter-spacing:0.5px">📊 Database Status</div>
|
|
<div style="display:flex;gap:6px;flex-wrap:wrap">
|
|
<span class="badge badge-db" id="dashTotal">—</span>
|
|
<span class="badge badge-db" id="dashBackup">—</span>
|
|
<button class="btn btn-sm btn-secondary" onclick="refreshDashboard()" style="padding:3px 10px;font-size:11px">🔄</button>
|
|
</div>
|
|
</div>
|
|
<div id="dashContent" style="font-size:13px;color:var(--text3)">Loading...</div>
|
|
</div>
|
|
|
|
<!-- Steps -->
|
|
<div class="steps">
|
|
<div class="step active" id="step1">1. Upload</div>
|
|
<div class="step" id="step2">2. Review</div>
|
|
<div class="step" id="step3">3. Import</div>
|
|
<div class="step" id="step4">4. Report</div>
|
|
</div>
|
|
|
|
<!-- Step 1: Upload ───────────────────────────────────────────────────── -->
|
|
<div class="card" id="cardUpload">
|
|
<h2>📤 Upload Seed Data</h2>
|
|
<div class="upload-zone" id="uploadZone" onclick="document.getElementById('fileInput').click()">
|
|
<div class="icon">📁</div>
|
|
<div class="text">Drop Excel file here or click to browse</div>
|
|
<div class="sub">.xlsx, .xls, or .csv</div>
|
|
<input type="file" id="fileInput" accept=".xlsx,.xls,.csv" onchange="handleFile(event)">
|
|
</div>
|
|
<div id="uploadStatus" style="margin-top:12px; font-size:13px; color:var(--text3); display:none;"></div>
|
|
|
|
<!-- Drag-drop events -->
|
|
<script>
|
|
const zone = document.getElementById('uploadZone');
|
|
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); });
|
|
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
|
|
zone.addEventListener('drop', e => {
|
|
e.preventDefault();
|
|
zone.classList.remove('dragover');
|
|
const file = e.dataTransfer.files[0];
|
|
if (file) uploadFile(file);
|
|
});
|
|
</script>
|
|
</div>
|
|
|
|
<!-- Step 2: Review ───────────────────────────────────────────────────── -->
|
|
<div class="card" id="cardReview" style="display:none;">
|
|
<h2>📋 Parse Results</h2>
|
|
|
|
<div class="stats-grid" id="statsGrid"></div>
|
|
|
|
<div style="display:flex; align-items:center; gap:12px; margin-bottom:12px;">
|
|
<span style="font-size:13px; color:var(--text2);">Preview — first 50 of <strong id="totalCount">—</strong> machines</span>
|
|
<span style="flex:1;"></span>
|
|
<button class="btn btn-sm btn-secondary" onclick="scrollToImport()">⬇ Skip to Import</button>
|
|
</div>
|
|
|
|
<div class="table-wrap">
|
|
<table id="previewTable">
|
|
<thead id="previewHead"></thead>
|
|
<tbody id="previewBody"></tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Column policy info -->
|
|
<div style="margin-top:16px; padding:12px; background:var(--card2); border-radius:var(--radius-sm); font-size:12px; color:var(--text2);">
|
|
<strong style="color:var(--accent2);">🛡️ Preserve fields</strong> (never overwritten): <span id="preserveList"></span><br>
|
|
<strong style="color:var(--green);">✏️ Auto fields</strong> (always overwritten): <span id="autoList"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 3: Import ───────────────────────────────────────────────────── -->
|
|
<div class="card" id="cardImport" style="display:none;">
|
|
<h2>🚀 Run Import</h2>
|
|
|
|
<!-- Mode selector -->
|
|
<div id="modeSelector">
|
|
<div style="font-size:14px; color:var(--text2); margin-bottom:8px;">Select import mode:</div>
|
|
<div class="mode-selector">
|
|
<div class="mode-option selected" id="modeSeed" onclick="selectMode('seed')">
|
|
<div class="title">🆕 Seed</div>
|
|
<div class="desc">Fresh database — all records inserted from scratch</div>
|
|
</div>
|
|
<div class="mode-option" id="modeUpdate" onclick="selectMode('update')">
|
|
<div class="title">🔄 Update</div>
|
|
<div class="desc">Upsert — GPS, photos, install dates preserved</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Options -->
|
|
<div class="toggle-row">
|
|
<label class="toggle-switch">
|
|
<input type="checkbox" id="toggleBackup" checked>
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
<span class="toggle-label">Backup existing GPS & photos before import</span>
|
|
</div>
|
|
|
|
<!-- DB path -->
|
|
<div style="margin:12px 0; font-size:12px; color:var(--text3);">
|
|
Target DB: <code style="color:var(--accent2);" id="dbPathDisplay">seed-data/assets.db</code>
|
|
</div>
|
|
|
|
<!-- Run button -->
|
|
<div class="btn-row" style="margin-top:20px;">
|
|
<button class="btn btn-primary" id="btnImport" onclick="runImport()">
|
|
🚀 Run Import
|
|
</button>
|
|
<span id="importStatus" style="font-size:13px; color:var(--text2);"></span>
|
|
</div>
|
|
|
|
<!-- Import result -->
|
|
<div id="importResult" style="display:none; margin-top:20px;"></div>
|
|
</div>
|
|
|
|
<!-- Step 4: Report ───────────────────────────────────────────────────── -->
|
|
<div class="card" id="cardReport" style="display:none;">
|
|
<h2>📄 Validation Report</h2>
|
|
<div style="margin-bottom:12px;">
|
|
<button class="btn btn-sm btn-secondary" id="btnRefreshReport" onclick="loadReport()">🔄 Refresh</button>
|
|
<span style="font-size:12px; color:var(--text3); margin-left:8px;" id="reportPath"></span>
|
|
</div>
|
|
<div class="report-content" id="reportContent">
|
|
<span style="color:var(--text3);">No report yet. Run an import first.</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Backup Management ───────────────────────────────────────────────── -->
|
|
<div class="card" id="cardBackup" style="margin-top:20px">
|
|
<h2>💾 Backup Management</h2>
|
|
<div class="btn-row" style="margin-bottom:8px">
|
|
<button class="btn btn-primary btn-sm" onclick="createBackup()">💾 Full Backup</button>
|
|
<button class="btn btn-sm" style="background:var(--accent2);color:#fff;border:none;cursor:pointer;border-radius:6px;padding:6px 14px;font-size:13px" onclick="createGpsBackup()">📍 GPS-Only Backup</button>
|
|
<span id="backupStatus" style="font-size:13px;color:var(--text2)"></span>
|
|
</div>
|
|
<div id="backupList">
|
|
<span style="color:var(--text3);font-size:13px">Loading backups...</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Database Management ────────────────────────────────────────────── -->
|
|
<div class="card" style="margin-top:20px;border-color:rgba(255,80,80,0.2);">
|
|
<h2 style="color:var(--red);">⚠️ Database Management</h2>
|
|
<div style="font-size:13px;color:var(--text3);margin-bottom:12px;">
|
|
These operations modify the underlying asset database directly.
|
|
Always <strong>backup GPS first</strong> to avoid losing coordinate data.
|
|
</div>
|
|
<div class="btn-row" style="gap:8px;flex-wrap:wrap;">
|
|
<button class="btn btn-danger btn-sm" onclick="restoreFromFile()">📍 Restore GPS from File</button>
|
|
<button class="btn btn-danger btn-sm" onclick="wipeDatabase()">🗑️ Wipe All Assets</button>
|
|
<span id="dbManageStatus" style="font-size:13px;color:var(--text2)"></span>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<script>
|
|
// ─── State ─────────────────────────────────────────────────────────────
|
|
|
|
let importMode = 'seed';
|
|
let sessionData = null;
|
|
|
|
// ─── Init ──────────────────────────────────────────────────────────────
|
|
|
|
(async function init() {
|
|
try {
|
|
// Load column policies
|
|
const colsRes = await fetch('/api/columns');
|
|
const cols = await colsRes.json();
|
|
document.getElementById('preserveList').textContent = cols.preserve.join(', ');
|
|
document.getElementById('autoList').textContent = cols.auto.join(', ');
|
|
|
|
// Load existing session
|
|
const sessRes = await fetch('/api/session');
|
|
sessionData = await sessRes.json();
|
|
if (sessionData.has_data) {
|
|
showParsedData(sessionData.parse_stats, sessionData.machine_count);
|
|
if (sessionData.last_import) {
|
|
showImportResult(sessionData.last_import);
|
|
}
|
|
}
|
|
|
|
// Load dashboard + backups
|
|
refreshDashboard();
|
|
loadBackupList();
|
|
} catch (e) {
|
|
console.error('Init error:', e);
|
|
}
|
|
})();
|
|
|
|
// ─── File handling ────────────────────────────────────────────────────
|
|
|
|
function handleFile(event) {
|
|
const file = event.target.files[0];
|
|
if (file) uploadFile(file);
|
|
}
|
|
|
|
async function uploadFile(file) {
|
|
const status = document.getElementById('uploadStatus');
|
|
status.style.display = 'block';
|
|
status.innerHTML = '<span class="spinner"></span> Parsing...';
|
|
document.getElementById('badgeStatus').textContent = 'Parsing...';
|
|
|
|
const form = new FormData();
|
|
form.append('file', file);
|
|
|
|
try {
|
|
const res = await fetch('/api/upload', { method: 'POST', body: form });
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.detail || 'Upload failed');
|
|
}
|
|
const data = await res.json();
|
|
sessionData = data;
|
|
showParsedData(data.stats, data.total_rows);
|
|
document.getElementById('badgeStatus').textContent = 'Parsed ✓';
|
|
status.innerHTML = `✅ Parsed <strong>${data.total_rows.toLocaleString()}</strong> machines from <em>${esc(data.file_name)}</em>`;
|
|
} catch (e) {
|
|
status.innerHTML = `❌ ${esc(e.message)}`;
|
|
document.getElementById('badgeStatus').textContent = 'Error';
|
|
}
|
|
}
|
|
|
|
// ─── Build preview table ──────────────────────────────────────────────
|
|
|
|
function showParsedData(stats, totalRows) {
|
|
// Show cards
|
|
document.getElementById('cardReview').style.display = '';
|
|
document.getElementById('cardImport').style.display = '';
|
|
document.getElementById('cardReport').style.display = '';
|
|
|
|
// Stats
|
|
const grid = document.getElementById('statsGrid');
|
|
grid.innerHTML = `
|
|
<div class="stat-card"><div class="value">${fmt(totalRows)}</div><div class="label">Total Machines</div></div>
|
|
<div class="stat-card"><div class="value">${fmt(stats.disney)}</div><div class="label">Disney Properties</div></div>
|
|
<div class="stat-card"><div class="value amber">${fmt(stats.unknown_machines)}</div><div class="label">Unknown Machines</div></div>
|
|
<div class="stat-card"><div class="value">${fmt(stats.ocr_corrections)}</div><div class="label">OCR Corrections</div></div>
|
|
<div class="stat-card"><div class="value red">${fmt(stats.priority?.High || 0)}</div><div class="label">High Priority</div></div>
|
|
<div class="stat-card"><div class="value amber">${fmt(stats.priority?.Mid || 0)}</div><div class="label">Mid Priority</div></div>
|
|
<div class="stat-card"><div class="value green">${fmt(stats.priority?.Low || 0)}</div><div class="label">Low Priority</div></div>
|
|
`;
|
|
|
|
// Telemetry stats
|
|
if (stats.telemetry) {
|
|
const tItems = Object.entries(stats.telemetry)
|
|
.filter(([k]) => k !== 'None')
|
|
.sort(([,a], [,b]) => b - a)
|
|
.map(([k, v]) => `<span style="margin-right:8px;"><strong>${esc(k)}</strong>: ${v}</span>`)
|
|
.join('');
|
|
if (tItems) {
|
|
grid.innerHTML += `<div class="stat-card" style="grid-column:span 2;"><div style="font-size:13px;color:var(--text2);">${tItems}</div><div class="label">Telemetry Providers</div></div>`;
|
|
}
|
|
}
|
|
|
|
// Update count
|
|
document.getElementById('totalCount').textContent = fmt(totalRows);
|
|
|
|
// Update steps
|
|
setStep(2, 'active');
|
|
setStep(1, 'done');
|
|
|
|
// Load session for the import card
|
|
fetch('/api/session').then(r => r.json()).then(d => {
|
|
sessionData = d;
|
|
if (d.last_import) showImportResult(d.last_import);
|
|
});
|
|
|
|
// Scroll to review
|
|
document.getElementById('cardReview').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
}
|
|
|
|
// ─── Build preview table (called after upload returns preview data) ───
|
|
|
|
// The upload endpoint already returns preview data — build the table when we have it
|
|
// We monkey-patch the upload handling to also populate the table
|
|
const origShowParsedData = showParsedData;
|
|
showParsedData = function(stats, totalRows) {
|
|
origShowParsedData(stats, totalRows);
|
|
|
|
// Build preview table from session data
|
|
if (sessionData && sessionData.preview) {
|
|
buildPreviewTable(sessionData.preview, sessionData.columns_previewed);
|
|
}
|
|
};
|
|
|
|
function buildPreviewTable(preview, columns) {
|
|
const head = document.getElementById('previewHead');
|
|
const body = document.getElementById('previewBody');
|
|
|
|
// Headers
|
|
head.innerHTML = '<tr>' + columns.map(c => `<th>${esc(c)}</th>`).join('') + '</tr>';
|
|
|
|
// Rows
|
|
body.innerHTML = preview.map(row => {
|
|
return '<tr>' + columns.map(col => {
|
|
let val = row[col];
|
|
if (val == null || val === '') return '<td style="color:var(--text3);">—</td>';
|
|
|
|
// Special formatting
|
|
if (col === 'priority') {
|
|
const cls = val === 'High' ? 'tag-high' : val === 'Mid' ? 'tag-mid' : 'tag-low';
|
|
return `<td><span class="tag ${cls}">${esc(val)}</span></td>`;
|
|
}
|
|
if (col === 'is_disney' && val) {
|
|
return `<td><span class="tag tag-disney">🏰 ${esc(sessionData?.stats?.disney_park || '')}</span></td>`;
|
|
}
|
|
if (col === 'has_cashless') {
|
|
return `<td><span class="tag ${val ? 'tag-yes' : ''}">${val ? '✓' : '—'}</span></td>`;
|
|
}
|
|
if (col === 'yearly_sales' && typeof val === 'number') {
|
|
return `<td style="text-align:right;">$${val.toLocaleString()}</td>`;
|
|
}
|
|
|
|
return `<td>${esc(String(val))}</td>`;
|
|
}).join('') + '</tr>';
|
|
}).join('');
|
|
}
|
|
|
|
// ─── Mode selection ───────────────────────────────────────────────────
|
|
|
|
function selectMode(mode) {
|
|
importMode = mode;
|
|
document.getElementById('modeSeed').classList.toggle('selected', mode === 'seed');
|
|
document.getElementById('modeUpdate').classList.toggle('selected', mode === 'update');
|
|
}
|
|
|
|
// ─── Run import ───────────────────────────────────────────────────────
|
|
|
|
async function runImport() {
|
|
const btn = document.getElementById('btnImport');
|
|
const status = document.getElementById('importStatus');
|
|
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<span class="spinner"></span> Importing...';
|
|
status.textContent = '';
|
|
document.getElementById('badgeStatus').textContent = 'Importing...';
|
|
|
|
const backup = document.getElementById('toggleBackup').checked;
|
|
const params = new URLSearchParams({ mode: importMode, backup: backup });
|
|
|
|
try {
|
|
const res = await fetch('/api/import?' + params, { method: 'POST' });
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.detail || 'Import failed');
|
|
}
|
|
const data = await res.json();
|
|
showImportResult(data);
|
|
document.getElementById('badgeStatus').textContent = 'Imported ✓';
|
|
setStep(3, 'done');
|
|
setStep(4, 'active');
|
|
|
|
// Load report
|
|
setTimeout(loadReport, 500);
|
|
} catch (e) {
|
|
status.innerHTML = `<span style="color:var(--red);">❌ ${esc(e.message)}</span>`;
|
|
document.getElementById('badgeStatus').textContent = 'Error';
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.innerHTML = '🚀 Run Import';
|
|
}
|
|
}
|
|
|
|
function showImportResult(data) {
|
|
const el = document.getElementById('importResult');
|
|
const stats = data.stats || {};
|
|
|
|
const inserted = (stats.inserted || 0).toLocaleString();
|
|
const updated = (stats.updated || 0).toLocaleString();
|
|
const preserved = (stats.preserved || 0).toLocaleString();
|
|
const errors = stats.errors || 0;
|
|
|
|
let html = '<div class="info-banner">✅ Import complete!</div>';
|
|
html += '<div class="stats-grid">';
|
|
if (inserted !== '0') html += `<div class="stat-card"><div class="value">${inserted}</div><div class="label">Inserted</div></div>`;
|
|
if (updated !== '0') html += `<div class="stat-card"><div class="value">${updated}</div><div class="label">Updated</div></div>`;
|
|
if (preserved !== '0') html += `<div class="stat-card"><div class="value green">${preserved}</div><div class="label">Preserved</div></div>`;
|
|
if (errors) html += `<div class="stat-card"><div class="value red">${errors}</div><div class="label">Errors</div></div>`;
|
|
html += `<div class="stat-card"><div class="value">${data.mode || '—'}</div><div class="label">Mode</div></div>`;
|
|
html += '</div>';
|
|
|
|
if (data.backup_path && !data.backup_path.startsWith('ERROR')) {
|
|
html += `<div style="font-size:12px; color:var(--green); margin-top:8px;">💾 Backup: <code>${esc(data.backup_path)}</code></div>`;
|
|
}
|
|
|
|
el.innerHTML = html;
|
|
el.style.display = '';
|
|
|
|
document.getElementById('cardReport').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
}
|
|
|
|
// ─── Report ───────────────────────────────────────────────────────────
|
|
|
|
async function loadReport() {
|
|
const el = document.getElementById('reportContent');
|
|
const pathEl = document.getElementById('reportPath');
|
|
el.innerHTML = '<span class="spinner"></span> Loading...';
|
|
|
|
try {
|
|
const res = await fetch('/api/report');
|
|
const data = await res.json();
|
|
if (data.content) {
|
|
el.innerHTML = renderMarkdown(data.content);
|
|
pathEl.textContent = data.path || '';
|
|
document.getElementById('cardReport').style.display = '';
|
|
setStep(4, 'active');
|
|
} else {
|
|
el.innerHTML = '<span style="color:var(--text3);">' + (data.note || 'No report available.') + '</span>';
|
|
}
|
|
} catch (e) {
|
|
el.innerHTML = `<span style="color:var(--red);">Error loading report: ${esc(e.message)}</span>`;
|
|
}
|
|
}
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────
|
|
|
|
function esc(s) {
|
|
if (s == null) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = s;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function fmt(n) {
|
|
if (n == null) return '—';
|
|
return Number(n).toLocaleString();
|
|
}
|
|
|
|
function setStep(n, state) {
|
|
const el = document.getElementById('step' + n);
|
|
el.classList.remove('active', 'done');
|
|
if (state) el.classList.add(state);
|
|
}
|
|
|
|
function scrollToImport() {
|
|
document.getElementById('cardImport').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
}
|
|
|
|
// ─── Simple markdown → HTML (headings, bold, code) ────────────────────
|
|
|
|
function renderMarkdown(md) {
|
|
let html = esc(md);
|
|
// Headings
|
|
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
|
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
|
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
|
|
// Bold
|
|
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
|
// Inline code
|
|
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
// Horizontal rules
|
|
html = html.replace(/^---$/gm, '<hr style="border-color:var(--border);margin:16px 0;">');
|
|
// Lists
|
|
html = html.replace(/^- (.+)$/gm, '• $1');
|
|
// Separator lines
|
|
html = html.replace(/^═+$/gm, '');
|
|
return html;
|
|
}
|
|
|
|
// ─── Dashboard ────────────────────────────────────────────────────
|
|
|
|
async function refreshDashboard() {
|
|
try {
|
|
const d = await fetch('/api/status').then(r => r.json());
|
|
const el = document.getElementById('dashContent');
|
|
document.getElementById('dashTotal').textContent = `📦 ${d.total_machines || 0} machines`;
|
|
const gps = d.gps_coverage?.with_gps || 0;
|
|
const total = d.total_machines || 1;
|
|
const pct = (gps / total * 100).toFixed(0);
|
|
const gpsClass = pct > 80 ? 'green' : pct > 50 ? 'amber' : 'red';
|
|
document.getElementById('dashBackup').textContent = d.backup_count > 0
|
|
? `💾 ${d.backup_count} backup${d.backup_count > 1 ? 's' : ''}`
|
|
: '💾 No backups';
|
|
|
|
const makes = Object.entries(d.by_make || {}).slice(0, 5).map(([k, v]) =>
|
|
`<span style="margin-right:8px"><strong>${esc(k)}</strong>: ${v}</span>`
|
|
).join('');
|
|
|
|
el.innerHTML = `
|
|
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:8px;margin-bottom:8px">
|
|
<div><span style="color:var(--text2);">By class:</span> ${Object.entries(d.by_class || {}).map(([k,v]) => `${k}: ${v}`).join(', ')}</div>
|
|
<div><span style="color:var(--text2);">Top makes:</span> ${makes || '—'}</div>
|
|
<div><span style="color:var(--text2);">GPS:</span> <span class="badge-db ${gpsClass}">${gps}/${total} (${pct}%)</span></div>
|
|
<div><span style="color:var(--text2);">Priority:</span> ${Object.entries(d.by_priority || {}).map(([k,v]) => `${k}: ${v}`).join(', ')}</div>
|
|
</div>
|
|
${d.last_modified ? `<div style="font-size:11px;color:var(--text3)">Last modified: ${d.last_modified.slice(0,19)}</div>` : ''}
|
|
`;
|
|
} catch (e) {
|
|
document.getElementById('dashContent').textContent = '⚠️ ' + e.message;
|
|
}
|
|
}
|
|
|
|
// ─── Backup Management ────────────────────────────────────────────
|
|
|
|
async function createBackup() {
|
|
const statusEl = document.getElementById('backupStatus');
|
|
statusEl.innerHTML = '<span class="spinner"></span> Backing up...';
|
|
try {
|
|
const res = await fetch('/api/backup', { method: 'POST' });
|
|
if (!res.ok) throw new Error((await res.json()).detail || 'Backup failed');
|
|
const d = await res.json();
|
|
statusEl.innerHTML = `<span style="color:var(--green)">✅ Backup: ${esc(d.backup_path)}</span>`;
|
|
loadBackupList();
|
|
refreshDashboard();
|
|
} catch (e) {
|
|
statusEl.innerHTML = `<span style="color:var(--red)">❌ ${esc(e.message)}</span>`;
|
|
}
|
|
}
|
|
|
|
async function loadBackupList() {
|
|
const el = document.getElementById('backupList');
|
|
try {
|
|
const res = await fetch('/api/backups');
|
|
const d = await res.json();
|
|
if (!d.backups?.length) {
|
|
el.innerHTML = '<span style="color:var(--text3);font-size:13px">No backups yet. Create one above.</span>';
|
|
return;
|
|
}
|
|
el.innerHTML = d.backups.map(b => `
|
|
<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 0;border-bottom:1px solid rgba(255,255,255,0.04)">
|
|
<div>
|
|
<div style="font-weight:500;font-size:13px">📦 ${esc(b.filename)}</div>
|
|
<div style="font-size:11px;color:var(--text3)">${b.created} · ${b.records} records</div>
|
|
</div>
|
|
<div style="display:flex;gap:4px">
|
|
<button class="btn btn-sm btn-secondary" onclick="compareBackup('${esc(b.path)}')">Compare</button>
|
|
<button class="btn btn-sm btn-danger" onclick="restoreBackup('${esc(b.path)}')">Restore</button>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
} catch (e) {
|
|
el.innerHTML = `<span style="color:var(--red);font-size:13px">❌ ${esc(e.message)}</span>`;
|
|
}
|
|
}
|
|
|
|
async function compareBackup(path) {
|
|
const el = document.getElementById('backupList');
|
|
el.innerHTML = '<span class="spinner"></span><span style="margin-left:8px;color:var(--text2)">Comparing...</span>';
|
|
try {
|
|
const res = await fetch('/api/compare?backup_path=' + encodeURIComponent(path));
|
|
if (!res.ok) throw new Error((await res.json()).detail || 'Compare failed');
|
|
const d = await res.json();
|
|
el.innerHTML = d.changes === 0
|
|
? '<div style="color:var(--green);font-size:13px">✅ No unexpected GPS/photo overwrites detected</div>'
|
|
: `<div style="color:var(--red);font-size:13px">⚠️ ${d.changes} field(s) changed. <a href="#" onclick="loadBackupList();return false">Back to list</a></div>`;
|
|
setTimeout(loadBackupList, 2500);
|
|
} catch (e) {
|
|
el.innerHTML = `<span style="color:var(--red);font-size:13px">❌ ${esc(e.message)}</span>`;
|
|
setTimeout(loadBackupList, 2000);
|
|
}
|
|
}
|
|
|
|
async function restoreBackup(path) {
|
|
if (!confirm('Restore GPS & photo fields from this backup?')) return;
|
|
const el = document.getElementById('backupList');
|
|
el.innerHTML = '<span class="spinner"></span><span style="margin-left:8px;color:var(--text2)">Restoring...</span>';
|
|
try {
|
|
const res = await fetch('/api/backup/restore?backup_path=' + encodeURIComponent(path));
|
|
if (!res.ok) throw new Error((await res.json()).detail || 'Restore failed');
|
|
const d = await res.json();
|
|
el.innerHTML = `<div style="color:var(--green);font-size:13px">✅ Restored from: ${esc(d.restored_from)}</div>`;
|
|
refreshDashboard();
|
|
setTimeout(loadBackupList, 2000);
|
|
} catch (e) {
|
|
el.innerHTML = `<span style="color:var(--red);font-size:13px">❌ ${esc(e.message)}</span>`;
|
|
setTimeout(loadBackupList, 2000);
|
|
}
|
|
}
|
|
|
|
// ─── GPS-only Backup ──────────────────────────────────────────────
|
|
|
|
async function createGpsBackup() {
|
|
const statusEl = document.getElementById('backupStatus');
|
|
statusEl.innerHTML = '<span class="spinner"></span> Backing up GPS...';
|
|
try {
|
|
const res = await fetch('/api/gps/backup', { method: 'POST' });
|
|
if (!res.ok) throw new Error((await res.json()).detail || 'GPS backup failed');
|
|
const d = await res.json();
|
|
statusEl.innerHTML = `<span style="color:var(--green)">✅ GPS backup: ${esc(d.backup_path)}</span>`;
|
|
loadBackupList();
|
|
refreshDashboard();
|
|
} catch (e) {
|
|
statusEl.innerHTML = `<span style="color:var(--red)">❌ ${esc(e.message)}</span>`;
|
|
}
|
|
}
|
|
|
|
// ─── Restore GPS from File (file picker) ──────────────────────────
|
|
|
|
function restoreFromFile() {
|
|
// Create hidden file input for .json.gz files
|
|
const input = document.createElement('input');
|
|
input.type = 'file';
|
|
input.accept = '.json.gz,.json';
|
|
input.onchange = async (e) => {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
const statusEl = document.getElementById('dbManageStatus');
|
|
statusEl.innerHTML = `<span class="spinner"></span> Uploading & restoring GPS from ${esc(file.name)}...`;
|
|
|
|
// Upload to temp location then restore
|
|
const form = new FormData();
|
|
form.append('backup_file', file);
|
|
try {
|
|
const res = await fetch('/api/gps/restore/upload', { method: 'POST', body: form });
|
|
if (!res.ok) throw new Error((await res.json()).detail || 'Restore failed');
|
|
const d = await res.json();
|
|
statusEl.innerHTML = `<span style="color:var(--green)">✅ GPS restored from: ${esc(d.restored_from)}</span>`;
|
|
refreshDashboard();
|
|
} catch (e) {
|
|
statusEl.innerHTML = `<span style="color:var(--red)">❌ ${esc(e.message)}</span>`;
|
|
}
|
|
};
|
|
input.click();
|
|
}
|
|
|
|
// ─── Wipe Database ────────────────────────────────────────────────
|
|
|
|
async function wipeDatabase() {
|
|
if (!confirm('⚠️ This will DELETE ALL ASSET RECORDS from the database. This cannot be undone without a backup.\n\nHave you backed up GPS coordinates first?')) return;
|
|
if (!confirm('Final confirmation: Are you sure you want to delete ALL assets?')) return;
|
|
const statusEl = document.getElementById('dbManageStatus');
|
|
statusEl.innerHTML = '<span class="spinner"></span> Wiping database...';
|
|
try {
|
|
const res = await fetch('/api/db/wipe', { method: 'POST' });
|
|
if (!res.ok) throw new Error((await res.json()).detail || 'Wipe failed');
|
|
const d = await res.json();
|
|
statusEl.innerHTML = `<span style="color:var(--red)">🗑️ Deleted ${d.records_deleted} records. Database is now empty.</span>`;
|
|
refreshDashboard();
|
|
} catch (e) {
|
|
statusEl.innerHTML = `<span style="color:var(--red)">❌ ${esc(e.message)}</span>`;
|
|
}
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|