diff --git a/admin_server.py b/admin_server.py
index b00ce83..e2afba3 100644
--- a/admin_server.py
+++ b/admin_server.py
@@ -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,
}
diff --git a/static/index.html b/static/index.html
index 9796866..5cda65c 100644
--- a/static/index.html
+++ b/static/index.html
@@ -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;
+ }
@@ -731,7 +783,6 @@
-
Default: admin / changeme
@@ -752,33 +803,39 @@
+
+
β οΈ Category Health
+
+
By Make
@@ -996,7 +1057,7 @@
+
+
+
+
@@ -1129,6 +1195,17 @@
+
+
+
+
β Help & Quick Tips
+
+
+
+
+
+
+
@@ -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 = `
+
+
β¨οΈ Keyboard Shortcuts
+
+ Ctrl+KFocus asset search
+ Ctrl+/Focus asset search
+ EscClose modals / cancel inline edit
+ EnterSave inline edit cell
+
+
+
+
βοΈ Inline Editing (Assets)
+
+ - Click any cell in the assets table to start editing
+ - Press Enter to save the cell
+ - Press Esc to cancel editing
+ - Click column headers to sort (ascending / descending)
+ - Empty cells have a β amber left border
+ - Use the Batch Lookup button to find business names from addresses
+
+
+
+
π₯ Cantaloupe Sync (Import)
+
+ - Upload an Excel file exported from Cantaloupe
+ - Review pending batches β approve or reject changes
+ - Use Review Changes to see per-field diffs before approving
+ - Blank-fill changes (populating empty fields) are auto-selected for convenience
+ - Machine replacements need manual approval per asset
+
+
+
+
πΈ EXIF Scanner
+
+ - Drop photos or click to upload β GPS data is read from EXIF
+ - Choose OCR engine: Tesseract (fast, local), LLM, or Google Gemini
+ - Sticker mode scans a 4-digit sticker on the machine
+ - Bulk process extracts Machine IDs and matches them to assets
+ - Use Push GPS to write GPS coordinates to matched assets
+
+
+ `;
+ 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 =
+ ''.repeat(8);
+ document.getElementById('dashCategoryBars').innerHTML =
+ '';
+ document.getElementById('dashMakeBars').innerHTML =
+ '';
+ document.getElementById('dashStatusBars').innerHTML =
+ '';
+ document.getElementById('dashActivity').innerHTML =
+ '';
+
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 =
- `Failed to load: ${esc(e.message)}
`;
+ `Failed to load dashboard β check your connection and try again. (${esc(e.message)})
`;
}
}
@@ -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 = `${health.unmapped_count} asset categor${health.unmapped_count === 1 ? 'y is' : 'ies are'} not in the categories lookup table:
+ ${health.unmapped.map(u =>
+ `
+ β ${esc(u.name)}
+ ${u.count} asset${u.count === 1 ? '' : 's'}
+
`
+ ).join('')}
+ `;
+}
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 `
${getActEmoji(a.action)}
-
${esc(a.details || a.action || '')} ${esc(a.user_name || '')}
+
${esc(details)} ${esc(a.user_name || '')}
${timeAgo(a.created_at)}
`;
@@ -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 = '';
+
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 = `β οΈ
Failed to load assets: ${esc(e.message)}
`;
+ el.innerHTML = `β οΈ
Failed to load assets β check your connection and try refreshing the page. (${esc(e.message)})
`;
}
}
@@ -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 = '';
+ el.innerHTML = 'π¦
No assets found β add one via the
+ button, or clear your search filters.
';
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 = '';
+ el.innerHTML = 'π₯
No users found β add one via the
+ Add User button above.
';
return;
}
el.innerHTML = `
@@ -2264,7 +2466,7 @@ function renderCustomers() {
document.getElementById('custBadgeNav').textContent = cachedCustomers.length;
if (filtered.length === 0) {
- el.innerHTML = '';
+ el.innerHTML = 'π’
No customers found β add one via the
+ Add Customer button, or clear your search.
';
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'
? `β ${esc(p.machine_id)}`
: 'β³ No match';
+ const uploaderBadge = p.uploaded_by
+ ? `π€ ${esc(p.uploaded_by)}`
+ : '';
const thumb = p.id ? `/api/admin/exif/photos/${p.id}/file` : '';
- return `
-
- ${thumb ? `

` : ''}
-
-
${esc(p.orig_filename || 'photo.jpg')}
-
- ${gpsBadge} ${matchBadge}
-
-
${p.created_at ? new Date(p.created_at).toLocaleString() : ''}
-
-
-
`;
+ return `\n
\n ${thumb ? `

` : ''}\n
\n
${esc(p.orig_filename || 'photo.jpg')}
\n
\n ${gpsBadge} ${matchBadge} ${uploaderBadge}
+
\n
${p.created_at ? new Date(p.created_at).toLocaleString() : ''}
\n
\n
\n
`;
}
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@@ -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';
+}