feat: add allocated status and project allocation to unit management

- Updated dashboard to display allocated units alongside deployed and benched units.
- Introduced a quick-info modal for units, showing detailed information including calibration status, project allocation, and upcoming jobs.
- Enhanced fleet calendar with a new quick-info modal for units, allowing users to view unit details without navigating away.
- Modified devices table to include allocated status and visual indicators for allocated units.
- Added allocated filter option in the roster view for better unit management.
- Implemented backend migration to add 'allocated' and 'allocated_to_project_id' columns to the roster table.
- Updated unit detail view to reflect allocated status and allow for project allocation input.
This commit is contained in:
2026-03-26 05:05:34 +00:00
parent 4f71d528ce
commit 64d4423308
11 changed files with 501 additions and 90 deletions

View File

@@ -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)

View File

@@ -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()

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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),

View File

@@ -57,6 +57,10 @@
<span class="text-gray-600 dark:text-gray-400">Benched</span>
<span id="benched-units" class="text-3xl md:text-2xl font-bold text-gray-600 dark:text-gray-400">--</span>
</div>
<div class="flex justify-between items-center">
<span class="text-orange-600 dark:text-orange-400">Allocated</span>
<span id="allocated-units" class="text-3xl md:text-2xl font-bold text-orange-500 dark:text-orange-400">--</span>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">By Device Type:</p>
<div class="flex justify-between items-center mb-1">
@@ -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;

View File

