diff --git a/admin_server.py b/admin_server.py index a40774d..545e98c 100644 --- a/admin_server.py +++ b/admin_server.py @@ -1662,6 +1662,9 @@ def list_assets_admin( status: Optional[str] = Query(None), category: Optional[str] = Query(None), customer_id: Optional[int] = Query(None), + company: Optional[str] = Query(None), + place: Optional[str] = Query(None), + make: Optional[str] = Query(None), limit: int = Query(500, ge=1, le=5000), offset: int = Query(0, ge=0), ): @@ -1683,6 +1686,15 @@ def list_assets_admin( if customer_id is not None: conditions.append("customer_id = ?") params.append(customer_id) + if company: + conditions.append("company = ?") + params.append(company) + if place: + conditions.append("place = ?") + params.append(place) + if make: + conditions.append("make = ?") + params.append(make) where = " AND ".join(conditions) sql = "SELECT * FROM assets" @@ -1855,6 +1867,60 @@ def batch_update_status(body: BatchStatusUpdate): return {"updated": len(body.ids), "status": body.status} +class BatchAssetUpdate(BaseModel): + ids: list[int] + fields: dict # { "column_name": value, ... } + + +@app.post("/api/assets/batch-update") +def batch_update_assets(body: BatchAssetUpdate): + """Update the same fields on multiple assets at once. Used by Cleanup tool. + + Body: { ids: [1,2,3], fields: { make: "Crane", building_name: "B7", floor: "FL 1" } } + Only updates non-null field values. + """ + if not body.ids: + raise HTTPException(status_code=422, detail="No asset IDs provided") + if not body.fields: + raise HTTPException(status_code=422, detail="No fields to update") + + ALLOWED_FIELDS = { + "make", "model", "name", "company", "place", "building_name", + "building_number", "floor", "room", "location_area", + "address", "category", "status", "customer_name", + } + # Filter to allowed fields with non-null/empty values + updates = {k: v for k, v in body.fields.items() + if k in ALLOWED_FIELDS and v is not None and v != ""} + if not updates: + raise HTTPException(status_code=422, detail="No valid fields to update") + + conn = get_db() + placeholders = ",".join("?" * len(body.ids)) + + # Verify all IDs exist + existing = conn.execute( + f"SELECT COUNT(*) FROM assets WHERE id IN ({placeholders})", + body.ids, + ).fetchone()[0] + if existing != len(body.ids): + conn.close() + raise HTTPException(status_code=404, detail="One or more asset IDs not found") + + set_clause = ", ".join(f"{col} = ?" for col in updates) + values = list(updates.values()) + conn.execute( + f"UPDATE assets SET {set_clause}, updated_at = datetime('now') " + f"WHERE id IN ({placeholders})", + values + body.ids, + ) + _log_activity(conn, "updated", "asset", 0, + f"Batch update: {len(body.ids)} assets — {', '.join(updates.keys())}") + conn.commit() + conn.close() + return {"updated": len(body.ids), "fields": list(updates.keys())} + + # ─── Icon Upload API ──────────────────────────────────────────────────────── diff --git a/static/index.html b/static/index.html index 6ac1b2a..b01b7ba 100644 --- a/static/index.html +++ b/static/index.html @@ -923,6 +923,36 @@ .donut-wrapper { flex-direction: column; align-items: center; } .report-stats { grid-template-columns: repeat(2, 1fr); } } + /* ── Cleanup Tool ── */ + .cu-filters { margin-bottom: 12px; } + .cu-f-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end; } + .cu-label { display: block; font-size: 11px; font-weight: 600; color: var(--text2); margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.3px; } + .cu-count { font-size: 14px; font-weight: 700; color: var(--accent2); padding: 6px 12px; background: var(--accent-bg); border-radius: var(--radius); white-space: nowrap; } + .cu-bulk-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 8px; margin-bottom: 8px; } + .cu-bulk-actions { display: flex; gap: 8px; margin-top: 8px; } + .cu-name-preview { font-size: 13px; color: var(--accent2); background: var(--accent-bg); padding: 8px 12px; border-radius: var(--radius-xs); margin-bottom: 8px; font-family: monospace; } + .cu-asset-row { display: flex; gap: 8px; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 13px; } + .cu-asset-row:last-child { border-bottom: none; } + .cu-ar-id { font-size: 11px; color: var(--text3); min-width: 50px; } + .cu-ar-name { flex: 2; font-weight: 600; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .cu-ar-make { flex: 1; min-width: 80px; } + .cu-ar-model { flex: 1; min-width: 80px; } + .cu-ar-building { flex: 1; min-width: 80px; } + .cu-ar-floor { flex: 0.6; min-width: 60px; } + .cu-ar-company { flex: 1.5; min-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .cu-ar-place { flex: 1; min-width: 80px; } + .cu-pagination { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; } + .cu-pagination .btn { font-size: 12px; } + .cu-inline-edit { background: transparent; border: 1px solid var(--border); border-radius: var(--radius-xs); padding: 2px 6px; font-size: 12px; width: 100%; color: var(--text); } + .cu-inline-edit:focus { border-color: var(--accent); outline: none; } + .cu-bulk-edit-row { display: flex; gap: 6px; margin-bottom: 6px; } + .cu-select-all { margin-right: 8px; } + @media (max-width: 768px) { + .cu-f-row { flex-direction: column; } + .cu-bulk-grid { grid-template-columns: 1fr 1fr; } + .cu-asset-row { flex-wrap: wrap; } + .cu-ar-company { min-width: 100%; } + } @@ -1003,6 +1033,9 @@ + @@ -1617,6 +1650,75 @@ + + + @@ -1823,6 +1925,10 @@ function switchPage(page) { else if (page === 'reports') loadReports(); else if (page === 'workorders') loadWorkOrders(); else if (page === 'msfssync') loadMsfsSync(); + else if (page === 'cleanup') { + loadCleanupFilterOptions(); + loadCleanup(); + } } function closeDetail() { if (AppState._prevPage) switchPage(AppState._prevPage); @@ -5544,6 +5650,301 @@ function renderWODetailModal(data) { showModal(data.msdyn_name, body, 'Close', 'btn-outline'); } +// ═════════════════════════════════════════════════════════════════════════════ +// CLEANUP PAGE +// ═════════════════════════════════════════════════════════════════════════════ + +let cleanupPage = 0; +const CLEANUP_PAGE_SIZE = 50; + +function generateAssetName(make, model, company, building, floor, place) { + let parts = []; + const m = (make || '').trim(); + const mo = (model || '').trim(); + const c = (company || '').trim(); + const b = (building || '').trim(); + const f = (floor || '').trim(); + const p = (place || '').trim(); + + if (m && mo) parts.push(m + ' ' + mo); + else if (m) parts.push(m); + else if (mo) parts.push(mo); + + if (c) parts.push('@ ' + c); + + let location = []; + if (b) location.push(b); + if (f) location.push(f); + if (location.length) parts.push('- ' + location.join(' ')); + + if (p) parts.push('- ' + p); + + return parts.join(' ') || '(enter fields to generate name)'; +} + +function updateNamePreview() { + const make = document.getElementById('cuBulkMake')?.value || ''; + const model = document.getElementById('cuBulkModel')?.value || ''; + const company = document.getElementById('cuBulkCompany')?.value || ''; + const building = document.getElementById('cuBulkBuilding')?.value || ''; + const floor = document.getElementById('cuBulkFloor')?.value || ''; + const place = document.getElementById('cuBulkPlace')?.value || ''; + const preview = generateAssetName(make, model, company, building, floor, place); + document.getElementById('cuNamePreview').textContent = '📝 ' + preview; +} + +// Wire up bulk edit inputs for live name preview +document.addEventListener('input', function(e) { + const ids = ['cuBulkMake','cuBulkModel','cuBulkCompany','cuBulkBuilding','cuBulkFloor','cuBulkPlace']; + if (ids.includes(e.target.id)) updateNamePreview(); +}); + +async function loadCleanupFilterOptions() { + try { + const [companies, places, makes] = await Promise.all([ + api('/api/assets?limit=1&company=x').then(() => fetch('/api/export/assets').then(r => r.text())).catch(() => null), + // Use distinct-query endpoints via the list API with aggregation + api('/api/assets?limit=1').then(() => {}).catch(() => {}), + ]); + // Fetch distinct values via the list API — use limit=1 trick to just get distinct meta + const allData = await api('/api/assets?limit=5000&offset=0'); + const assets = allData.assets || []; + + const companySet = new Set(); + const placeSet = new Set(); + const makeSet = new Set(); + assets.forEach(a => { + if (a.company) companySet.add(a.company); + if (a.place) placeSet.add(a.place); + if (a.make) makeSet.add(a.make); + }); + + function populateSelect(id, values) { + const sel = document.getElementById(id); + if (!sel) return; + const current = sel.value; + sel.innerHTML = ''; + [...values].sort().forEach(v => { + const opt = document.createElement('option'); + opt.value = v; + opt.textContent = v; + sel.appendChild(opt); + }); + sel.value = current; + } + + populateSelect('cuFilterCompany', companySet); + populateSelect('cuFilterPlace', placeSet); + populateSelect('cuFilterMake', makeSet); + } catch (e) { + console.warn('Failed to load cleanup filters:', e); + } +} + +async function loadCleanup() { + const company = document.getElementById('cuFilterCompany')?.value || ''; + const place = document.getElementById('cuFilterPlace')?.value || ''; + const make = document.getElementById('cuFilterMake')?.value || ''; + + const params = new URLSearchParams(); + params.set('limit', CLEANUP_PAGE_SIZE); + params.set('offset', cleanupPage * CLEANUP_PAGE_SIZE); + if (company) params.set('company', company); + if (place) params.set('place', place); + if (make) params.set('make', make); + + const listEl = document.getElementById('cuResultsList'); + const countEl = document.getElementById('cuCount'); + const paginationEl = document.getElementById('cuPagination'); + + try { + listEl.innerHTML = '
Loading...
'; + const data = await api('/api/assets?' + params.toString()); + const assets = data.assets || []; + const total = data.total || 0; + + countEl.textContent = total + ' asset' + (total !== 1 ? 's' : ''); + + // Show/hide bulk bar + const bulkBar = document.getElementById('cuBulkBar'); + if (assets.length > 0) { + bulkBar.style.display = 'block'; + document.getElementById('cuBulkCount').textContent = '(' + total + ' matching)'; + document.getElementById('cuBulkApplyCount').textContent = total; + + // Pre-fill bulk fields from first asset's common values + if (assets.length > 1) { + const commonMake = assets.every(a => a.make === assets[0].make) ? assets[0].make : ''; + const commonCompany = assets.every(a => a.company === assets[0].company) ? assets[0].company : ''; + const commonPlace = assets.every(a => a.place === assets[0].place) ? assets[0].place : ''; + ['cuBulkMake','cuBulkModel','cuBulkCompany','cuBulkPlace','cuBulkBuilding','cuBulkFloor','cuBulkRoom','cuBulkLocation'].forEach(id => { + const el = document.getElementById(id); + if (el) { + if (!el.dataset.userSet) el.value = ''; + } + }); + } + } else { + bulkBar.style.display = 'none'; + } + + // Render list + if (assets.length === 0) { + listEl.innerHTML = '
No assets match these filters.
'; + paginationEl.innerHTML = ''; + return; + } + + let html = '
' + + 'Showing ' + (data.offset + 1) + '–' + Math.min(data.offset + assets.length, total) + ' of ' + total + '' + + 'Click any value to edit inline
'; + + assets.forEach(a => { + const catIcon = categoryIcon(a.category); + html += '
' + + '' + + '
' + a.machine_id + '
' + + '
' + catIcon + esc(a.name || '').slice(0, 40) + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + esc(a.company || '').slice(0, 30) + '
' + + '
' + esc(a.place || '') + '
' + + '
'; + }); + listEl.innerHTML = html; + + // Pagination + const totalPages = Math.ceil(total / CLEANUP_PAGE_SIZE); + let pagHtml = ''; + if (cleanupPage > 0) pagHtml += ''; + pagHtml += 'Page ' + (cleanupPage + 1) + ' of ' + totalPages + ''; + if (cleanupPage < totalPages - 1) pagHtml += ''; + paginationEl.innerHTML = pagHtml; + + } catch (e) { + listEl.innerHTML = '
Error: ' + esc(e.message) + '
'; + } +} + +function updateBulkSelection() { + // Currently all loaded assets are included in batch (we use the filter params, not checkboxes) +} + +async function cellEdit(assetId, field, value) { + try { + const body = {}; + body[field] = value; + await api('/api/assets/' + assetId, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + } catch (e) { + showToast('Save failed: ' + e.message, true); + } +} + +async function applyBulkEdit() { + const company = document.getElementById('cuFilterCompany')?.value || ''; + const place = document.getElementById('cuFilterPlace')?.value || ''; + const make = document.getElementById('cuFilterMake')?.value || ''; + + // Build fields object from non-empty bulk edit inputs + const fields = {}; + const fieldMap = { + cuBulkMake: 'make', cuBulkModel: 'model', cuBulkBuilding: 'building_name', + cuBulkFloor: 'floor', cuBulkRoom: 'room', cuBulkCompany: 'company', + cuBulkPlace: 'place', cuBulkLocation: 'location_area', + }; + Object.entries(fieldMap).forEach(([elId, dbField]) => { + const val = document.getElementById(elId)?.value?.trim() || ''; + if (val) fields[dbField] = val; + }); + + if (Object.keys(fields).length === 0) { + showToast('Fill in at least one field to bulk-update', true); + return; + } + + // Build name from the pattern if make/model are present + const bulkMake = fields.make || ''; + const bulkModel = fields.model || ''; + const bulkCompany = fields.company || ''; + const bulkBuilding = fields.building_name || ''; + const bulkFloor = fields.floor || ''; + const bulkPlace = fields.place || ''; + const newName = generateAssetName(bulkMake, bulkModel, bulkCompany, bulkBuilding, bulkFloor, bulkPlace); + if (newName && !newName.startsWith('(enter')) { + fields.name = newName; + } + + // Get total count for this filter + const params = new URLSearchParams(); + params.set('limit', 1); + if (company) params.set('company', company); + if (place) params.set('place', place); + if (make) params.set('make', make); + + try { + const info = await api('/api/assets?' + params.toString()); + const total = info.total || 0; + + if (total === 0) { + showToast('No assets match current filters', true); + return; + } + + const confirmed = await showModal('Apply Bulk Edit', + 'Update ' + total + ' assets with:

' + + Object.entries(fields).map(([k, v]) => '' + k + ': ' + esc(v)).join('
') + + '

This will also update the name to: ' + esc(fields.name || '(unchanged)') + '', + 'Apply to All', 'btn-accent'); + if (!confirmed) return; + + // Fetch ALL asset IDs matching this filter + const allData = await api('/api/assets?' + params.toString().replace('limit=1', 'limit=5000')); + const ids = (allData.assets || []).map(a => a.id); + if (ids.length === 0) { + showToast('No asset IDs to update', true); + return; + } + + // Batch update in chunks + const CHUNK = 500; + let updated = 0; + for (let i = 0; i < ids.length; i += CHUNK) { + const chunk = ids.slice(i, i + CHUNK); + await api('/api/assets/batch-update', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids: chunk, fields }), + }); + updated += chunk.length; + } + + showToast('✅ Updated ' + updated + ' assets', false); + loadCleanup(); + } catch (e) { + showToast('❌ Bulk update failed: ' + e.message, true); + } +} + +function clearBulkEdit() { + ['cuBulkMake','cuBulkModel','cuBulkCompany','cuBulkBuilding','cuBulkFloor','cuBulkRoom','cuBulkPlace','cuBulkLocation'].forEach(id => { + const el = document.getElementById(id); + if (el) { el.value = ''; el.dataset.userSet = '0'; } + }); + updateNamePreview(); +} + +// ── Helper: category icon ── +function categoryIcon(cat) { + const icons = { 'snack': '🍿', 'bev': '🥤', 'food': '🍔', 'coffee': '☕', 'ice': '🧊', 'other': '📦', 'equipment': '🔧', 'appliances': '🔌' }; + return icons[(cat || '').toLowerCase()] || '📦'; +} + // ═════════════════════════════════════════════════════════════════════════════ // MSFS ADB SYNC PAGE // ═════════════════════════════════════════════════════════════════════════════