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:
@@ -1662,6 +1662,9 @@ def list_assets_admin(
|
|||||||
status: Optional[str] = Query(None),
|
status: Optional[str] = Query(None),
|
||||||
category: Optional[str] = Query(None),
|
category: Optional[str] = Query(None),
|
||||||
customer_id: Optional[int] = 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),
|
limit: int = Query(500, ge=1, le=5000),
|
||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
):
|
):
|
||||||
@@ -1683,6 +1686,15 @@ def list_assets_admin(
|
|||||||
if customer_id is not None:
|
if customer_id is not None:
|
||||||
conditions.append("customer_id = ?")
|
conditions.append("customer_id = ?")
|
||||||
params.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)
|
where = " AND ".join(conditions)
|
||||||
sql = "SELECT * FROM assets"
|
sql = "SELECT * FROM assets"
|
||||||
@@ -1855,6 +1867,60 @@ def batch_update_status(body: BatchStatusUpdate):
|
|||||||
return {"updated": len(body.ids), "status": body.status}
|
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 ────────────────────────────────────────────────────────
|
# ─── Icon Upload API ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -923,6 +923,36 @@
|
|||||||
.donut-wrapper { flex-direction: column; align-items: center; }
|
.donut-wrapper { flex-direction: column; align-items: center; }
|
||||||
.report-stats { grid-template-columns: repeat(2, 1fr); }
|
.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>
|
</style>
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
<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>
|
<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">
|
<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>
|
<span class="ni-icon">📡</span><span>MSFS Sync</span>
|
||||||
</button>
|
</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;">
|
<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>
|
<span class="ni-icon">❓</span><span>Help</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -1617,6 +1650,75 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- ═══ MODAL ═══ -->
|
<!-- ═══ MODAL ═══ -->
|
||||||
@@ -1823,6 +1925,10 @@ function switchPage(page) {
|
|||||||
else if (page === 'reports') loadReports();
|
else if (page === 'reports') loadReports();
|
||||||
else if (page === 'workorders') loadWorkOrders();
|
else if (page === 'workorders') loadWorkOrders();
|
||||||
else if (page === 'msfssync') loadMsfsSync();
|
else if (page === 'msfssync') loadMsfsSync();
|
||||||
|
else if (page === 'cleanup') {
|
||||||
|
loadCleanupFilterOptions();
|
||||||
|
loadCleanup();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function closeDetail() {
|
function closeDetail() {
|
||||||
if (AppState._prevPage) switchPage(AppState._prevPage);
|
if (AppState._prevPage) switchPage(AppState._prevPage);
|
||||||
@@ -5544,6 +5650,301 @@ function renderWODetailModal(data) {
|
|||||||
showModal(data.msdyn_name, body, 'Close', 'btn-outline');
|
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
|
// MSFS ADB SYNC PAGE
|
||||||
// ═════════════════════════════════════════════════════════════════════════════
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
Reference in New Issue
Block a user