diff --git a/backend/main.py b/backend/main.py index 5be71ee..86f1e0c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -707,6 +707,33 @@ async def devices_all_partial(request: Request): "hardware_model": unit_data.get("hardware_model"), }) + # Add allocated units + for unit_id, unit_data in snapshot.get("allocated", {}).items(): + units_list.append({ + "id": unit_id, + "status": "Allocated", + "age": "N/A", + "last_seen": "N/A", + "deployed": False, + "retired": False, + "out_for_calibration": False, + "allocated": True, + "allocated_to_project_id": unit_data.get("allocated_to_project_id", ""), + "ignored": False, + "note": unit_data.get("note", ""), + "device_type": unit_data.get("device_type", "seismograph"), + "address": unit_data.get("address", ""), + "coordinates": unit_data.get("coordinates", ""), + "project_id": unit_data.get("project_id", ""), + "last_calibrated": unit_data.get("last_calibrated"), + "next_calibration_due": unit_data.get("next_calibration_due"), + "deployed_with_modem_id": unit_data.get("deployed_with_modem_id"), + "deployed_with_unit_id": unit_data.get("deployed_with_unit_id"), + "ip_address": unit_data.get("ip_address"), + "phone_number": unit_data.get("phone_number"), + "hardware_model": unit_data.get("hardware_model"), + }) + # Add out-for-calibration units for unit_id, unit_data in snapshot["out_for_calibration"].items(): units_list.append({ @@ -784,17 +811,19 @@ async def devices_all_partial(request: Request): # Sort by status category, then by ID def sort_key(unit): - # Priority: deployed (active) -> benched -> out_for_calibration -> retired -> ignored + # Priority: deployed (active) -> allocated -> benched -> out_for_calibration -> retired -> ignored if unit["deployed"]: return (0, unit["id"]) - elif not unit["retired"] and not unit["out_for_calibration"] and not unit["ignored"]: + elif unit.get("allocated"): return (1, unit["id"]) - elif unit["out_for_calibration"]: + elif not unit["retired"] and not unit["out_for_calibration"] and not unit["ignored"]: return (2, unit["id"]) - elif unit["retired"]: + elif unit["out_for_calibration"]: return (3, unit["id"]) - else: + elif unit["retired"]: return (4, unit["id"]) + else: + return (5, unit["id"]) units_list.sort(key=sort_key) diff --git a/backend/migrate_add_allocated.py b/backend/migrate_add_allocated.py new file mode 100644 index 0000000..ac1900d --- /dev/null +++ b/backend/migrate_add_allocated.py @@ -0,0 +1,35 @@ +""" +Migration: Add allocated and allocated_to_project_id columns to roster table. +Run once: python backend/migrate_add_allocated.py +""" +import sqlite3 +import os + +DB_PATH = os.path.join(os.path.dirname(__file__), '..', 'data', 'seismo_fleet.db') + +def run(): + conn = sqlite3.connect(DB_PATH) + cur = conn.cursor() + + # Check existing columns + cur.execute("PRAGMA table_info(roster)") + cols = {row[1] for row in cur.fetchall()} + + if 'allocated' not in cols: + cur.execute("ALTER TABLE roster ADD COLUMN allocated BOOLEAN DEFAULT 0 NOT NULL") + print("Added column: allocated") + else: + print("Column already exists: allocated") + + if 'allocated_to_project_id' not in cols: + cur.execute("ALTER TABLE roster ADD COLUMN allocated_to_project_id VARCHAR") + print("Added column: allocated_to_project_id") + else: + print("Column already exists: allocated_to_project_id") + + conn.commit() + conn.close() + print("Migration complete.") + +if __name__ == '__main__': + run() diff --git a/backend/models.py b/backend/models.py index 7359f21..bb0ca10 100644 --- a/backend/models.py +++ b/backend/models.py @@ -33,6 +33,8 @@ class RosterUnit(Base): deployed = Column(Boolean, default=True) retired = Column(Boolean, default=False) out_for_calibration = Column(Boolean, default=False) + allocated = Column(Boolean, default=False) # Staged for an upcoming job, not yet deployed + allocated_to_project_id = Column(String, nullable=True) # Which project it's allocated to note = Column(String, nullable=True) project_id = Column(String, nullable=True) location = Column(String, nullable=True) # Legacy field - use address/coordinates instead diff --git a/backend/routers/fleet_calendar.py b/backend/routers/fleet_calendar.py index eafcff9..0a100d0 100644 --- a/backend/routers/fleet_calendar.py +++ b/backend/routers/fleet_calendar.py @@ -701,6 +701,8 @@ async def get_planner_availability( "calibration_status": "needs_calibration" if not u.last_calibrated else "valid", "deployed": u.deployed, "out_for_calibration": u.out_for_calibration or False, + "allocated": getattr(u, 'allocated', False) or False, + "allocated_to_project_id": getattr(u, 'allocated_to_project_id', None) or "", "note": u.note or "", "reservations": unit_reservations.get(u.id, []) }) @@ -716,6 +718,56 @@ async def get_planner_availability( } +@router.get("/api/fleet-calendar/unit-quick-info/{unit_id}", response_class=JSONResponse) +async def get_unit_quick_info(unit_id: str, db: Session = Depends(get_db)): + """Return at-a-glance info for the planner quick-view modal.""" + from backend.models import Emitter + u = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first() + if not u: + raise HTTPException(status_code=404, detail="Unit not found") + + today = date.today() + expiry = (u.last_calibrated + timedelta(days=365)) if u.last_calibrated else None + + # Active/upcoming reservations + assignments = db.query(JobReservationUnit).filter(JobReservationUnit.unit_id == unit_id).all() + reservations = [] + for a in assignments: + res = db.query(JobReservation).filter( + JobReservation.id == a.reservation_id, + JobReservation.end_date >= today + ).first() + if res: + reservations.append({ + "name": res.name, + "start_date": res.start_date.isoformat() if res.start_date else None, + "end_date": res.end_date.isoformat() if res.end_date else None, + "end_date_tbd": res.end_date_tbd, + "color": res.color or "#3B82F6", + "location_name": a.location_name, + }) + + # Last seen from emitter + emitter = db.query(Emitter).filter(Emitter.unit_type == unit_id).first() + + return { + "id": u.id, + "unit_type": u.unit_type, + "deployed": u.deployed, + "out_for_calibration": u.out_for_calibration or False, + "note": u.note or "", + "project_id": u.project_id or "", + "address": u.address or u.location or "", + "coordinates": u.coordinates or "", + "deployed_with_modem_id": u.deployed_with_modem_id or "", + "last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None, + "next_calibration_due": u.next_calibration_due.isoformat() if u.next_calibration_due else (expiry.isoformat() if expiry else None), + "cal_expired": not u.last_calibrated or (expiry and expiry < today), + "last_seen": emitter.last_seen.isoformat() if emitter and emitter.last_seen else None, + "reservations": reservations, + } + + @router.get("/api/fleet-calendar/available-units", response_class=HTMLResponse) async def get_available_units_partial( request: Request, diff --git a/backend/routers/roster_edit.py b/backend/routers/roster_edit.py index 9972e50..e343b83 100644 --- a/backend/routers/roster_edit.py +++ b/backend/routers/roster_edit.py @@ -500,6 +500,8 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)): "deployed": unit.deployed, "retired": unit.retired, "out_for_calibration": unit.out_for_calibration or False, + "allocated": getattr(unit, 'allocated', False) or False, + "allocated_to_project_id": getattr(unit, 'allocated_to_project_id', None) or "", "note": unit.note or "", "project_id": unit.project_id or "", "location": unit.location or "", @@ -532,6 +534,8 @@ async def edit_roster_unit( deployed: str = Form(None), retired: str = Form(None), out_for_calibration: str = Form(None), + allocated: str = Form(None), + allocated_to_project_id: str = Form(None), note: str = Form(""), project_id: str = Form(None), location: str = Form(None), @@ -574,6 +578,7 @@ async def edit_roster_unit( deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False retired_bool = retired in ['true', 'True', '1', 'yes'] if retired else False out_for_calibration_bool = out_for_calibration in ['true', 'True', '1', 'yes'] if out_for_calibration else False + allocated_bool = allocated in ['true', 'True', '1', 'yes'] if allocated else False # Convert port strings to integers slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None @@ -611,6 +616,8 @@ async def edit_roster_unit( unit.deployed = deployed_bool unit.retired = retired_bool unit.out_for_calibration = out_for_calibration_bool + unit.allocated = allocated_bool + unit.allocated_to_project_id = allocated_to_project_id if allocated_bool else None unit.note = note unit.project_id = project_id unit.location = location diff --git a/backend/services/snapshot.py b/backend/services/snapshot.py index e4340f2..da54f65 100644 --- a/backend/services/snapshot.py +++ b/backend/services/snapshot.py @@ -86,6 +86,12 @@ def emit_status_snapshot(): age = "N/A" last_seen = None fname = "" + elif getattr(r, 'allocated', False) and not r.deployed: + # Allocated: staged for an upcoming job, not yet physically deployed + status = "Allocated" + age = "N/A" + last_seen = None + fname = "" else: if e: last_seen = ensure_utc(e.last_seen) @@ -110,6 +116,8 @@ def emit_status_snapshot(): "note": r.note or "", "retired": r.retired, "out_for_calibration": r.out_for_calibration or False, + "allocated": getattr(r, 'allocated', False) or False, + "allocated_to_project_id": getattr(r, 'allocated_to_project_id', None) or "", # Device type and type-specific fields "device_type": r.device_type or "seismograph", "last_calibrated": r.last_calibrated.isoformat() if r.last_calibrated else None, @@ -141,6 +149,8 @@ def emit_status_snapshot(): "note": "", "retired": False, "out_for_calibration": False, + "allocated": False, + "allocated_to_project_id": "", # Device type and type-specific fields (defaults for unknown units) "device_type": "seismograph", # default "last_calibrated": None, @@ -192,7 +202,12 @@ def emit_status_snapshot(): benched_units = { uid: u for uid, u in units.items() - if not u["retired"] and not u["out_for_calibration"] and not u["deployed"] and uid not in ignored + if not u["retired"] and not u["out_for_calibration"] and not u["allocated"] and not u["deployed"] and uid not in ignored + } + + allocated_units = { + uid: u for uid, u in units.items() + if not u["retired"] and not u["out_for_calibration"] and u["allocated"] and not u["deployed"] and uid not in ignored } retired_units = { @@ -216,13 +231,15 @@ def emit_status_snapshot(): "units": units, "active": active_units, "benched": benched_units, + "allocated": allocated_units, "retired": retired_units, "out_for_calibration": out_for_calibration_units, "unknown": unknown_units, "summary": { - "total": len(active_units) + len(benched_units), + "total": len(active_units) + len(benched_units) + len(allocated_units), "active": len(active_units), "benched": len(benched_units), + "allocated": len(allocated_units), "retired": len(retired_units), "out_for_calibration": len(out_for_calibration_units), "unknown": len(unknown_units), diff --git a/templates/dashboard.html b/templates/dashboard.html index 3ffa65b..d1ec900 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -57,6 +57,10 @@ Benched -- +
+ Allocated + -- +

