Add Disney park badges, filter pills, editable classification, and service entrances to asset detail view

This commit is contained in:
2026-05-22 17:53:32 -04:00
parent bb0f6b81e7
commit cce5cf2b5f
+139 -11
View File
@@ -221,6 +221,12 @@
letter-spacing: 0.05em; color: var(--text2); margin-bottom: 10px;
}
.dropdown-item {
padding: 8px 12px; border-radius: 6px; cursor: pointer; font-size: 13px;
transition: background .15s;
}
.dropdown-item:hover { background: var(--hover); }
/* ═══════════════════════════════════════════════════════════════════════
BUTTONS
═══════════════════════════════════════════════════════════════════════ */
@@ -416,6 +422,11 @@
.status-tag.maintenance { background: var(--amber-bg); color: var(--amber); }
.status-tag.retired { background: var(--red-bg); color: var(--red); }
.disney-tag {
display: inline-block; font-size: 10px; padding: 2px 8px; border-radius: 4px;
font-weight: 600; background: #8b5cf622; color: #8b5cf6;
}
/* ═══════════════════════════════════════════════════════════════════════
SEARCH BAR
═══════════════════════════════════════════════════════════════════════ */
@@ -1089,6 +1100,12 @@
<div id="detailAccessFields"></div>
</div>
<!-- Service Entrances -->
<div class="card" id="detailServiceEntrances" style="display:none;">
<div class="card-title">🚚 Service Entrances</div>
<div id="detailSEFields"></div>
</div>
<!-- Check-in History -->
<div class="card">
<div class="card-title">Check-in History <span id="checkinCount" style="color:var(--accent2);"></span></div>
@@ -1322,6 +1339,30 @@
function isLoggedIn() { return !!AppState.authToken; }
function isTechnician() { return AppState.currentUser && AppState.currentUser.role === 'technician'; }
// ── Dropdown helper ────────────────────────────────────────────────────
let _activeDropdown = null;
function showDropdown(anchorEl, itemsHtml) {
closeDropdown();
const rect = anchorEl.getBoundingClientRect();
const el = document.createElement('div');
el.id = '__dropdown';
el.style.cssText = 'position:fixed;z-index:9999;background:var(--card-bg);border:1px solid var(--border);border-radius:8px;padding:6px;box-shadow:0 8px 24px rgba(0,0,0,.4);min-width:200px';
el.style.top = (rect.bottom + 6) + 'px';
el.style.left = Math.min(rect.left, window.innerWidth - 220) + 'px';
el.innerHTML = itemsHtml;
document.body.appendChild(el);
_activeDropdown = el;
// Click outside to close
setTimeout(() => document.addEventListener('click', _closeDropdownHandler), 10);
}
function _closeDropdownHandler() { closeDropdown(); document.removeEventListener('click', _closeDropdownHandler); }
function closeDropdown() {
if (_activeDropdown) {
_activeDropdown.remove();
_activeDropdown = null;
}
}
function updateUserUI() {
const u = AppState.currentUser;
const initial = u ? u.username.charAt(0).toUpperCase() : '?';
@@ -2966,8 +3007,13 @@
return '📦';
}
const PARK_ICONS = {'magic-kingdom':'🏰','epcot':'🌍','hollywood-studios':'🎬','animal-kingdom':'🌿','disney-springs':'🛍️','resort':'🏨','office':'🏢','other':'📍'};
const PARK_NAMES = {'magic-kingdom':'Magic Kingdom','epcot':'Epcot','hollywood-studios':'Hollywood Studios','animal-kingdom':'Animal Kingdom','disney-springs':'Disney Springs','resort':'Resort','office':'Office','other':'Other'};
function parkIcon(key) { return PARK_ICONS[key] || '📍'; }
function parkName(key) { return PARK_NAMES[key] || key; }
// ── Filter state ──────────────────────────────────────────────────────
let assetFilters = { category: null, status: null, make: null, q: '' };
let assetFilters = { category: null, status: null, make: null, disney_park: null, q: '' };
let assetDebounce = null;
let cachedAssets = [];
@@ -3004,6 +3050,7 @@
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.q) params.set('q', assetFilters.q);
if (assetFilters.assigned_to) params.set('assigned_to', assetFilters.assigned_to);
params.set('limit', '100');
@@ -3014,6 +3061,9 @@
var filtered = assetFilters.assigned_to
? assets.filter(function(a) { return a.assigned_to === assetFilters.assigned_to; })
: assets;
filtered = assetFilters.disney_park
? filtered.filter(function(a) { return a.disney_park === assetFilters.disney_park; })
: filtered;
renderAssetList(filtered);
renderFilterPills(assets);
} catch (e) {
@@ -3037,9 +3087,10 @@
<div class="ai-info">
<div class="ai-name">${esc(a.name)}</div>
<div class="ai-meta">
${esc(a.machine_id)}${a.serial_number ? ' · S/N: ' + esc(a.serial_number) : ''}
${a.make ? ' · ' + esc(a.make) : ''}
· <span class="status-tag ${a.status || 'active'}">${a.status || 'active'}</span>
${esc(a.machine_id)}${a.serial_number ? ' · S/N: ' + esc(a.serial_number) : ''}
${a.make ? ' · ' + esc(a.make) : ''}
${a.disney_park ? ' · <span class="disney-tag">' + parkIcon(a.disney_park) + ' ' + parkName(a.disney_park) + '</span>' : ''}
· <span class="status-tag ${a.status || 'active'}">${a.status || 'active'}</span>
</div>
</div>
<div class="ai-arrow"></div>
@@ -3048,37 +3099,45 @@
}
function renderFilterPills(assets) {
const cats = new Set(), stats = new Set(), makes = new Set();
const cats = new Set(), stats = new Set(), makes = new Set(), parks = new Set();
assets.forEach(a => {
if (a.category) cats.add(a.category);
if (a.status) stats.add(a.status);
if (a.make) makes.add(a.make);
if (a.disney_park) parks.add(a.disney_park);
});
let html = '<span class="pill' + (!assetFilters.category && !assetFilters.status && !assetFilters.make ? ' active' : '') + '" onclick="setFilter(null,null,null)">All</span>';
let html = '<span class="pill' + (!assetFilters.category && !assetFilters.status && !assetFilters.make && !assetFilters.disney_park ? ' active' : '') + '" onclick="setFilter(null,null,null,null)">All</span>';
// Category pills
[...cats].sort().forEach(c => {
html += `<span class="pill${assetFilters.category === c ? ' active' : ''}" onclick="setFilter('${esc(c)}',null,null)">📂 ${esc(c)}</span>`;
html += `<span class="pill${assetFilters.category === c ? ' active' : ''}" onclick="setFilter('${esc(c)}',null,null,null)">📂 ${esc(c)}</span>`;
});
// Status pills
[...stats].sort().forEach(s => {
html += `<span class="pill${assetFilters.status === s ? ' active' : ''}" onclick="setFilter(null,'${esc(s)}',null)">${s === 'active' ? '🟢' : s === 'maintenance' ? '🟡' : '🔴'} ${s}</span>`;
html += `<span class="pill${assetFilters.status === s ? ' active' : ''}" onclick="setFilter(null,'${esc(s)}',null,null)">${s === 'active' ? '🟢' : s === 'maintenance' ? '🟡' : '🔴'} ${s}</span>`;
});
// Make pills
[...makes].sort().forEach(m => {
html += `<span class="pill${assetFilters.make === m ? ' active' : ''}" onclick="setFilter(null,null,'${esc(m)}')">🏭 ${esc(m)}</span>`;
html += `<span class="pill${assetFilters.make === m ? ' active' : ''}" onclick="setFilter(null,null,'${esc(m)}',null)">🏭 ${esc(m)}</span>`;
});
// Disney Park pills
const sortedParks = [...parks].sort((a,b) => (PARK_NAMES[a]||a).localeCompare(PARK_NAMES[b]||b));
sortedParks.forEach(p => {
html += `<span class="pill${assetFilters.disney_park === p ? ' active' : ''}" onclick="setFilter(null,null,null,'${esc(p)}')">${parkIcon(p)} ${parkName(p)}</span>`;
});
document.getElementById('filterPills').innerHTML = html;
}
function setFilter(category, status, make) {
function setFilter(category, status, make, disney_park) {
assetFilters.category = category;
assetFilters.status = status;
assetFilters.make = make;
assetFilters.disney_park = disney_park;
loadAssets();
}
@@ -3132,7 +3191,7 @@
document.getElementById('detailName').textContent = a.name;
document.getElementById('detailMeta').innerHTML =
`${esc(a.machine_id)} · <span class="status-tag ${a.status || 'active'}">${a.status || 'active'}</span>`;
`${esc(a.machine_id)} · <span class="status-tag ${a.status || 'active'}">${a.status || 'active'}</span>${a.disney_park ? ' · <span class="disney-tag">' + parkIcon(a.disney_park) + ' ' + parkName(a.disney_park) + '</span>' : ''}`;
// Main details
document.getElementById('detailFields').innerHTML = [
@@ -3146,6 +3205,21 @@
df('Status', statusBadge(a.status)),
].filter(Boolean).join('');
// Disney Park badge
if (a.disney_park) {
const parkIcons = {'magic-kingdom':'🏰','epcot':'🌍','hollywood-studios':'🎬','animal-kingdom':'🌿','disney-springs':'🛍️','resort':'🏨','office':'🏢','other':'📍'};
const parkNames = {'magic-kingdom':'Magic Kingdom','epcot':'Epcot','hollywood-studios':'Hollywood Studios','animal-kingdom':'Animal Kingdom','disney-springs':'Disney Springs','resort':'Resort','office':'Office','other':'Other'};
const parkColors = {'magic-kingdom':'#8b5cf6','epcot':'#06b6d4','hollywood-studios':'#f59e0b','animal-kingdom':'#22c55e','disney-springs':'#ec4899','resort':'#3b82f6','office':'#6b7280','other':'#95a5a6'};
const icon = parkIcons[a.disney_park] || '📍';
const name = parkNames[a.disney_park] || a.disney_park;
const color = parkColors[a.disney_park] || '#95a5a6';
document.getElementById('detailFields').innerHTML +=
'<div class="detail-field" style="border:0;padding:4px 0">' +
'<div class="df-label">Disney Park</div>' +
'<span id="disneyParkBadge" style="display:inline-flex;align-items:center;gap:6px;background:' + color + '22;color:' + color + ';border:1px solid ' + color + '44;border-radius:6px;padding:4px 10px;font-size:13px;font-weight:600;cursor:pointer" onclick="changeDisneyPark(' + a.id + ')">' +
icon + ' ' + esc(name) + ' ✏️</span></div>';
}
// Customer / Location / Technician
if (a.customer_name) {
document.getElementById('detailFields').innerHTML += df('Customer',
@@ -3221,6 +3295,9 @@
accessCard.style.display = 'none';
}
// Service Entrances
await loadServiceEntrances(a);
await loadCheckinHistory(id);
} catch (e) {
showToast(e.message, true);
@@ -3431,6 +3508,57 @@
}
}
async function loadServiceEntrances(a) {
const el = document.getElementById('detailServiceEntrances');
const fields = document.getElementById('detailSEFields');
if (!a.location_id) {
el.style.display = 'none';
return;
}
try {
const ses = await api('/api/locations/' + a.location_id + '/service-entrances');
if (!ses || !ses.length) {
el.style.display = 'none';
return;
}
el.style.display = 'block';
fields.innerHTML = ses.map(se => `
<div class="detail-field">
<div class="df-label">${esc(se.name || 'Service Entrance')}</div>
<div style="font-size:13px;">
📍 ${Number(se.latitude).toFixed(5)}, ${Number(se.longitude).toFixed(5)}
${se.notes ? '<br>📝 ' + esc(se.notes) : ''}
</div>
</div>
`).join('');
} catch (e) {
el.style.display = 'none';
}
}
async function changeDisneyPark(assetId) {
const parks = ['magic-kingdom','epcot','hollywood-studios','animal-kingdom','disney-springs','resort','office','other'];
const current = document.getElementById('disneyParkBadge')?.textContent?.trim() || '';
const options = parks.map(p => '<div class="dropdown-item" onclick="setDisneyPark(' + assetId + ',' + "'" + p + "'" + ')">' + parkIcon(p) + ' ' + parkName(p) + (current.includes(parkName(p)) ? ' ✓' : '') + '</div>').join('');
const noneOpt = '<div class="dropdown-item" onclick="setDisneyPark(' + assetId + ',null)">❌ Clear</div>';
showDropdown(document.getElementById('disneyParkBadge'), options + noneOpt);
}
async function setDisneyPark(assetId, park) {
closeDropdown();
try {
await api('/api/assets/' + assetId + '/disney-park', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({disney_park: park})
});
await viewAsset(assetId);
showToast(park ? 'Set to ' + parkName(park) : 'Disney park cleared');
} catch (e) {
showToast(e.message, true);
}
}
// ── Edit ──────────────────────────────────────────────────────────────
async function editAsset() {
if (!AppState.currentAssetId) return;