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 @@
-
+
-
@@ -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
';