diff --git a/server.py b/server.py index 2136be4..7aa990e 100644 --- a/server.py +++ b/server.py @@ -798,6 +798,45 @@ def health(): return {"status": "ok"} +# ─── Customer & Location listing (for filter dropdowns) ───────────────────── + + +@app.get("/api/customers") +def list_customers(): + conn = get_db() + rows = conn.execute( + "SELECT id, name FROM customers ORDER BY name" + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +@app.get("/api/locations") +def list_locations(customer_id: Optional[int] = Query(None)): + conn = get_db() + if customer_id: + rows = conn.execute( + "SELECT id, customer_id, name FROM locations WHERE customer_id = ? ORDER BY name", + (customer_id,), + ).fetchall() + else: + rows = conn.execute( + "SELECT id, customer_id, name FROM locations ORDER BY customer_id, name" + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +@app.get("/api/users") +def list_users(): + conn = get_db() + rows = conn.execute( + "SELECT id, username, role FROM users ORDER BY username" + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + # ─── Task 4: POST /api/assets ────────────────────────────────────────────── diff --git a/static/index.html b/static/index.html index 11dc25c..e377f8d 100644 --- a/static/index.html +++ b/static/index.html @@ -663,8 +663,58 @@ .import-btn { white-space: nowrap; flex-shrink: 0; } /* ═══════════════════════════════════════════════════════════════════════ - CHECKIN HISTORY ITEMS + FILTER PANEL (collapsible) ═══════════════════════════════════════════════════════════════════════ */ + .filter-toggle { + display: flex; align-items: center; gap: 6px; + padding: 7px 14px; border-radius: 20px; border: 1px solid var(--border); + background: transparent; color: var(--text2); cursor: pointer; + font-size: 12px; font-weight: 500; white-space: nowrap; + transition: all 0.15s; + } + .filter-toggle:hover { border-color: var(--accent); color: var(--text); } + .filter-toggle.active { border-color: var(--accent); color: var(--accent2); background: var(--accent-bg); } + .filter-toggle .ft-count { + background: var(--accent); color: #fff; font-size: 10px; font-weight: 700; + padding: 1px 6px; border-radius: 8px; margin-left: 2px; + } + .filter-panel { + display: none; margin-bottom: 8px; + background: var(--card2); border: 1px solid var(--border); + border-radius: var(--radius-sm); padding: 12px; + } + .filter-panel.open { display: block; animation: fadeIn 0.15s; } + .filter-panel .fp-row { display: flex; gap: 8px; margin-bottom: 8px; } + .filter-panel .fp-row:last-child { margin-bottom: 0; } + .filter-panel .fp-row select { flex: 1; margin-bottom: 0; font-size: 12px; padding: 7px 10px; } + .filter-panel .fp-label { + font-size: 10px; font-weight: 600; text-transform: uppercase; + letter-spacing: 0.04em; color: var(--text3); margin-bottom: 3px; + } + .filter-panel .fp-actions { display: flex; gap: 6px; margin-top: 4px; } + .filter-panel .fp-actions button { font-size: 11px; padding: 5px 12px; } + + /* Active filter tags */ + .active-filters { + display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 6px; + } + .active-filters:empty { display: none; } + .filter-tag { + display: inline-flex; align-items: center; gap: 4px; + font-size: 11px; padding: 3px 10px; border-radius: 14px; + background: var(--accent-bg); color: var(--accent2); + border: 1px solid var(--accent); + font-weight: 500; + } + .filter-tag .ft-remove { + cursor: pointer; font-size: 13px; line-height: 1; margin-left: 2px; + opacity: 0.7; + } + .filter-tag .ft-remove:hover { opacity: 1; } + + .result-count { + font-size: 12px; color: var(--text2); margin-bottom: 4px; + } .checkin-item { padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.04); font-size: 13px; @@ -1054,13 +1104,56 @@
-
+
+
+ +
+
+
+
Customer
+ +
+
+
Location
+ +
+
+
+
+
Status
+ +
+
+
Assigned To
+ +
+
+
+ + +
+
+
+
@@ -3007,50 +3100,213 @@ function parkName(key) { return PARK_NAMES[key] || key; } // ── Filter state ────────────────────────────────────────────────────── - let assetFilters = { category: null, status: null, make: null, disney_park: null, q: '' }; + let assetFilters = { category: null, status: null, make: null, disney_park: null, q: '', + customer_id: null, location_id: null, assigned_to: null }; let assetOffset = 0; const ASSET_PAGE_SIZE = 2000; let assetTotal = 0; let assetDebounce = null; let cachedAssets = []; + let filterDropdownsLoaded = false; + + function toggleFilterPanel() { + const panel = document.getElementById('filterPanel'); + const toggle = document.getElementById('filterToggle'); + panel.classList.toggle('open'); + toggle.classList.toggle('active', panel.classList.contains('open')); + if (panel.classList.contains('open') && !filterDropdownsLoaded) { + loadFilterDropdowns(); + } + } + + async function loadFilterDropdowns() { + if (filterDropdownsLoaded) return; + filterDropdownsLoaded = true; + try { + // Load customers + const customers = await api('/api/customers'); + const custSel = document.getElementById('filterCustomer'); + custSel.innerHTML = '' + + customers.map(c => ``).join(''); + // Load users for assigned_to filter + const users = await api('/api/users'); + const assnSel = document.getElementById('filterAssignedTo'); + assnSel.innerHTML = '' + + users.filter(u => u.role === 'technician' || u.role === 'admin') + .map(u => ``).join(''); + } catch (e) { + // silent fail — dropdowns just show defaults + } + } async function loadAssets() { const q = document.getElementById('assetSearch').value.trim(); assetFilters.q = q; const clearBtn = document.getElementById('clearSearch'); if (clearBtn) clearBtn.style.display = q ? 'block' : 'none'; + renderActiveFilterTags(); clearTimeout(assetDebounce); assetDebounce = setTimeout(_loadAssets, 200); } - async function _loadAssets() { - const params = new URLSearchParams(); - if (assetFilters.category) params.set('category', assetFilters.category); - if (assetFilters.status) params.set('status', assetFilters.status); - if (assetFilters.make) params.set('make', assetFilters.make); - if (assetFilters.q) params.set('q', assetFilters.q); - params.set('limit', '100'); - try { - const assets = await api('/api/assets?' + params.toString()); - cachedAssets = assets; - renderAssetList(assets); - renderFilterPills(assets); - } catch (e) { - document.getElementById('assetList').innerHTML = - '
Failed to load assets
'; - } + function clearAssetSearch() { + document.getElementById('assetSearch').value = ''; + assetFilters.q = ''; + document.getElementById('clearSearch').style.display = 'none'; + assetOffset = 0; + loadAssets(); } - // _loadAssets patched by Phase M to support assigned_to filter - var __loadAssets_orig = _loadAssets; + function onFilterChange() { + var custEl = document.getElementById('filterCustomer'); + var locEl = document.getElementById('filterLocation'); + var stEl = document.getElementById('filterStatus'); + var assnEl = document.getElementById('filterAssignedTo'); + + assetFilters.customer_id = custEl.value || null; + assetFilters.status = stEl.value || null; + assetFilters.assigned_to = assnEl.value || null; + + var prevCust = locEl.dataset.customerId; + if (custEl.value !== prevCust) { + locEl.innerHTML = ''; + locEl.disabled = true; + assetFilters.location_id = null; + if (custEl.value) { + loadLocationsForCustomer(custEl.value); + } + } else { + assetFilters.location_id = locEl.value || null; + } + locEl.dataset.customerId = custEl.value; + + assetOffset = 0; + renderActiveFilterTags(); + _loadAssets(); + } + + async function loadLocationsForCustomer(customerId) { + try { + var locations = await api('/api/locations?customer_id=' + customerId); + var locEl = document.getElementById('filterLocation'); + locEl.innerHTML = '' + + locations.map(function(l) { return ''; }).join(''); + locEl.disabled = false; + } catch (e) {} + } + + function getActiveFilterCount() { + var count = 0; + if (assetFilters.category) count++; + if (assetFilters.status) count++; + if (assetFilters.make) count++; + if (assetFilters.disney_park) count++; + if (assetFilters.customer_id) count++; + if (assetFilters.location_id) count++; + if (assetFilters.assigned_to) count++; + if (assetFilters.q) count++; + return count; + } + + function renderActiveFilterTags() { + var el = document.getElementById('activeFilters'); + var countEl = document.getElementById('filterCount'); + var toggle = document.getElementById('filterToggle'); + var count = getActiveFilterCount(); + + countEl.textContent = count; + countEl.style.display = count > 0 ? 'inline' : 'none'; + if (toggle) toggle.classList.toggle('active', count > 0); + + var tags = []; + if (assetFilters.q) tags.push({key:'q', label:'\uD83D\uDD0D "' + assetFilters.q + '"'}); + if (assetFilters.category) tags.push({key:'category', label:'\uD83D\uDCC2 ' + assetFilters.category}); + if (assetFilters.status) tags.push({key:'status', label: statusIcon(assetFilters.status) + ' ' + assetFilters.status}); + if (assetFilters.make) tags.push({key:'make', label:'\uD83C\uDFED ' + assetFilters.make}); + if (assetFilters.disney_park) tags.push({key:'disney_park', label: parkIcon(assetFilters.disney_park) + ' ' + parkName(assetFilters.disney_park)}); + if (assetFilters.customer_id) { + var custSel = document.getElementById('filterCustomer'); + var txt = custSel.options[custSel.selectedIndex] ? custSel.options[custSel.selectedIndex].text : 'Customer'; + tags.push({key:'customer_id', label:'\uD83C\uDFE2 ' + txt}); + } + if (assetFilters.location_id) { + var locSel = document.getElementById('filterLocation'); + var txt = locSel.options[locSel.selectedIndex] ? locSel.options[locSel.selectedIndex].text : 'Location'; + tags.push({key:'location_id', label:'\uD83D\uDCCD ' + txt}); + } + if (assetFilters.assigned_to) tags.push({key:'assigned_to', label:'\uD83D\uDC64 ' + assetFilters.assigned_to}); + + el.innerHTML = tags.map(function(t) { + return '' + esc(t.label) + ' \u2715'; + }).join(''); + } + + function statusIcon(s) { + if (s === 'active') return '\uD83D\uDFE2'; + if (s === 'maintenance') return '\uD83D\uDFE1'; + if (s === 'retired') return '\uD83D\uDD34'; + return ''; + } + + function removeFilter(key) { + if (key === 'q') { + document.getElementById('assetSearch').value = ''; + assetFilters.q = ''; + document.getElementById('clearSearch').style.display = 'none'; + } else if (key === 'category') { + assetFilters.category = null; + } else if (key === 'status') { + document.getElementById('filterStatus').value = ''; + assetFilters.status = null; + } else if (key === 'make') { + assetFilters.make = null; + } else if (key === 'disney_park') { + assetFilters.disney_park = null; + } else if (key === 'customer_id') { + document.getElementById('filterCustomer').value = ''; + document.getElementById('filterLocation').innerHTML = ''; + document.getElementById('filterLocation').disabled = true; + assetFilters.customer_id = null; + assetFilters.location_id = null; + } else if (key === 'location_id') { + document.getElementById('filterLocation').value = ''; + assetFilters.location_id = null; + } else if (key === 'assigned_to') { + document.getElementById('filterAssignedTo').value = ''; + assetFilters.assigned_to = null; + } + renderActiveFilterTags(); + assetOffset = 0; + _loadAssets(); + } + + function clearAllFilters() { + assetFilters = { category: null, status: null, make: null, disney_park: null, q: '', + customer_id: null, location_id: null, assigned_to: null }; + document.getElementById('assetSearch').value = ''; + document.getElementById('clearSearch').style.display = 'none'; + document.getElementById('filterCustomer').value = ''; + document.getElementById('filterLocation').innerHTML = ''; + document.getElementById('filterLocation').disabled = true; + document.getElementById('filterStatus').value = ''; + document.getElementById('filterAssignedTo').value = ''; + renderActiveFilterTags(); + assetOffset = 0; + _loadAssets(); + } + + // _loadAssets with all filters + var __loadAssets_orig = null; _loadAssets = async function() { var params = new URLSearchParams(); - if (assetFilters.category) params.set('category', assetFilters.category); - if (assetFilters.status) params.set('status', assetFilters.status); - if (assetFilters.make) params.set('make', assetFilters.make); - if (assetFilters.disney_park) params.set('disney_park', assetFilters.disney_park); - if (assetFilters.q) params.set('q', assetFilters.q); - if (assetFilters.assigned_to) params.set('assigned_to', assetFilters.assigned_to); + if (assetFilters.category) params.set('category', assetFilters.category); + if (assetFilters.status) params.set('status', assetFilters.status); + if (assetFilters.make) params.set('make', assetFilters.make); + if (assetFilters.disney_park) params.set('disney_park', assetFilters.disney_park); + if (assetFilters.q) params.set('q', assetFilters.q); + if (assetFilters.customer_id) params.set('customer_id', assetFilters.customer_id); + if (assetFilters.location_id) params.set('location_id', assetFilters.location_id); + if (assetFilters.assigned_to) params.set('assigned_to', assetFilters.assigned_to); params.set('limit', String(ASSET_PAGE_SIZE)); params.set('offset', String(assetOffset)); @@ -3061,6 +3317,8 @@ renderAssetList(assets); renderFilterPills(assets); renderPagination(); + var countEl = document.getElementById('resultCount'); + countEl.textContent = assetTotal + ' asset' + (assetTotal !== 1 ? 's' : '') + ' found'; } catch (e) { document.getElementById('assetList').innerHTML = '
Failed to load assets
';