@@ -650,7 +650,7 @@
<!-- Fleet Summary (shown on jobs list) -->
<div id="right-fleet-summary" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 flex flex-col gap-4">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Summary</h2>
<div id="fleet-summary-stats" class="grid grid-cols-2 sm:grid-cols-4 gap-3 text-center">
<div id="fleet-summary-stats" class="flex flex-col gap-0">
<!-- Populated by JS -->
</div>
<input type="text" id="summary-search" placeholder="Search by unit ID..."
@@ -713,6 +713,70 @@
</div>
</div>
<!-- Unit Quick-Info Modal -->
<div id="unit-quick-modal" class="fixed inset-0 z-50 hidden">
<div class="fixed inset-0 bg-black/50" onclick="closeUnitQuickModal()"></div>
<div class="fixed inset-0 flex items-center justify-center p-4 pointer-events-none">
<div id="unit-quick-modal-inner" class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md pointer-events-auto" onclick="event.stopPropagation()">
<!-- Header -->
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3">
<h3 id="uqm-title" class="text-lg font-bold text-gray-900 dark:text-white"></h3>
<span id="uqm-deployed-badge"></span>
<span id="uqm-outforcal-badge"></span>
</div>
<button onclick="closeUnitQuickModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<!-- Body -->
<div class="px-5 py-4 flex flex-col gap-4">
<!-- Cal row -->
<div class="flex gap-6">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-0.5">Last Calibration</p>
<p id="uqm-cal-date" class="text-sm font-medium text-gray-900 dark:text-white"></p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-0.5">Cal Due</p>
<p id="uqm-cal-due" class="text-sm font-medium"></p>
</div>
</div>
<!-- Location / address -->
<div id="uqm-address-row" class="hidden">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-0.5">Address / Location</p>
<p id="uqm-address" class="text-sm text-gray-800 dark:text-gray-200"></p>
</div>
<!-- Project -->
<div id="uqm-project-row" class="hidden">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-0.5">Project</p>
<p id="uqm-project" class="text-sm text-gray-800 dark:text-gray-200"></p>
</div>
<!-- Modem -->
<div id="uqm-modem-row" class="hidden">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-0.5">Deployed With Modem</p>
<p id="uqm-modem" class="text-sm text-gray-800 dark:text-gray-200"></p>
</div>
<!-- Last seen -->
<div id="uqm-lastseen-row" class="hidden">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-0.5">Last Seen</p>
<p id="uqm-lastseen" class="text-sm text-gray-800 dark:text-gray-200"></p>
</div>
<!-- Note -->
<div id="uqm-note-row" class="hidden">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-0.5">Note</p>
<p id="uqm-note" class="text-sm text-gray-800 dark:text-gray-200 italic"></p>
</div>
<!-- Reservations -->
<div id="uqm-reservations-row" class="hidden">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Upcoming Jobs</p>
<div id="uqm-reservations" class="flex flex-col gap-1"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Day Detail Slide Panel -->
<div id="panel-backdrop" class="panel-backdrop" onclick="closeDayPanel()"></div>
<div id="day-panel" class="slide-panel">
@@ -1678,7 +1742,7 @@ function plannerRenderUnits() {
row.innerHTML = `
<div class="flex flex-col gap-0.5 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<button onclick="event.stopPropagation(); openUnitDetailModal('${unit.id}')"
<button onclick="event.stopPropagation(); openUnitQuickModal('${unit.id}')"
class="font-medium text-blue-600 dark:text-blue-400 hover:underline text-sm">${unit.id}</button>
${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
? '<span class="text-xs px-2 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded-full">Deployed</span>'
: '<span class="text-xs px-2 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded-full">Benched</span>';
// Out for cal badge
if (u.out_for_calibration) {
document.getElementById('uqm-outforcal-badge').innerHTML =
'<span class="text-xs px-2 py-0.5 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 rounded-full">Out for Cal</span>';
}
// 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 `<div class="flex items-center gap-2 text-sm">
<span class="w-2.5 h-2.5 rounded-full flex-shrink-0" style="background:${r.color}"></span>
<span class="font-medium text-gray-800 dark:text-gray-200">${r.name}</span>
<span class="text-gray-400 dark:text-gray-500">${s}${e}${loc}</span>
</div>`;
}).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 `<button onclick="summarySetFilter(${f === null ? 'null' : `'${f}'`})"
class="rounded-lg p-3 text-left w-full cursor-pointer transition-all ring-2 ${isActive ? ringColor : 'ring-transparent'} ${colorClass}">
<p class="text-2xl font-bold">${count}</p>
<p class="text-xs opacity-80">${label}</p>
</button>`;
};
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 = `
<button onclick="summarySetFilter(null)"
class="${cardBase} ${!active ? 'ring-gray-400 dark:ring-gray-300' : 'ring-transparent'} bg-gray-50 dark:bg-slate-700 hover:bg-gray-100 dark:hover:bg-slate-600">
<p class="text-2xl font-bold text-gray-900 dark:text-white">${total}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Total</p>
</button>
<button onclick="summarySetFilter('deployed')"
class="${cardBase} ${active === 'deployed' ? 'ring-green-500' : 'ring-transparent'} bg-green-50 dark:bg-green-900/20 hover:bg-green-100 dark:hover:bg-green-900/40">
<p class="text-2xl font-bold text-green-700 dark:text-green-400">${deployed}</p>
<p class="text-xs text-green-600 dark:text-green-500">Deployed</p>
</button>
<button onclick="summarySetFilter('benched')"
class="${cardBase} ${active === 'benched' ? 'ring-blue-500' : 'ring-transparent'} bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/40">
<p class="text-2xl font-bold text-blue-700 dark:text-blue-400">${benched}</p>
<p class="text-xs text-blue-600 dark:text-blue-500">Benched</p>
</button>
<button onclick="summarySetFilter('cal_expired')"
class="${cardBase} ${active === 'cal_expired' ? 'ring-red-500' : 'ring-transparent'} bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/40">
<p class="text-2xl font-bold text-red-700 dark:text-red-400">${calExpired}</p>
<p class="text-xs text-red-600 dark:text-red-500">Cal Expired</p>
</button>
`;
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2">
${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')}
</div>
<div class="flex flex-wrap gap-1.5 mt-2">
${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)}
</div>`;
// 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 = '<p class="text-sm text-gray-400 dark:text-gray-500 text-center py-8">No units found</p>';
list.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 text-center py-8">No units match</p>';
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
? '<span class="text-xs px-1.5 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded">Deployed</span>'
: '<span class="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded">Benched</span>';
const calBadge = expired
? `<span class="text-xs px-1.5 py-0.5 bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded">Cal expired</span>`
: `<span class="text-xs text-gray-400 dark:text-gray-500">Cal: ${calDate}</span>`;
const outForCalBadge = u._outForCal
? '<span class="text-xs px-1.5 py-0.5 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 rounded">Out for Cal</span>'
: '';
const allocatedBadge = u._allocated
? `<span class="text-xs px-1.5 py-0.5 bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400 rounded" title="${u.allocated_to_project_id ? 'For: ' + u.allocated_to_project_id : ''}">Allocated${u.allocated_to_project_id ? ': ' + u.allocated_to_project_id : ''}</span>`
: '';
let calBadge;
if (!calDate) {
calBadge = '<span class="text-xs px-1.5 py-0.5 bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded">Cal expired</span>';
} else if (u._calExpired) {
calBadge = `<span class="text-xs px-1.5 py-0.5 bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded">Cal expired ${expiryDate}</span>`;
} else {
calBadge = `<span class="text-xs text-gray-400 dark:text-gray-500">Cal: ${calDate} · exp. ${expiryDate}</span>`;
}
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 `<span class="text-xs px-1.5 py-0.5 rounded font-medium" style="background-color:${r.color}22; color:${r.color}; border:1px solid ${r.color}66;"><span class="opacity-60">Reserved:</span> ${r.reservation_name} ${s}${e}</span>`;
return `<span class="text-xs px-1.5 py-0.5 rounded font-medium" style="background-color:${r.color}22; color:${r.color}; border:1px solid ${r.color}66;">${r.reservation_name} ${s}${e}</span>`;
}).join('');
return `
<div class="flex flex-col gap-1 px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50">
<div class="flex items-center gap-2 flex-wrap">
<button onclick="openUnitDetailModal('${u.id}')"
<button onclick="openUnitQuickModal('${u.id}')"
class="font-medium text-blue-600 dark:text-blue-400 hover:underline text-sm">${u.id}</button>
${deployedBadge}
${calBadge}
${deployedBadge}${outForCalBadge}${allocatedBadge}${calBadge}
</div>
${resBadges ? `<div class="flex flex-wrap gap-1">${resBadges}</div>` : ''}
</div>`;
}).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 `<button onclick="summaryToggleFilter('${f}')"
class="text-xs px-2.5 py-1 rounded-full font-medium border transition-colors ${pillColors[f]}">
${label} <span class="${isActive ? 'opacity-80' : 'opacity-60'}">${count}</span>
</button>`;
}
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() {
? `<span class="text-gray-300 dark:text-gray-600 cursor-grab active:cursor-grabbing select-none" title="Drag to reorder">⠿</span>`
: `<span class="w-4"></span>`;
// 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
? '<span class="px-1.5 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded">Deployed</span>'
: '<span class="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded">Benched</span>';
const outForCalBadge = uData.out_for_calibration
? '<span class="px-1.5 py-0.5 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 rounded">Out for Cal</span>'
: '';
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 = `<span class="text-xs text-red-500 dark:text-red-400 font-medium">No cal</span>`;
} else if (calExpired) {
const expiryStr = expiry.toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'});
if (jobStart && jobEnd && expiry >= jobStart && expiry <= jobEnd) {
expiryBadge = `<span class="px-1.5 py-0.5 bg-amber-50 dark:bg-amber-900/20 text-amber-600 dark:text-amber-400 rounded border border-amber-200 dark:border-amber-800">cal expires ${expiryStr}</span>`;
} else if (!jobStart || !jobEnd) {
expiryBadge = `<span class="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded">cal exp. ${expiryStr}</span>`;
}
calInline = `<span class="text-xs text-red-500 dark:text-red-400 font-medium">Cal exp. ${expiryStr}</span>`;
} else if (expiresInJob) {
const expiryStr = expiry.toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'});
calInline = `<span class="text-xs text-amber-500 dark:text-amber-400 font-medium">Cal exp. ${expiryStr}</span>`;
} else {
expiryBadge = '<span class="px-1.5 py-0.5 bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded">No cal</span>';
const expiryStr = expiry.toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'});
calInline = `<span class="text-xs text-gray-400 dark:text-gray-500">Cal exp. ${expiryStr}</span>`;
}
unitInfoLine = `<div class="pl-6 flex items-center gap-1.5 flex-wrap text-xs mt-0.5">${deployedBadge}${outForCalBadge}${expiryBadge}<span class="text-gray-400 dark:text-gray-500">Cal: ${calStr}</span></div>`;
}
}
@@ -1998,7 +2229,8 @@ function plannerRenderSlots() {
${dragHandle}
<span class="text-sm text-gray-500 dark:text-gray-400 w-16 flex-shrink-0">Loc. ${idx + 1}</span>
${slot.unit_id
? `<span class="flex-1 font-medium text-gray-900 dark:text-white">${slot.unit_id}</span>
? `<button onclick="openUnitQuickModal('${slot.unit_id}')" class="font-medium text-blue-600 dark:text-blue-400 hover:underline">${slot.unit_id}</button>
${calInline ? `<span class="flex-1">${calInline}</span>` : '<span class="flex-1"></span>'}
${powerSelect}
<button onclick="plannerClearSlot(${idx})" class="text-xs text-gray-400 hover:text-red-500 flex-shrink-0" title="Remove unit">✕</button>`
: `<button onclick="plannerSelectSlot(${idx})" class="flex-1 text-left text-sm italic ${plannerSelectedSlotIdx === idx ? 'text-blue-600 dark:text-blue-400 font-medium' : 'text-gray-400 dark:text-gray-500'}">${plannerSelectedSlotIdx === idx ? '← click a unit to assign here' : 'Empty — click to select'}</button>
@@ -2006,7 +2238,6 @@ function plannerRenderSlots() {
<button onclick="plannerRemoveSlot(${idx})" class="text-xs text-gray-400 hover:text-red-500 flex-shrink-0" title="Remove location">✕</button>`
}
</div>
${unitInfoLine}
<div class="pl-8 flex flex-col gap-1 mt-1">
<input type="text" value="${slot.location_name ? slot.location_name.replace(/"/g, '&quot;') : ''}"
oninput="plannerSetLocationName(${idx}, this.value)"

View File

@@ -51,7 +51,7 @@
{% for unit in units %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
data-device-type="{{ unit.device_type }}"
data-status="{% if unit.deployed %}deployed{% elif unit.out_for_calibration %}out_for_calibration{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% else %}benched{% endif %}"
data-status="{% if unit.deployed %}deployed{% elif unit.out_for_calibration %}out_for_calibration{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% elif unit.allocated %}allocated{% else %}benched{% endif %}"
data-health="{{ unit.status }}"
data-id="{{ unit.id }}"
data-type="{{ unit.device_type }}"
@@ -62,6 +62,8 @@
<div class="flex items-center space-x-2">
{% if unit.out_for_calibration %}
<span class="w-3 h-3 rounded-full bg-purple-500" title="Out for Calibration"></span>
{% elif unit.allocated %}
<span class="w-3 h-3 rounded-full bg-orange-400" title="Allocated"></span>
{% elif not unit.deployed %}
<span class="w-3 h-3 rounded-full bg-gray-400 dark:bg-gray-500" title="Benched"></span>
{% elif unit.status == 'OK' %}
@@ -76,6 +78,8 @@
<span class="w-2 h-2 rounded-full bg-blue-500" title="Deployed"></span>
{% elif unit.out_for_calibration %}
<span class="w-2 h-2 rounded-full bg-purple-400" title="Out for Calibration"></span>
{% elif unit.allocated %}
<span class="w-2 h-2 rounded-full bg-orange-400" title="Allocated"></span>
{% else %}
<span class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600" title="Benched"></span>
{% endif %}
@@ -207,7 +211,7 @@
<div class="unit-card device-card"
onclick="openUnitModal('{{ unit.id }}', '{{ unit.status }}', '{{ unit.age }}')"
data-device-type="{{ unit.device_type }}"
data-status="{% if unit.deployed %}deployed{% elif unit.out_for_calibration %}out_for_calibration{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% else %}benched{% endif %}"
data-status="{% if unit.deployed %}deployed{% elif unit.out_for_calibration %}out_for_calibration{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% elif unit.allocated %}allocated{% else %}benched{% endif %}"
data-health="{{ unit.status }}"
data-unit-id="{{ unit.id }}"
data-age="{{ unit.age }}">
@@ -216,6 +220,8 @@
<div class="flex items-center gap-2">
{% if unit.out_for_calibration %}
<span class="w-4 h-4 rounded-full bg-purple-500" title="Out for Calibration"></span>
{% elif unit.allocated %}
<span class="w-4 h-4 rounded-full bg-orange-400" title="Allocated"></span>
{% elif not unit.deployed %}
<span class="w-4 h-4 rounded-full bg-gray-400 dark:bg-gray-500" title="Benched"></span>
{% elif unit.status == 'OK' %}
@@ -231,12 +237,13 @@
</div>
<span class="px-3 py-1 rounded-full text-xs font-medium
{% if unit.out_for_calibration %}bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300
{% elif unit.allocated %}bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-300
{% elif unit.status == 'OK' %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300
{% elif unit.status == 'Pending' %}bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300
{% elif unit.status == 'Missing' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300
{% else %}bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400
{% endif %}">
{% 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 %}
</span>
</div>

View File

@@ -66,6 +66,7 @@
<button class="filter-btn filter-status active-filter" data-value="all">All</button>
<button class="filter-btn filter-status" data-value="deployed">Deployed</button>
<button class="filter-btn filter-status" data-value="benched">Benched</button>
<button class="filter-btn filter-status" data-value="allocated">Allocated</button>
<button class="filter-btn filter-status" data-value="retired">Retired</button>
<button class="filter-btn filter-status" data-value="ignored">Ignored</button>
</div>
@@ -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';

View File

@@ -562,7 +562,7 @@
<!-- Status Checkboxes -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-4 space-y-3">
<div class="flex items-center gap-6">
<div class="flex items-center gap-6 flex-wrap">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="deployed" id="deployed" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
@@ -573,6 +573,18 @@
class="w-4 h-4 text-purple-600 focus:ring-purple-500 rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Out for Calibration</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="allocated" id="allocated" value="true"
onchange="document.getElementById('allocatedProjectRow').style.display = this.checked ? '' : 'none'"
class="w-4 h-4 text-orange-500 focus:ring-orange-400 rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Allocated</span>
</label>
</div>
<div id="allocatedProjectRow" style="display:none">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Allocated to Project</label>
<input type="text" name="allocated_to_project_id" id="allocatedToProjectId"
placeholder="Project name or ID"
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-orange-400 text-sm">
</div>
<!-- Hidden field for retired — controlled by the Retire button below -->
<input type="hidden" name="retired" id="retired" value="">
@@ -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 || '';