feat: improved asset filter UX - collapsible filter panel, customer/location cascade, status dropdown, assigned_to filter, active filter tags, result count

Backend:
- GET /api/customers — lists all customers for filter dropdown
- GET /api/locations?customer_id=X — cascading locations per customer
- GET /api/users — lists users for assigned_to filter

Frontend:
- Collapsible 'Filters' panel with Customer→Location cascade, Status, Assigned To
- Active filter tags showing what's currently filtering, removable individually
- 'Clear all filters' button to reset all at once
- Result count display ('X assets found')
- Better search placeholder showing searchable fields
- Filter count badge on the toggle button
This commit is contained in:
2026-05-22 18:15:35 -04:00
parent 35ccebb0f0
commit d98a791c96
2 changed files with 325 additions and 28 deletions
+286 -28
View File
@@ -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 @@
<!-- ── List View ────────────────────────────────────────────────────── -->
<div id="assetsListView">
<div class="search-bar">
<input id="assetSearch" type="text" class="input-field" placeholder="Search assets..." oninput="loadAssets()">
<input id="assetSearch" type="text" class="input-field" placeholder="Search name, machine ID, serial, description..." oninput="loadAssets()">
<button class="clear-btn" id="clearSearch" onclick="clearAssetSearch()"></button>
</div>
<div class="assets-toolbar">
<div style="display:flex;gap:6px;align-items:flex-start;margin-bottom:8px;">
<button class="filter-toggle" id="filterToggle" onclick="toggleFilterPanel()">
🎯 Filters <span id="filterCount" class="ft-count" style="display:none;">0</span>
</button>
<div class="filter-scroll" id="filterPills"></div>
<button class="btn btn-outline btn-sm import-btn" onclick="showImportView()">📥 Import</button>
</div>
<!-- Filter panel (collapsible) -->
<div class="filter-panel" id="filterPanel">
<div class="fp-row">
<div style="flex:1">
<div class="fp-label">Customer</div>
<select id="filterCustomer" class="input-field" onchange="onFilterChange()">
<option value="">All customers</option>
</select>
</div>
<div style="flex:1">
<div class="fp-label">Location</div>
<select id="filterLocation" class="input-field" onchange="onFilterChange()" disabled>
<option value="">All locations</option>
</select>
</div>
</div>
<div class="fp-row">
<div style="flex:1">
<div class="fp-label">Status</div>
<select id="filterStatus" class="input-field" onchange="onFilterChange()">
<option value="">All statuses</option>
<option value="active">🟢 Active</option>
<option value="maintenance">🟡 Maintenance</option>
<option value="retired">🔴 Retired</option>
</select>
</div>
<div style="flex:1">
<div class="fp-label">Assigned To</div>
<select id="filterAssignedTo" class="input-field" onchange="onFilterChange()">
<option value="">Anyone</option>
</select>
</div>
</div>
<div class="fp-actions">
<button class="btn btn-outline btn-xs" onclick="clearAllFilters()" style="flex:1;">✕ Clear all filters</button>
<button class="btn btn-outline btn-xs" onclick="toggleFilterPanel()" style="flex:1;">✓ Done</button>
</div>
</div>
<div class="active-filters" id="activeFilters"></div>
<div class="result-count" id="resultCount"></div>
<div id="assetList"></div>
<div id="pagination" class="pagination-bar" style="display:none;margin-top:8px;"></div>
</div>
@@ -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 = '<option value="">All customers</option>' +
customers.map(c => `<option value="${c.id}">${esc(c.name)}</option>`).join('');
// Load users for assigned_to filter
const users = await api('/api/users');
const assnSel = document.getElementById('filterAssignedTo');
assnSel.innerHTML = '<option value="">Anyone</option>' +
users.filter(u => u.role === 'technician' || u.role === 'admin')
.map(u => `<option value="${esc(u.username)}">${esc(u.username)}</option>`).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 =
'<div class="empty-state">Failed to load assets</div>';
}
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 = '<option value="">All locations</option>';
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 = '<option value="">All locations</option>' +
locations.map(function(l) { return '<option value="' + l.id + '">' + esc(l.name) + '</option>'; }).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 '<span class="filter-tag">' + esc(t.label) + ' <span class="ft-remove" onclick="removeFilter(\'' + t.key + '\')">\u2715</span></span>';
}).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 = '<option value="">All locations</option>';
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 = '<option value="">All locations</option>';
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 =
'<div class="empty-state">Failed to load assets</div>';