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:
+286
-28
@@ -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>';
|
||||
|
||||
Reference in New Issue
Block a user