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:
+17
-2
@@ -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
@@ -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 & 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>
|
||||
|
||||
Reference in New Issue
Block a user