feat: Asset Cleanup tool with bulk edit + name generator

Adds a new Cleanup page in the admin Dashboard for reviewing
and sanitizing asset records.

Features:
- Filter by Company, Place/City, or Make with live counts
- Inline edit for make, model, building, floor on each asset
- Bulk edit bar: set any field for ALL matching assets at once
- Auto-generates names in '<make> <model> @ <company> - <Building> <Floor>' format
- Paginated results (50/page) with prev/next navigation
- Backend: POST /api/assets/batch-update for efficient batch updates
- Backend: company/place/make filter params on GET /api/assets
This commit is contained in:
Leo
2026-06-02 22:07:37 -04:00
parent 4ab08ca7bd
commit 25aec1490a
2 changed files with 467 additions and 0 deletions
+66
View File
@@ -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 ────────────────────────────────────────────────────────
+401
View File
@@ -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%; }
}
</style>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://cdn.jsdelivr.net/npm/exifr@7/dist/lite.umd.js"></script>
@@ -1003,6 +1033,9 @@
<button class="nav-item" data-page="msfssync" onclick="switchPage('msfssync')" title="MSFS Sync — trigger ADB data pull from the Dynamics 365 mobile app">
<span class="ni-icon">📡</span><span>MSFS Sync</span>
</button>
<button class="nav-item" data-page="cleanup" onclick="switchPage('cleanup')" title="Cleanup — review and sanitize asset names, locations, buildings">
<span class="ni-icon">🧹</span><span>Cleanup</span>
</button>
<button class="nav-item" onclick="openHelpModal()" title="Help — keyboard shortcuts, tips, and documentation" style="border-top:1px solid var(--border);margin-top:4px;">
<span class="ni-icon"></span><span>Help</span>
</button>
@@ -1617,6 +1650,75 @@
</div>
</div>
<!-- ── CLEANUP TOOL ── -->
<div id="pageCleanup" class="page hidden">
<div class="page-title">
<span>🧹 Asset Cleanup</span>
<div class="pt-actions">
<button class="btn btn-sm btn-outline" onclick="loadCleanup()">🔄 Refresh</button>
</div>
</div>
<!-- Filters -->
<div class="card">
<div class="card-title">Filter Assets</div>
<div id="cleanupFilters" class="cu-filters">
<div class="cu-f-row">
<div style="flex:1;">
<label class="cu-label">Company</label>
<select id="cuFilterCompany" class="input-field" onchange="loadCleanup()">
<option value="">All Companies</option>
</select>
</div>
<div style="flex:1;">
<label class="cu-label">Place / City</label>
<select id="cuFilterPlace" class="input-field" onchange="loadCleanup()">
<option value="">All Places</option>
</select>
</div>
<div style="flex:1;">
<label class="cu-label">Make</label>
<select id="cuFilterMake" class="input-field" onchange="loadCleanup()">
<option value="">All Makes</option>
</select>
</div>
<div style="flex-shrink:0;display:flex;align-items:flex-end;">
<div id="cuCount" class="cu-count">0 assets</div>
</div>
</div>
</div>
</div>
<!-- Bulk Edit Bar -->
<div id="cuBulkBar" class="card" style="display:none;">
<div class="card-title">✏️ Bulk Edit <span id="cuBulkCount"></span></div>
<div class="cu-bulk-grid">
<div><label class="cu-label">Make</label><input id="cuBulkMake" class="input-field" placeholder="e.g. Crane"></div>
<div><label class="cu-label">Model</label><input id="cuBulkModel" class="input-field" placeholder="e.g. 5800"></div>
<div><label class="cu-label">Building</label><input id="cuBulkBuilding" class="input-field" placeholder="e.g. B7"></div>
<div><label class="cu-label">Floor</label><input id="cuBulkFloor" class="input-field" placeholder="e.g. FL 1"></div>
<div><label class="cu-label">Room</label><input id="cuBulkRoom" class="input-field" placeholder="e.g. 101"></div>
<div><label class="cu-label">Company</label><input id="cuBulkCompany" class="input-field" placeholder="Override company"></div>
<div><label class="cu-label">Place</label><input id="cuBulkPlace" class="input-field" placeholder="Override place/city"></div>
<div><label class="cu-label">Location Area</label><input id="cuBulkLocation" class="input-field" placeholder="e.g. Cafeteria"></div>
</div>
<div class="cu-name-preview" id="cuNamePreview">📝 Name preview will appear here</div>
<div class="cu-bulk-actions">
<button class="btn btn-sm btn-accent" onclick="applyBulkEdit()">💾 Apply to <span id="cuBulkApplyCount">0</span> Assets</button>
<button class="btn btn-sm btn-outline" onclick="clearBulkEdit()">✕ Clear Fields</button>
</div>
</div>
<!-- Results List -->
<div id="cuResults" class="card">
<div class="card-title">Results</div>
<div id="cuResultsList">
<div style="font-size:13px;color:var(--text2);">Select filters above to begin.</div>
</div>
<div id="cuPagination" class="cu-pagination"></div>
</div>
</div>
</div>
<!-- ═══ MODAL ═══ -->
@@ -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 = '<option value="">All ' + (id === 'cuFilterCompany' ? 'Companies' : id === 'cuFilterPlace' ? 'Places' : 'Makes') + '</option>';
[...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 = '<div class="loading-row"><div class="spinner"></div> Loading...</div>';
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 = '<div style="font-size:13px;color:var(--text2);padding:20px 0;text-align:center;">No assets match these filters.</div>';
paginationEl.innerHTML = '';
return;
}
let html = '<div style="font-size:11px;color:var(--text3);margin-bottom:6px;display:flex;justify-content:space-between;">' +
'<span>Showing ' + (data.offset + 1) + '' + Math.min(data.offset + assets.length, total) + ' of ' + total + '</span>' +
'<span>Click any value to edit inline</span></div>';
assets.forEach(a => {
const catIcon = categoryIcon(a.category);
html += '<div class="cu-asset-row" data-id="' + a.id + '">' +
'<input type="checkbox" class="cu-row-check" data-id="' + a.id + '" style="margin-right:6px;flex-shrink:0;" onchange="updateBulkSelection()">' +
'<div class="cu-ar-id">' + a.machine_id + '</div>' +
'<div class="cu-ar-name" title="' + esc(a.name) + '">' + catIcon + esc(a.name || '').slice(0, 40) + '</div>' +
'<div class="cu-ar-make"><input class="cu-inline-edit" value="' + esc(a.make || '') + '" placeholder="Make" onchange="cellEdit(' + a.id + ',\'make\',this.value)"></div>' +
'<div class="cu-ar-model"><input class="cu-inline-edit" value="' + esc(a.model || '') + '" placeholder="Model" onchange="cellEdit(' + a.id + ',\'model\',this.value)"></div>' +
'<div class="cu-ar-building"><input class="cu-inline-edit" value="' + esc(a.building_name || '') + '" placeholder="Building" onchange="cellEdit(' + a.id + ',\'building_name\',this.value)"></div>' +
'<div class="cu-ar-floor"><input class="cu-inline-edit" value="' + esc(a.floor || '') + '" placeholder="Floor" onchange="cellEdit(' + a.id + ',\'floor\',this.value)"></div>' +
'<div class="cu-ar-company" title="' + esc(a.company || '') + '">' + esc(a.company || '').slice(0, 30) + '</div>' +
'<div class="cu-ar-place">' + esc(a.place || '') + '</div>' +
'</div>';
});
listEl.innerHTML = html;
// Pagination
const totalPages = Math.ceil(total / CLEANUP_PAGE_SIZE);
let pagHtml = '';
if (cleanupPage > 0) pagHtml += '<button class="btn btn-sm btn-outline" onclick="cleanupPage--;loadCleanup()">◀ Prev</button>';
pagHtml += '<span style="font-size:12px;color:var(--text2);padding:0 8px;">Page ' + (cleanupPage + 1) + ' of ' + totalPages + '</span>';
if (cleanupPage < totalPages - 1) pagHtml += '<button class="btn btn-sm btn-outline" onclick="cleanupPage++;loadCleanup()">Next ▶</button>';
paginationEl.innerHTML = pagHtml;
} catch (e) {
listEl.innerHTML = '<div style="color:var(--red);">Error: ' + esc(e.message) + '</div>';
}
}
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 <strong>' + total + '</strong> assets with:<br><br>' +
Object.entries(fields).map(([k, v]) => '<strong>' + k + '</strong>: ' + esc(v)).join('<br>') +
'<br><br>This will also update the <strong>name</strong> to: <code>' + esc(fields.name || '(unchanged)') + '</code>',
'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
// ═════════════════════════════════════════════════════════════════════════════