By Device Type:

@@ -703,6 +707,7 @@ function updateDashboard(event) { document.getElementById('total-units').textContent = data.summary?.total ?? 0; document.getElementById('deployed-units').textContent = data.summary?.active ?? 0; document.getElementById('benched-units').textContent = data.summary?.benched ?? 0; + document.getElementById('allocated-units').textContent = data.summary?.allocated ?? 0; document.getElementById('status-ok').textContent = data.summary?.ok ?? 0; document.getElementById('status-pending').textContent = data.summary?.pending ?? 0; document.getElementById('status-missing').textContent = data.summary?.missing ?? 0; diff --git a/templates/fleet_calendar.html b/templates/fleet_calendar.html index 8271073..cb75bcf 100644 --- a/templates/fleet_calendar.html +++ b/templates/fleet_calendar.html @@ -650,7 +650,7 @@

Fleet Summary

-
+
+ + +
@@ -1678,7 +1742,7 @@ function plannerRenderUnits() { row.innerHTML = `
- ${deployedBadge} ${expiryWarning} @@ -1707,6 +1771,108 @@ function closeUnitDetailModal() { document.getElementById('unit-detail-iframe').src = ''; } +async function openUnitQuickModal(unitId) { + document.getElementById('unit-quick-modal').classList.remove('hidden'); + // Reset while loading + document.getElementById('uqm-title').textContent = unitId; + document.getElementById('uqm-deployed-badge').innerHTML = ''; + document.getElementById('uqm-outforcal-badge').innerHTML = ''; + document.getElementById('uqm-cal-date').textContent = '…'; + document.getElementById('uqm-cal-due').textContent = '…'; + ['uqm-address-row','uqm-project-row','uqm-modem-row','uqm-lastseen-row','uqm-note-row','uqm-reservations-row'] + .forEach(id => document.getElementById(id).classList.add('hidden')); + + try { + const resp = await fetch(`/api/fleet-calendar/unit-quick-info/${unitId}`); + if (!resp.ok) throw new Error('Not found'); + const u = await resp.json(); + const today = new Date(); today.setHours(0,0,0,0); + + // Deployed badge + document.getElementById('uqm-deployed-badge').innerHTML = u.deployed + ? 'Deployed' + : 'Benched'; + + // Out for cal badge + if (u.out_for_calibration) { + document.getElementById('uqm-outforcal-badge').innerHTML = + 'Out for Cal'; + } + + // Cal date + const calDateEl = document.getElementById('uqm-cal-date'); + calDateEl.textContent = u.last_calibrated + ? new Date(u.last_calibrated + 'T00:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'}) + : 'No record'; + calDateEl.className = `text-sm font-medium ${!u.last_calibrated ? 'text-red-500 dark:text-red-400' : 'text-gray-900 dark:text-white'}`; + + // Cal due + const calDueEl = document.getElementById('uqm-cal-due'); + if (u.next_calibration_due) { + const due = new Date(u.next_calibration_due + 'T00:00:00'); + const expired = due < today; + calDueEl.textContent = due.toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'}) + (expired ? ' (expired)' : ''); + calDueEl.className = `text-sm font-medium ${expired ? 'text-red-500 dark:text-red-400' : 'text-green-600 dark:text-green-400'}`; + } else { + calDueEl.textContent = '—'; + calDueEl.className = 'text-sm font-medium text-red-500 dark:text-red-400'; + } + + // Address + if (u.address) { + document.getElementById('uqm-address-row').classList.remove('hidden'); + document.getElementById('uqm-address').textContent = u.address; + } + + // Project + if (u.project_id) { + document.getElementById('uqm-project-row').classList.remove('hidden'); + document.getElementById('uqm-project').textContent = u.project_id; + } + + // Modem + if (u.deployed_with_modem_id) { + document.getElementById('uqm-modem-row').classList.remove('hidden'); + document.getElementById('uqm-modem').textContent = u.deployed_with_modem_id; + } + + // Last seen + if (u.last_seen) { + document.getElementById('uqm-lastseen-row').classList.remove('hidden'); + document.getElementById('uqm-lastseen').textContent = + new Date(u.last_seen).toLocaleString('en-US', {month:'short', day:'numeric', year:'numeric', hour:'numeric', minute:'2-digit'}); + } + + // Note + if (u.note) { + document.getElementById('uqm-note-row').classList.remove('hidden'); + document.getElementById('uqm-note').textContent = u.note; + } + + // Reservations + if (u.reservations && u.reservations.length > 0) { + document.getElementById('uqm-reservations-row').classList.remove('hidden'); + document.getElementById('uqm-reservations').innerHTML = u.reservations.map(r => { + const s = r.start_date ? new Date(r.start_date + 'T00:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric'}) : ''; + const e = r.end_date_tbd ? 'TBD' : (r.end_date ? new Date(r.end_date + 'T00:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric'}) : 'TBD'); + const loc = r.location_name ? ` · ${r.location_name}` : ''; + return `
+ + ${r.name} + ${s}–${e}${loc} +
`; + }).join(''); + } + + } catch(e) { + document.getElementById('uqm-cal-date').textContent = 'Error loading'; + } +} + +function closeUnitQuickModal() { + document.getElementById('unit-quick-modal').classList.add('hidden'); +} + function plannerAddSlot() { plannerState.slots.push({ unit_id: null, power_type: null, notes: null, location_name: null }); plannerRenderSlots(); @@ -1716,7 +1882,7 @@ function plannerAddSlot() { // Fleet Summary (right panel on jobs list) // ============================================================ let summaryAllUnits = []; -let summaryActiveFilter = null; // null | 'deployed' | 'benched' | 'cal_expired' +let summaryActiveFilters = new Set(); // multi-select: 'deployed' | 'benched' | 'cal_expired' | 'cal_good' | 'out_for_cal' | 'reserved' async function loadFleetSummary() { const plannerDeviceType = document.querySelector('input[name="planner_device_type"]:checked')?.value || 'seismograph'; @@ -1724,7 +1890,7 @@ async function loadFleetSummary() { const resp = await fetch(`/api/fleet-calendar/planner-availability?device_type=${plannerDeviceType}`); const data = await resp.json(); summaryAllUnits = data.units || []; - summaryActiveFilter = null; + summaryActiveFilters = new Set(); renderFleetSummary(); } catch(e) { console.error('Fleet summary load error', e); } } @@ -1733,88 +1899,158 @@ function summaryFilterUnits() { renderFleetSummary(); } +// Stat cards: set exactly this one filter (or clear all if already the only active one) function summarySetFilter(f) { - summaryActiveFilter = summaryActiveFilter === f ? null : f; + if (f === null) { + summaryActiveFilters = new Set(); + } else if (summaryActiveFilters.size === 1 && summaryActiveFilters.has(f)) { + summaryActiveFilters = new Set(); + } else { + summaryActiveFilters = new Set([f]); + } + renderFleetSummary(); +} + +// Pills: toggle independently (multi-select) +function summaryToggleFilter(f) { + if (summaryActiveFilters.has(f)) summaryActiveFilters.delete(f); + else summaryActiveFilters.add(f); renderFleetSummary(); } function renderFleetSummary() { const search = document.getElementById('summary-search')?.value.toLowerCase() || ''; + const today = new Date(); today.setHours(0,0,0,0); - // Stats (always against full list) - const total = summaryAllUnits.length; - const deployed = summaryAllUnits.filter(u => u.deployed).length; - const benched = summaryAllUnits.filter(u => !u.deployed).length; - const calExpired = summaryAllUnits.filter(u => u.expiry_date && new Date(u.expiry_date + 'T00:00:00') < new Date()).length; + // Computed flags for each unit + const withFlags = summaryAllUnits.map(u => { + const expiry = u.expiry_date ? new Date(u.expiry_date + 'T00:00:00') : null; + return { + ...u, + _calExpired: !u.last_calibrated || (expiry && expiry < today), + _calGood: u.last_calibrated && expiry && expiry >= today, + _outForCal: !!u.out_for_calibration, + _allocated: !!u.allocated, + _reserved: (u.reservations || []).length > 0, + }; + }); + + // Counts always against full list + const counts = { + total: withFlags.length, + deployed: withFlags.filter(u => u.deployed).length, + benched: withFlags.filter(u => !u.deployed).length, + cal_expired: withFlags.filter(u => u._calExpired).length, + cal_good: withFlags.filter(u => u._calGood).length, + out_for_cal: withFlags.filter(u => u._outForCal).length, + allocated: withFlags.filter(u => u._allocated).length, + reserved: withFlags.filter(u => u._reserved).length, + }; + + const af = summaryActiveFilters; + + // Stat cards — single-shortcut behavior, highlighted when they're the sole active filter + const cardActive = (f) => af.size === 1 && af.has(f); + const card = (f, label, count, colorClass, ringColor) => { + const isActive = f === null ? af.size === 0 : cardActive(f); + return ``; + }; - const cardBase = 'rounded-lg p-3 text-left w-full cursor-pointer transition-all ring-2'; - const active = summaryActiveFilter; document.getElementById('fleet-summary-stats').innerHTML = ` - - - - - `; +
+ ${card(null, 'Total', counts.total, 'bg-gray-50 dark:bg-slate-700 text-gray-900 dark:text-white hover:bg-gray-100 dark:hover:bg-slate-600', 'ring-gray-400 dark:ring-gray-300')} + ${card('deployed', 'Deployed', counts.deployed, 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-900/40', 'ring-green-500')} + ${card('benched', 'Benched', counts.benched, 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-900/40', 'ring-blue-500')} + ${card('cal_good', 'Cal Good', counts.cal_good, 'bg-teal-50 dark:bg-teal-900/20 text-teal-700 dark:text-teal-400 hover:bg-teal-100 dark:hover:bg-teal-900/40', 'ring-teal-500')} +
+
+ ${summaryPill('cal_expired', 'Cal Expired', counts.cal_expired, af)} + ${summaryPill('out_for_cal', 'Out for Cal', counts.out_for_cal, af)} + ${summaryPill('allocated', 'Allocated', counts.allocated, af)} + ${summaryPill('reserved', 'Reserved', counts.reserved, af)} +
`; - // Apply filter + search to the list - let units = summaryAllUnits; - if (active === 'deployed') units = units.filter(u => u.deployed); - else if (active === 'benched') units = units.filter(u => !u.deployed); - else if (active === 'cal_expired') units = units.filter(u => u.expiry_date && new Date(u.expiry_date + 'T00:00:00') < new Date()); + // Apply all active filters (AND logic) + search + const filterFns = { + deployed: u => u.deployed, + benched: u => !u.deployed, + cal_expired: u => u._calExpired, + cal_good: u => u._calGood, + out_for_cal: u => u._outForCal, + allocated: u => u._allocated, + reserved: u => u._reserved, + }; + let units = af.size === 0 ? withFlags : withFlags.filter(u => [...af].some(f => filterFns[f](u))); if (search) units = units.filter(u => u.id.toLowerCase().includes(search)); - // Unit list const list = document.getElementById('fleet-summary-list'); if (units.length === 0) { - list.innerHTML = '

No units found

'; + list.innerHTML = '

No units match

'; return; } list.innerHTML = units.map(u => { const calDate = u.last_calibrated ? new Date(u.last_calibrated + 'T00:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'}) - : 'No cal date'; - const expired = u.expiry_date && new Date(u.expiry_date + 'T00:00:00') < new Date(); + : null; + const expiryDate = u.expiry_date + ? new Date(u.expiry_date + 'T00:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'}) + : null; + const deployedBadge = u.deployed ? 'Deployed' : 'Benched'; - const calBadge = expired - ? `Cal expired` - : `Cal: ${calDate}`; + const outForCalBadge = u._outForCal + ? 'Out for Cal' + : ''; + const allocatedBadge = u._allocated + ? `Allocated${u.allocated_to_project_id ? ': ' + u.allocated_to_project_id : ''}` + : ''; + let calBadge; + if (!calDate) { + calBadge = 'Cal expired'; + } else if (u._calExpired) { + calBadge = `Cal expired ${expiryDate}`; + } else { + calBadge = `Cal: ${calDate} · exp. ${expiryDate}`; + } + const resBadges = (u.reservations || []).map(r => { const s = r.start_date ? new Date(r.start_date + 'T00:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric'}) : ''; const e = r.end_date ? new Date(r.end_date + 'T00:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric'}) : 'TBD'; - return `Reserved: ${r.reservation_name} ${s}–${e}`; + return `${r.reservation_name} ${s}–${e}`; }).join(''); + return `
- - ${deployedBadge} - ${calBadge} + ${deployedBadge}${outForCalBadge}${allocatedBadge}${calBadge}
${resBadges ? `
${resBadges}
` : ''}
`; }).join(''); } +function summaryPill(f, label, count, activeSet) { + const isActive = activeSet.has(f); + const pillColors = { + cal_expired: isActive ? 'bg-red-600 text-white border-red-600' : 'bg-transparent border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-red-500 hover:text-red-600 dark:hover:text-red-400', + out_for_cal: isActive ? 'bg-purple-600 text-white border-purple-600' : 'bg-transparent border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-purple-500 hover:text-purple-600 dark:hover:text-purple-400', + allocated: isActive ? 'bg-orange-500 text-white border-orange-500' : 'bg-transparent border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-orange-500 hover:text-orange-600 dark:hover:text-orange-400', + reserved: isActive ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-transparent border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-indigo-500 hover:text-indigo-600 dark:hover:text-indigo-400', + }; + return ``; +} + function showRightPanel(panel) { document.getElementById('right-fleet-summary').classList.toggle('hidden', panel !== 'summary'); document.getElementById('right-available-units').classList.toggle('hidden', panel !== 'available'); @@ -1959,37 +2195,32 @@ function plannerRenderSlots() { ? `` : ``; - // Build unit info badges for filled slots - let unitInfoLine = ''; + // Build inline cal text for filled slots + let calInline = ''; if (slot.unit_id) { const uData = plannerState.allUnits.find(u => u.id === slot.unit_id); if (uData) { - const deployedBadge = uData.deployed - ? 'Deployed' - : 'Benched'; - const outForCalBadge = uData.out_for_calibration - ? 'Out for Cal' - : ''; - const calStr = uData.last_calibrated - ? new Date(uData.last_calibrated + 'T00:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'}) - : 'No cal date'; + const today = new Date(); today.setHours(0,0,0,0); + const expiry = uData.expiry_date ? new Date(uData.expiry_date + 'T00:00:00') : null; + const calExpired = !uData.last_calibrated || (expiry && expiry < today); const start = document.getElementById('planner-start').value; const end = document.getElementById('planner-end').value; - let expiryBadge = ''; - if (uData.expiry_date) { - const expiry = new Date(uData.expiry_date + 'T00:00:00'); - const jobStart = start ? new Date(start + 'T00:00:00') : null; - const jobEnd = end ? new Date(end + 'T00:00:00') : null; + const jobStart = start ? new Date(start + 'T00:00:00') : null; + const jobEnd = end ? new Date(end + 'T00:00:00') : null; + const expiresInJob = expiry && jobStart && jobEnd && expiry >= jobStart && expiry <= jobEnd; + + if (!uData.last_calibrated) { + calInline = `No cal`; + } else if (calExpired) { const expiryStr = expiry.toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'}); - if (jobStart && jobEnd && expiry >= jobStart && expiry <= jobEnd) { - expiryBadge = `cal expires ${expiryStr}`; - } else if (!jobStart || !jobEnd) { - expiryBadge = `cal exp. ${expiryStr}`; - } + calInline = `Cal exp. ${expiryStr}`; + } else if (expiresInJob) { + const expiryStr = expiry.toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'}); + calInline = `Cal exp. ${expiryStr}`; } else { - expiryBadge = 'No cal'; + const expiryStr = expiry.toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'}); + calInline = `Cal exp. ${expiryStr}`; } - unitInfoLine = `
${deployedBadge}${outForCalBadge}${expiryBadge}Cal: ${calStr}
`; } } @@ -1998,7 +2229,8 @@ function plannerRenderSlots() { ${dragHandle} Loc. ${idx + 1} ${slot.unit_id - ? `${slot.unit_id} + ? ` + ${calInline ? `${calInline}` : ''} ${powerSelect} ` : ` @@ -2006,7 +2238,6 @@ function plannerRenderSlots() { ` }
- ${unitInfoLine}
{% if unit.out_for_calibration %} + {% elif unit.allocated %} + {% elif not unit.deployed %} {% elif unit.status == 'OK' %} @@ -76,6 +78,8 @@ {% elif unit.out_for_calibration %} + {% elif unit.allocated %} + {% else %} {% endif %} @@ -207,7 +211,7 @@
@@ -216,6 +220,8 @@
{% if unit.out_for_calibration %} + {% elif unit.allocated %} + {% elif not unit.deployed %} {% elif unit.status == 'OK' %} @@ -231,12 +237,13 @@
- {% if unit.out_for_calibration %}Out for Cal{% elif unit.status in ['N/A', 'Unknown'] %}Benched{% else %}{{ unit.status }}{% endif %} + {% if unit.out_for_calibration %}Out for Cal{% elif unit.allocated %}Allocated{% elif unit.status in ['N/A', 'Unknown'] %}Benched{% else %}{{ unit.status }}{% endif %}
diff --git a/templates/roster.html b/templates/roster.html index 0db9605..6380962 100644 --- a/templates/roster.html +++ b/templates/roster.html @@ -66,6 +66,7 @@ +
@@ -1352,7 +1353,7 @@ // Toggle health filter visibility (hide for retired/ignored) const healthGroup = document.getElementById('health-filter-group'); - if (this.dataset.value === 'retired' || this.dataset.value === 'ignored') { + if (this.dataset.value === 'retired' || this.dataset.value === 'ignored' || this.dataset.value === 'allocated') { healthGroup.style.display = 'none'; } else { healthGroup.style.display = 'flex'; diff --git a/templates/unit_detail.html b/templates/unit_detail.html index d17345b..d2fbf73 100644 --- a/templates/unit_detail.html +++ b/templates/unit_detail.html @@ -562,7 +562,7 @@
-
+
+ +
+ @@ -881,10 +893,14 @@ function populateViewMode() { document.getElementById('age').textContent = unitStatus.age || '--'; } else { - document.getElementById('statusIndicator').className = 'w-3 h-3 rounded-full bg-gray-400'; - document.getElementById('statusText').className = 'font-semibold text-gray-600 dark:text-gray-400'; - // Show "Benched" if not deployed, otherwise "No status data" - document.getElementById('statusText').textContent = !currentUnit.deployed ? 'Benched' : 'No status data'; + const isAllocated = currentUnit.allocated && !currentUnit.deployed; + document.getElementById('statusIndicator').className = isAllocated + ? 'w-3 h-3 rounded-full bg-orange-400' + : 'w-3 h-3 rounded-full bg-gray-400'; + document.getElementById('statusText').className = isAllocated + ? 'font-semibold text-orange-500 dark:text-orange-400' + : 'font-semibold text-gray-600 dark:text-gray-400'; + document.getElementById('statusText').textContent = isAllocated ? 'Allocated' : (!currentUnit.deployed ? 'Benched' : 'No status data'); document.getElementById('lastSeen').textContent = '--'; document.getElementById('age').textContent = '--'; } @@ -896,6 +912,11 @@ function populateViewMode() { } else if (currentUnit.out_for_calibration) { document.getElementById('retiredStatus').textContent = 'Out for Calibration'; document.getElementById('retiredStatus').className = 'font-medium text-purple-600 dark:text-purple-400'; + } else if (currentUnit.allocated && !currentUnit.deployed) { + document.getElementById('retiredStatus').textContent = currentUnit.allocated_to_project_id + ? `Allocated — ${currentUnit.allocated_to_project_id}` + : 'Allocated'; + document.getElementById('retiredStatus').className = 'font-medium text-orange-500 dark:text-orange-400'; } else { document.getElementById('retiredStatus').textContent = 'Active'; document.getElementById('retiredStatus').className = 'font-medium text-gray-900 dark:text-white'; @@ -1095,6 +1116,10 @@ function populateEditForm() { document.getElementById('retired').value = currentUnit.retired ? 'true' : ''; updateRetireButton(currentUnit.retired); document.getElementById('note').value = currentUnit.note || ''; + const allocatedChecked = currentUnit.allocated || false; + document.getElementById('allocated').checked = allocatedChecked; + document.getElementById('allocatedToProjectId').value = currentUnit.allocated_to_project_id || ''; + document.getElementById('allocatedProjectRow').style.display = allocatedChecked ? '' : 'none'; // Seismograph fields document.getElementById('lastCalibrated').value = currentUnit.last_calibrated || '';