feat: category icons sync + unknown type dashboard widget

- Synced categories table: added Equipment/Appliances with icons,
  removed unused Furniture/Utensils & Serveware
- Updated seed data to match real asset categories
- Added category_health query to /api/stats (finds asset categories
  not in the categories lookup table)
- Added Category Health card to dashboard UI (red warning, shown
  only when unmapped categories exist)
This commit is contained in:
2026-05-31 00:28:07 -04:00
parent 750cde68c9
commit d58834bc56
2 changed files with 288 additions and 33 deletions
+17 -2
View File
@@ -310,8 +310,9 @@ def _seed_if_empty(conn: sqlite3.Connection, table: str, columns: tuple, rows: l
def _seed_data(conn: sqlite3.Connection):
"""Insert default seed data for lookup tables."""
_seed_if_empty(conn, "categories", ("name", "icon"), [
("Furniture", "🪑"), ("Appliances", "🔌"),
("Utensils & Serveware", "🍽️"), ("Equipment", "⚙️"), ("Other", "📦"),
("Bev", "🥤"), ("Snack", "🍿"),
("Food", "🍔"), ("Equipment", "🔧"),
("Appliances", "🔌"), ("Other", "📦"),
])
_seed_if_empty(conn, "key_names", ("name",), [
("MK500",), ("Green Dot",), ("Red Key",), ("Blue Key",),
@@ -1526,6 +1527,19 @@ def get_stats():
).fetchall()
by_make = {r["make"]: r["cnt"] for r in makes}
# Find asset categories not in the categories lookup table
unmapped = conn.execute(
"""SELECT a.category AS name, COUNT(*) AS cnt
FROM assets a LEFT JOIN categories c ON a.category = c.name
WHERE c.id IS NULL AND a.category IS NOT NULL AND a.category != ''
GROUP BY a.category ORDER BY cnt DESC"""
).fetchall()
category_health = {
"unmapped": [{"name": r["name"], "count": r["cnt"]} for r in unmapped],
"total_categories": len(by_category),
"unmapped_count": len(unmapped),
}
conn.close()
return {
"total_assets": total_assets,
@@ -1538,6 +1552,7 @@ def get_stats():
"top_visited": top_visited_list,
"time_on_site": time_on_site,
"by_make": by_make,
"category_health": category_health,
}
+271 -31
View File
@@ -136,6 +136,7 @@
flex: 1;
padding: 24px;
max-width: calc(100vw - var(--sidebar-w));
position: relative;
}
.page-title {
font-size: 22px;
@@ -533,6 +534,42 @@
.empty-state { text-align: center; padding: 28px 16px; color: var(--text2); font-size: 13px; }
.empty-state .es-icon { font-size: 36px; margin-bottom: 8px; opacity: 0.5; }
.loading-row { text-align: center; padding: 20px; color: var(--text2); }
/* ── Skeleton cards (dashboard loading) ── */
.skeleton-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
margin-bottom: 14px;
min-height: 80px;
}
.skeleton-card.skeleton-stat { padding: 18px; text-align: center; min-height: 100px; }
.skeleton-card .skeleton-bar {
height: 14px; background: var(--card2); border-radius: 8px; margin-bottom: 8px;
animation: skeleton-pulse 1.4s ease-in-out infinite;
}
.skeleton-card .skeleton-bar:last-child { margin-bottom: 0; }
.skeleton-card .skeleton-bar.w60 { width: 60%; }
.skeleton-card .skeleton-bar.w40 { width: 40%; }
.skeleton-card .skeleton-bar.w80 { width: 80%; }
.skeleton-card .skeleton-circle {
width: 48px; height: 48px; border-radius: 50%; background: var(--card2);
margin: 0 auto 10px; animation: skeleton-pulse 1.4s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 0.3; }
50% { opacity: 0.7; }
}
@keyframes pulse-border {
0%, 100% { border-color: var(--accent); }
50% { border-color: var(--accent2); }
}
td.saving {
opacity: 0.6;
box-shadow: inset 0 0 0 1px var(--accent);
animation: pulse-border 1s infinite;
}
.flex-row { display: flex; gap: 8px; align-items: center; }
.gap-sm { gap: 6px; }
.mt-8 { margin-top: 8px; }
@@ -716,6 +753,21 @@
.cs-repl-status.approved { background: rgba(72,199,142,0.15); color: var(--green); }
.cs-repl-status.rejected { background: rgba(240,61,62,0.15); color: var(--red); }
.cs-repl-resolve-all { margin-top: 8px; }
/* ── Route Planner iframe ── */
#pageRoute {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
margin-top: 0; padding: 0;
display: flex; flex-direction: column;
}
#pageRoute.hidden { display: none !important; }
#routeFrame {
width: 100%;
flex: 1;
border: none;
background: #fff;
}
</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>
@@ -731,7 +783,6 @@
<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>
@@ -752,33 +803,39 @@
</div>
</div>
<div class="sidebar-nav">
<button class="nav-item" data-page="dashboard" onclick="switchPage('dashboard')">
<button class="nav-item" data-page="dashboard" onclick="switchPage('dashboard')" title="Dashboard — overview stats, charts, and recent activity">
<span class="ni-icon">📈</span><span>Dashboard</span>
</button>
<button class="nav-item" data-page="assets" onclick="switchPage('assets')">
<button class="nav-item" data-page="assets" onclick="switchPage('assets')" title="Assets — search, sort, and inline-edit all assets">
<span class="ni-icon">📦</span><span>Assets</span><span class="ni-badge" id="assetBadgeNav"></span>
</button>
<button class="nav-item" data-page="settings" onclick="switchPage('settings')">
<button class="nav-item" data-page="settings" onclick="switchPage('settings')" title="Settings — manage categories, makes, models, key names, badge types">
<span class="ni-icon">⚙️</span><span>Settings</span>
</button>
<button class="nav-item" data-page="users" onclick="switchPage('users')">
<button class="nav-item" data-page="users" onclick="switchPage('users')" title="Users — manage admin accounts and roles">
<span class="ni-icon">👥</span><span>Users</span><span class="ni-badge" id="userBadgeNav"></span>
</button>
<button class="nav-item" data-page="customers" onclick="switchPage('customers')">
<button class="nav-item" data-page="customers" onclick="switchPage('customers')" title="Customers & Locations — manage customer info and site locations">
<span class="ni-icon">🏢</span><span>Customers</span><span class="ni-badge" id="custBadgeNav"></span>
</button>
<button class="nav-item" data-page="activity" onclick="switchPage('activity')">
<button class="nav-item" data-page="activity" onclick="switchPage('activity')" title="Activity Log — audit trail of all actions">
<span class="ni-icon">📋</span><span>Activity Log</span>
</button>
<button class="nav-item" data-page="export" onclick="switchPage('export')">
<button class="nav-item" data-page="export" onclick="switchPage('export')" title="Export & Admin — CSV exports, GPS clearing, database reset">
<span class="ni-icon">📤</span><span>Export</span>
</button>
<button class="nav-item" data-page="cantaloupe" onclick="switchPage('cantaloupe')">
<button class="nav-item" data-page="cantaloupe" onclick="switchPage('cantaloupe')" title="Import — upload Excel files and review changes">
<span class="ni-icon">📥</span><span>Import</span>
</button>
<button class="nav-item" data-page="exifscanner" onclick="switchPage('exifscanner')">
<button class="nav-item" data-page="exifscanner" onclick="switchPage('exifscanner')" title="EXIF Scanner — upload photos, extract GPS, OCR machine IDs">
<span class="ni-icon">📸</span><span>EXIF Scanner</span>
</button>
<button class="nav-item" data-page="route" onclick="switchPage('route')" title="Route Planner — view asset routes (external iframe)">
<span class="ni-icon">🗺️</span><span>Route</span>
</button>
<button class="nav-item" onclick="openHelpModal()" title="Help — keyboard shortcuts, tips, and documentation" style="border-top:1px solid var(--border);margin-top:4px;">
<span class="ni-icon"></span><span>Help</span>
</button>
</div>
<div class="sidebar-footer">
<button id="logoutBtn" class="logout-btn" onclick="doLogout()">🚪 Sign out</button>
@@ -807,6 +864,10 @@
<div class="card-title">By Category</div>
<div id="dashCategoryBars"></div>
</div>
<div class="card" id="dashCategoryHealthCard" style="display:none">
<div class="card-title" style="color:var(--red)">⚠️ Category Health</div>
<div id="dashCategoryHealth" style="font-size:13px;"></div>
</div>
<div class="card">
<div class="card-title">By Make</div>
<div id="dashMakeBars"></div>
@@ -996,7 +1057,7 @@
<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;">
<input id="assetSearch" type="text" class="input-field" placeholder="Search name, machine ID, company... (Ctrl+K)" oninput="loadAssets()" style="width:260px;font-size:12px;padding:6px 10px;">
<select id="assetStatusFilter" class="input-field" onchange="loadAssets()" style="width:auto;font-size:12px;">
<option value="">All Status</option>
<option value="active">Active</option>
@@ -1116,6 +1177,11 @@
</div>
<div id="exifPrevResults"></div>
</div>
</div> <!-- /pageExifscanner -->
<!-- ── ROUTE PLANNER (iframe) ── -->
<div id="pageRoute" class="page hidden">
<iframe id="routeFrame" src="https://route.ourpad.casa" frameborder="0"></iframe>
</div>
</div>
@@ -1129,6 +1195,17 @@
</div>
</div>
<!-- ═══ HELP MODAL ═══ -->
<div id="helpModal" class="modal-overlay" style="z-index:600;" onclick="closeHelpModal()">
<div class="modal" onclick="event.stopPropagation()" style="max-width:560px;max-height:80vh;overflow-y:auto;">
<div class="modal-title" style="font-size:18px;">❓ Help &amp; Quick Tips</div>
<div class="modal-body" style="font-size:13px;color:var(--text2);"></div>
<div class="modal-actions">
<button class="btn btn-primary" onclick="closeHelpModal()" style="flex:1;">Got it</button>
</div>
</div>
</div>
<!-- ═══ TOAST ═══ -->
<div id="toast" class="toast"></div>
@@ -1201,7 +1278,7 @@ function showToast(msg, isError = false) {
t.textContent = msg;
t.className = 'toast' + (isError ? ' error' : '');
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 3000);
setTimeout(() => t.classList.remove('show'), 4000);
}
let modalResolve = null;
@@ -1222,10 +1299,73 @@ function closeModal(confirmed = false) {
if (modalResolve) { modalResolve(confirmed); modalResolve = null; }
}
// ── Help Modal ──
function openHelpModal() {
const body = document.querySelector('#helpModal .modal-body');
body.innerHTML = `
<div style="margin-bottom:14px;">
<div style="font-weight:700;font-size:14px;color:var(--text);margin-bottom:6px;">⌨️ Keyboard Shortcuts</div>
<div style="display:grid;grid-template-columns:auto 1fr;gap:4px 12px;font-size:13px;">
<span style="background:var(--card2);padding:1px 6px;border-radius:4px;font-family:monospace;font-size:11px;">Ctrl+K</span><span>Focus asset search</span>
<span style="background:var(--card2);padding:1px 6px;border-radius:4px;font-family:monospace;font-size:11px;">Ctrl+/</span><span>Focus asset search</span>
<span style="background:var(--card2);padding:1px 6px;border-radius:4px;font-family:monospace;font-size:11px;">Esc</span><span>Close modals / cancel inline edit</span>
<span style="background:var(--card2);padding:1px 6px;border-radius:4px;font-family:monospace;font-size:11px;">Enter</span><span>Save inline edit cell</span>
</div>
</div>
<div style="margin-bottom:14px;">
<div style="font-weight:700;font-size:14px;color:var(--text);margin-bottom:6px;">✏️ Inline Editing (Assets)</div>
<ul style="margin:0;padding-left:18px;list-style:disc;">
<li>Click any cell in the assets table to start editing</li>
<li>Press <strong>Enter</strong> to save the cell</li>
<li>Press <strong>Esc</strong> to cancel editing</li>
<li>Click column headers to sort (ascending / descending)</li>
<li>Empty cells have a <span style="background:var(--amber-bg);color:var(--amber);padding:0 6px;border-radius:4px;">■</span> amber left border</li>
<li>Use the <strong>Batch Lookup</strong> button to find business names from addresses</li>
</ul>
</div>
<div style="margin-bottom:14px;">
<div style="font-weight:700;font-size:14px;color:var(--text);margin-bottom:6px;">📥 Cantaloupe Sync (Import)</div>
<ul style="margin:0;padding-left:18px;list-style:disc;">
<li>Upload an Excel file exported from Cantaloupe</li>
<li>Review <strong>pending batches</strong> — approve or reject changes</li>
<li>Use <strong>Review Changes</strong> to see per-field diffs before approving</li>
<li>Blank-fill changes (populating empty fields) are auto-selected for convenience</li>
<li>Machine replacements need manual approval per asset</li>
</ul>
</div>
<div>
<div style="font-weight:700;font-size:14px;color:var(--text);margin-bottom:6px;">📸 EXIF Scanner</div>
<ul style="margin:0;padding-left:18px;list-style:disc;">
<li>Drop photos or click to upload — GPS data is read from EXIF</li>
<li>Choose OCR engine: Tesseract (fast, local), LLM, or Google Gemini</li>
<li><strong>Sticker mode</strong> scans a 4-digit sticker on the machine</li>
<li>Bulk process extracts Machine IDs and matches them to assets</li>
<li>Use <strong>Push GPS</strong> to write GPS coordinates to matched assets</li>
</ul>
</div>
`;
document.getElementById('helpModal').style.display = 'flex';
document.getElementById('helpModal').classList.add('open');
}
function closeHelpModal() {
document.getElementById('helpModal').classList.remove('open');
document.getElementById('helpModal').style.display = 'none';
}
// ═════════════════════════════════════════════════════════════════════════════
// PAGE ROUTING
// ═════════════════════════════════════════════════════════════════════════════
function switchPage(page) {
// Check for dirty inline edits before navigating
const editingCells = document.querySelectorAll('.ae-cell.editing');
if (editingCells.length > 0) {
if (!confirm('You have unsaved inline edits. Discard them and navigate away?')) {
return;
}
// Force-cancel all edits by re-rendering
if (cachedAssets.length > 0) renderAssetTable();
}
AppState.currentPage = page;
document.querySelectorAll('.page').forEach(p => p.classList.add('hidden'));
const target = document.getElementById('page' + page.charAt(0).toUpperCase() + page.slice(1));
@@ -1245,6 +1385,7 @@ function switchPage(page) {
else if (page === 'activity') loadActivity();
else if (page === 'cantaloupe') loadCantaloupeSync();
else if (page === 'exifscanner') loadExifScanner();
else if (page === 'route') initRoutePage();
}
function closeDetail() {
if (AppState._prevPage) switchPage(AppState._prevPage);
@@ -1257,10 +1398,13 @@ function closeDetail() {
async function doLogin() {
const username = document.getElementById('loginUser').value.trim();
const password = document.getElementById('loginPass').value;
const loginBtn = document.getElementById('loginBtn');
if (!username || !password) {
showLoginError('Please enter username and password');
return;
}
loginBtn.disabled = true;
loginBtn.textContent = 'Signing in...';
try {
const data = await api('/api/auth/login', {
method: 'POST',
@@ -1276,6 +1420,9 @@ async function doLogin() {
switchPage('dashboard');
} catch (e) {
showLoginError(e.message);
} finally {
loginBtn.disabled = false;
loginBtn.textContent = 'Sign In';
}
}
@@ -1325,6 +1472,18 @@ async function initAuth() {
// DASHBOARD
// ═════════════════════════════════════════════════════════════════════════════
async function loadDashboard() {
// Show skeleton loading placeholders
document.getElementById('dashStats').innerHTML =
'<div class="skeleton-card skeleton-stat"><div class="skeleton-circle"></div><div class="skeleton-bar w60" style="margin:0 auto"></div><div class="skeleton-bar w40" style="margin:4px auto 0"></div></div>'.repeat(8);
document.getElementById('dashCategoryBars').innerHTML =
'<div class="skeleton-card"><div class="skeleton-bar w80"></div><div class="skeleton-bar w60"></div><div class="skeleton-bar w40"></div></div>';
document.getElementById('dashMakeBars').innerHTML =
'<div class="skeleton-card"><div class="skeleton-bar w80"></div><div class="skeleton-bar w60"></div><div class="skeleton-bar w40"></div></div>';
document.getElementById('dashStatusBars').innerHTML =
'<div class="skeleton-card"><div class="skeleton-bar w80"></div><div class="skeleton-bar w60"></div></div>';
document.getElementById('dashActivity').innerHTML =
'<div class="skeleton-card"><div class="skeleton-bar w80"></div><div class="skeleton-bar w60"></div><div class="skeleton-bar w40"></div></div>';
try {
const [stats, activity] = await Promise.all([
api('/api/stats'),
@@ -1332,12 +1491,13 @@ async function loadDashboard() {
]);
renderDashStats(stats || {});
renderDashCategoryBars(stats || {});
renderDashCategoryHealth(stats.category_health);
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>`;
`<div class="card" style="grid-column:1/-1;text-align:center;color:var(--red);">Failed to load dashboard — check your connection and try again. (${esc(e.message)})</div>`;
}
}
@@ -1383,6 +1543,25 @@ 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 renderDashCategoryHealth(health) {
const card = document.getElementById('dashCategoryHealthCard');
const el = document.getElementById('dashCategoryHealth');
if (!health || !health.unmapped || health.unmapped.length === 0) {
card.style.display = 'none';
return;
}
card.style.display = '';
el.innerHTML = `<div style="margin-bottom:8px;color:var(--text2);">${health.unmapped_count} asset categor${health.unmapped_count === 1 ? 'y is' : 'ies are'} not in the categories lookup table:</div>
${health.unmapped.map(u =>
`<div style="display:flex;justify-content:space-between;padding:2px 0;border-bottom:1px solid rgba(255,255,255,0.05);">
<span style="color:var(--red)">❓ ${esc(u.name)}</span>
<span style="color:var(--text2)">${u.count} asset${u.count === 1 ? '' : 's'}</span>
</div>`
).join('')}
<div style="margin-top:8px;font-size:12px;color:var(--text2);">
Go to <a href="#" onclick="switchPage('settings');return false" style="color:var(--accent);">Settings → Categories</a> to add them.
</div>`;
}
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');
@@ -1400,10 +1579,23 @@ function renderDashActivity(items) {
}
el.innerHTML = items.map(a => {
const icon = getActIcon(a.action);
let details = a.details || a.action || '';
// Try to pretty-print JSON details
try {
const parsed = JSON.parse(details);
if (typeof parsed === 'object' && parsed !== null) {
const parts = [];
if (parsed.assets_updated !== undefined) parts.push(`${parsed.assets_updated} asset(s) updated`);
if (parsed.fields_applied !== undefined) parts.push(`${parsed.fields_applied} field(s) applied`);
if (parsed.assets_created !== undefined) parts.push(`${parsed.assets_created} created`);
if (parts.length > 0) details = parts.join(', ');
else details = JSON.stringify(parsed).replace(/[{}"]/g, '').replace(/,/g, ', ');
}
} catch(e) { /* not JSON, show as-is */ }
return `<div class="activity-item">
<div class="act-icon ${icon}">${getActEmoji(a.action)}</div>
<div class="act-info">
<div class="act-text">${esc(a.details || a.action || '')} <span class="act-user">${esc(a.user_name || '')}</span></div>
<div class="act-text">${esc(details)} <span class="act-user">${esc(a.user_name || '')}</span></div>
<div class="act-time">${timeAgo(a.created_at)}</div>
</div>
</div>`;
@@ -1453,6 +1645,9 @@ async function loadAssets() {
const search = document.getElementById('assetSearch')?.value?.trim() || '';
const status = document.getElementById('assetStatusFilter')?.value || '';
// Show loading spinner
el.innerHTML = '<div class="loading-row"><div class="spinner"></div> Loading assets...</div>';
await loadAssetSettings();
let url = '/api/assets?limit=5000';
@@ -1466,7 +1661,7 @@ async function loadAssets() {
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>`;
el.innerHTML = `<div class="card"><div class="empty-state"><div class="es-icon">⚠️</div>Failed to load assets — check your connection and try refreshing the page. (${esc(e.message)})</div></div>`;
}
}
@@ -1548,6 +1743,9 @@ async function saveCell(assetId, field, value) {
const cell = document.querySelector(`[data-edit-id="${assetId}"][data-field="${field}"]`);
if (!cell) return;
// Add saving visual indicator
cell.classList.add('saving');
// Optimistic update
const asset = cachedAssets.find(a => a.id === assetId);
if (asset) {
@@ -1571,9 +1769,13 @@ async function saveCell(assetId, field, value) {
headers: { 'Content-Type': 'application/json' },
});
if (asset && updated) Object.assign(asset, updated);
// Remove saving class on success
document.querySelectorAll(`[data-edit-id="${assetId}"][data-field="${field}"]`).forEach(el => el.classList.remove('saving'));
showToast(`${field} saved`);
} catch (e) {
showToast(`✗ Failed to save ${field}: ${e.message}`, true);
// Remove saving class on error
document.querySelectorAll(`[data-edit-id="${assetId}"][data-field="${field}"]`).forEach(el => el.classList.remove('saving'));
showToast(`✗ Failed to save ${field}: ${e.message} — check your connection and try again`, true);
if (asset) renderAssetTable();
}
}
@@ -1621,7 +1823,7 @@ function renderAssetTable() {
});
if (sorted.length === 0) {
el.innerHTML = '<div class="card"><div class="empty-state"><div class="es-icon">📦</div>No assets found</div></div>';
el.innerHTML = '<div class="card"><div class="empty-state"><div class="es-icon">📦</div>No assets found — add one via the <strong>+</strong> button, or clear your search filters.</div></div>';
document.getElementById('assetPagination').innerHTML = '';
return;
}
@@ -2104,7 +2306,7 @@ function renderRoleBadges(roleStr) {
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>';
el.innerHTML = '<div class="card"><div class="empty-state"><div class="es-icon">👥</div>No users found — add one via the <strong>+ Add User</strong> button above.</div></div>';
return;
}
el.innerHTML = `<div class="table-wrap"><table class="data-table">
@@ -2264,7 +2466,7 @@ function renderCustomers() {
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>';
el.innerHTML = '<div class="card"><div class="empty-state"><div class="es-icon">🏢</div>No customers found — add one via the <strong>+ Add Customer</strong> button, or clear your search.</div></div>';
return;
}
el.innerHTML = filtered.map(c => {
@@ -3489,6 +3691,38 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('loginUser').addEventListener('keydown', e => {
if (e.key === 'Enter') document.getElementById('loginPass').focus();
});
// ── Keyboard shortcuts ──
// Ctrl+K or Ctrl+/ to focus asset search (when logged in and on assets page)
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === '/')) {
e.preventDefault();
if (!isLoggedIn()) return;
const searchInput = document.getElementById('assetSearch');
if (searchInput) {
switchPage('assets');
setTimeout(() => searchInput.focus(), 100);
}
}
// Escape to close modals
if (e.key === 'Escape') {
if (document.getElementById('modalOverlay').classList.contains('open')) {
closeModal(false);
}
if (document.getElementById('helpModal') && document.getElementById('helpModal').style.display !== 'none') {
closeHelpModal();
}
}
});
// Warn on page refresh/close if inline edits are in progress
window.addEventListener('beforeunload', (e) => {
const editingCells = document.querySelectorAll('.ae-cell.editing');
if (editingCells.length > 0) {
e.preventDefault();
e.returnValue = 'You have unsaved inline edits.';
}
});
});
// ═════════════════════════════════════════════════════════════════════════════
@@ -3585,19 +3819,12 @@ function renderExifPrevCard(p, idx) {
const matchBadge = matched === 'matched'
? `<span class="status-tag" style="background:var(--green-bg);color:var(--green);font-size:10px;">✓ ${esc(p.machine_id)}</span>`
: '<span class="status-tag" style="background:var(--card2);color:var(--text2);font-size:10px;">⏳ No match</span>';
const uploaderBadge = p.uploaded_by
? `<span class="status-tag" style="background:var(--accent-bg);color:var(--accent2);font-size:10px;">👤 ${esc(p.uploaded_by)}</span>`
: '';
const thumb = p.id ? `/api/admin/exif/photos/${p.id}/file` : '';
return `<div style="background:var(--card);border-radius:var(--radius-sm);padding:10px;margin-bottom:6px;border-left:3px solid ${hasGps ? 'var(--green)' : 'var(--amber)'};">
<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>`;
return `<div style="background:var(--card);border-radius:var(--radius-sm);padding:10px;margin-bottom:6px;border-left:3px solid ${hasGps ? 'var(--green)' : 'var(--amber)'};">\n <div style="display:flex;gap:8px;">\n ${thumb ? `<img src="${thumb}" style="width:48px;height:48px;border-radius:6px;object-fit:cover;flex-shrink:0;" onclick="window.open('${thumb}','_blank')">` : ''}\n <div style="flex:1;min-width:0;">\n <div style="font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${esc(p.orig_filename || 'photo.jpg')}</div>\n <div style="display:flex;gap:4px;margin-top:4px;flex-wrap:wrap;">\n ${gpsBadge} ${matchBadge} ${uploaderBadge}
</div>\n <div style="font-size:10px;color:var(--text3);margin-top:4px;">${p.created_at ? new Date(p.created_at).toLocaleString() : ''}</div>\n </div>\n </div>\n </div>`;
}
// ═════════════════════════════════════════════════════════════════════════════
@@ -4029,6 +4256,19 @@ function openExifLightbox(src) {
function closeExifLightbox() {
if (exifLightboxEl) exifLightboxEl.style.display = 'none';
}
// ═════════════════════════════════════════════════════════════════════════════
// ROUTE PLANNER
// ═════════════════════════════════════════════════════════════════════════════
function initRoutePage() {
const iframe = document.getElementById('routeFrame');
if (!iframe) return;
// Force-reload on each visit so the route data is always fresh
if (iframe.dataset.loaded) {
iframe.src = iframe.src;
}
iframe.dataset.loaded = '1';
}
</script>
</body>
</html>