diff --git a/backend/models.py b/backend/models.py
index 9abee13..04cc3d3 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -495,3 +495,6 @@ class JobReservationUnit(Base):
assignment_source = Column(String, default="specific") # "specific" | "filled" | "swap"
assigned_at = Column(DateTime, default=datetime.utcnow)
notes = Column(Text, nullable=True) # "Replacing BE17353" etc.
+
+ # Power requirements for this deployment slot
+ power_type = Column(String, nullable=True) # "ac" | "solar" | None
diff --git a/backend/routers/fleet_calendar.py b/backend/routers/fleet_calendar.py
index 170c7e0..2127bf7 100644
--- a/backend/routers/fleet_calendar.py
+++ b/backend/routers/fleet_calendar.py
@@ -223,6 +223,9 @@ async def get_reservation(
unit_ids = [a.unit_id for a in assignments]
units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all() if unit_ids else []
+ units_by_id = {u.id: u for u in units}
+ # Build power_type lookup from assignments
+ power_type_map = {a.unit_id: a.power_type for a in assignments}
return {
"id": reservation.id,
@@ -239,11 +242,12 @@ async def get_reservation(
"color": reservation.color,
"assigned_units": [
{
- "id": u.id,
- "last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None,
- "deployed": u.deployed
+ "id": uid,
+ "last_calibrated": units_by_id[uid].last_calibrated.isoformat() if uid in units_by_id and units_by_id[uid].last_calibrated else None,
+ "deployed": units_by_id[uid].deployed if uid in units_by_id else False,
+ "power_type": power_type_map.get(uid)
}
- for u in units
+ for uid in unit_ids
]
}
@@ -337,52 +341,50 @@ async def assign_units_to_reservation(
data = await request.json()
unit_ids = data.get("unit_ids", [])
+ # Optional per-unit power types: {"BE17354": "ac", "BE9441": "solar"}
+ power_types = data.get("power_types", {})
- if not unit_ids:
- raise HTTPException(status_code=400, detail="No units specified")
+ # Verify units exist (allow empty list to clear all assignments)
+ if unit_ids:
+ units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all()
+ found_ids = {u.id for u in units}
+ missing = set(unit_ids) - found_ids
+ if missing:
+ raise HTTPException(status_code=404, detail=f"Units not found: {', '.join(missing)}")
- # Verify units exist
- units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all()
- found_ids = {u.id for u in units}
- missing = set(unit_ids) - found_ids
- if missing:
- raise HTTPException(status_code=404, detail=f"Units not found: {', '.join(missing)}")
+ # Full replace: delete all existing assignments for this reservation first
+ db.query(JobReservationUnit).filter_by(reservation_id=reservation_id).delete()
+ db.flush()
- # Check for conflicts (already assigned to overlapping reservations)
+ # Check for conflicts with other reservations and insert new assignments
conflicts = []
for unit_id in unit_ids:
- # Check if unit is already assigned to this reservation
- existing = db.query(JobReservationUnit).filter_by(
- reservation_id=reservation_id,
- unit_id=unit_id
- ).first()
- if existing:
- continue # Already assigned, skip
-
# Check overlapping reservations
- overlapping = db.query(JobReservation).join(
- JobReservationUnit, JobReservation.id == JobReservationUnit.reservation_id
- ).filter(
- JobReservationUnit.unit_id == unit_id,
- JobReservation.id != reservation_id,
- JobReservation.start_date <= reservation.end_date,
- JobReservation.end_date >= reservation.start_date
- ).first()
+ if reservation.end_date:
+ overlapping = db.query(JobReservation).join(
+ JobReservationUnit, JobReservation.id == JobReservationUnit.reservation_id
+ ).filter(
+ JobReservationUnit.unit_id == unit_id,
+ JobReservation.id != reservation_id,
+ JobReservation.start_date <= reservation.end_date,
+ JobReservation.end_date >= reservation.start_date
+ ).first()
- if overlapping:
- conflicts.append({
- "unit_id": unit_id,
- "conflict_reservation": overlapping.name,
- "conflict_dates": f"{overlapping.start_date} - {overlapping.end_date}"
- })
- continue
+ if overlapping:
+ conflicts.append({
+ "unit_id": unit_id,
+ "conflict_reservation": overlapping.name,
+ "conflict_dates": f"{overlapping.start_date} - {overlapping.end_date}"
+ })
+ continue
# Add assignment
assignment = JobReservationUnit(
id=str(uuid.uuid4()),
reservation_id=reservation_id,
unit_id=unit_id,
- assignment_source="filled" if reservation.assignment_type == "quantity" else "specific"
+ assignment_source="filled" if reservation.assignment_type == "quantity" else "specific",
+ power_type=power_types.get(unit_id)
)
db.add(assignment)
@@ -511,9 +513,8 @@ async def get_reservations_list(
else:
end_date = date(end_year, end_month + 1, 1) - timedelta(days=1)
- # Include TBD reservations that started before window end
+ # Include TBD reservations that started before window end — show ALL device types
reservations = db.query(JobReservation).filter(
- JobReservation.device_type == device_type,
JobReservation.start_date <= end_date,
or_(
JobReservation.end_date >= start_date,
@@ -524,9 +525,24 @@ async def get_reservations_list(
# Get assignment counts
reservation_data = []
for res in reservations:
- assigned_count = db.query(JobReservationUnit).filter_by(
+ assignments = db.query(JobReservationUnit).filter_by(
reservation_id=res.id
- ).count()
+ ).all()
+ assigned_count = len(assignments)
+
+ # Enrich assignments with unit details
+ unit_ids = [a.unit_id for a in assignments]
+ units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all() if unit_ids else []
+ units_by_id = {u.id: u for u in units}
+ assigned_units = [
+ {
+ "id": a.unit_id,
+ "power_type": a.power_type,
+ "deployed": units_by_id[a.unit_id].deployed if a.unit_id in units_by_id else False,
+ "last_calibrated": units_by_id[a.unit_id].last_calibrated if a.unit_id in units_by_id else None,
+ }
+ for a in assignments
+ ]
# Check for calibration conflicts
conflicts = check_calibration_conflicts(db, res.id)
@@ -534,6 +550,7 @@ async def get_reservations_list(
reservation_data.append({
"reservation": res,
"assigned_count": assigned_count,
+ "assigned_units": assigned_units,
"has_conflicts": len(conflicts) > 0,
"conflict_count": len(conflicts)
})
@@ -549,6 +566,56 @@ async def get_reservations_list(
)
+@router.get("/api/fleet-calendar/planner-availability", response_class=JSONResponse)
+async def get_planner_availability(
+ device_type: str = "seismograph",
+ start_date: Optional[str] = None,
+ end_date: Optional[str] = None,
+ exclude_reservation_id: Optional[str] = None,
+ db: Session = Depends(get_db)
+):
+ """Get available units for the reservation planner split-panel UI.
+ Dates are optional — if omitted, returns all non-retired units regardless of reservations.
+ """
+ if start_date and end_date:
+ try:
+ start = date.fromisoformat(start_date)
+ end = date.fromisoformat(end_date)
+ except ValueError:
+ raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
+ units = get_available_units_for_period(db, start, end, device_type, exclude_reservation_id)
+ else:
+ # No dates: return all non-retired units of this type
+ from backend.models import RosterUnit as RU
+ from datetime import timedelta
+ all_units = db.query(RU).filter(
+ RU.device_type == device_type,
+ RU.retired == False
+ ).all()
+ units = []
+ for u in all_units:
+ expiry = (u.last_calibrated + timedelta(days=365)) if u.last_calibrated else None
+ units.append({
+ "id": u.id,
+ "last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None,
+ "expiry_date": expiry.isoformat() if expiry else None,
+ "calibration_status": "needs_calibration" if not u.last_calibrated else "valid",
+ "deployed": u.deployed,
+ "out_for_calibration": u.out_for_calibration or False,
+ "note": u.note or ""
+ })
+
+ # Sort: benched first (easier to assign), then deployed, then by ID
+ units.sort(key=lambda u: (1 if u["deployed"] else 0, u["id"]))
+
+ return {
+ "units": units,
+ "start_date": start_date,
+ "end_date": end_date,
+ "count": len(units)
+ }
+
+
@router.get("/api/fleet-calendar/available-units", response_class=HTMLResponse)
async def get_available_units_partial(
request: Request,
diff --git a/backend/services/fleet_calendar_service.py b/backend/services/fleet_calendar_service.py
index 33b5ce7..4331cf5 100644
--- a/backend/services/fleet_calendar_service.py
+++ b/backend/services/fleet_calendar_service.py
@@ -646,22 +646,20 @@ def get_available_units_for_period(
if unit.id in reserved_unit_ids:
continue
- # Check calibration through end of period
- if not unit.last_calibrated:
- continue # Needs calibration
-
- expiry_date = unit.last_calibrated + timedelta(days=365)
- if expiry_date <= end_date:
- continue # Calibration expires during period
-
- cal_status = get_calibration_status(unit, end_date, warning_days)
+ if unit.last_calibrated:
+ expiry_date = unit.last_calibrated + timedelta(days=365)
+ cal_status = get_calibration_status(unit, end_date, warning_days)
+ else:
+ expiry_date = None
+ cal_status = "needs_calibration"
available_units.append({
"id": unit.id,
- "last_calibrated": unit.last_calibrated.isoformat(),
- "expiry_date": expiry_date.isoformat(),
+ "last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else None,
+ "expiry_date": expiry_date.isoformat() if expiry_date else None,
"calibration_status": cal_status,
"deployed": unit.deployed,
+ "out_for_calibration": unit.out_for_calibration or False,
"note": unit.note or ""
})
diff --git a/templates/base.html b/templates/base.html
index ec90558..c4d391e 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -85,7 +85,7 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Project Reservations
+
+
+
+
Loading reservations...
+
+
+
+
+
+
+
+
New Reservation
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% for color in ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899'] %}
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
+
+
Monitoring Locations
+
+
+
+
+
+
+ Set dates and click "+ Add Location" to start adding units
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Set start and end dates to see available units
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Unit Detail
+
+
+
+
+
+
+
@@ -616,6 +846,39 @@ function openReservationModal() {
updateCalendarAvailability();
}
+function toggleResCard(id) {
+ const detail = document.getElementById('res-detail-' + id);
+ const chevron = document.getElementById('chevron-' + id);
+ if (!detail) return;
+ const isHidden = detail.classList.contains('hidden');
+ detail.classList.toggle('hidden', !isHidden);
+ if (chevron) chevron.style.transform = isHidden ? 'rotate(180deg)' : '';
+}
+
+// Event delegation for reservation cards (handles HTMX-loaded content)
+document.addEventListener('click', function(e) {
+ const header = e.target.closest('.res-card-header');
+ if (!header) return;
+ const id = header.dataset.resId;
+ if (id) toggleResCard(id);
+});
+
+async function deleteReservation(id, name) {
+ if (!confirm(`Delete reservation "${name}"?\n\nThis will remove all unit assignments.`)) return;
+ try {
+ const response = await fetch(`/api/fleet-calendar/reservations/${id}`, { method: 'DELETE' });
+ if (response.ok) {
+ htmx.trigger('#planner-reservations-list', 'load');
+ } else {
+ const data = await response.json();
+ alert('Error: ' + (data.detail || 'Failed to delete'));
+ }
+ } catch (error) {
+ console.error('Error:', error);
+ alert('Error deleting reservation');
+ }
+}
+
async function editReservation(id) {
try {
const response = await fetch(`/api/fleet-calendar/reservations/${id}`);
@@ -869,5 +1132,438 @@ document.addEventListener('keydown', function(e) {
closeReservationModal();
}
});
+
+// ============================================================
+// Tab + sub-tab switching
+// ============================================================
+function switchPlannerTab(tab) {
+ const isAssign = tab === 'assign';
+ document.getElementById('ptab-list').classList.toggle('hidden', isAssign);
+ document.getElementById('ptab-assign').classList.toggle('hidden', !isAssign);
+
+ ['list', 'assign'].forEach(t => {
+ const btn = document.getElementById(`ptab-btn-${t}`);
+ if (t === tab) {
+ btn.classList.add('bg-white', 'dark:bg-slate-600', 'text-gray-900', 'dark:text-white', 'shadow');
+ btn.classList.remove('text-gray-600', 'dark:text-gray-300', 'hover:text-gray-900', 'dark:hover:text-white');
+ } else {
+ btn.classList.remove('bg-white', 'dark:bg-slate-600', 'text-gray-900', 'dark:text-white', 'shadow');
+ btn.classList.add('text-gray-600', 'dark:text-gray-300', 'hover:text-gray-900', 'dark:hover:text-white');
+ }
+ });
+}
+
+function switchTab(tab) {
+ document.getElementById('view-calendar').classList.toggle('hidden', tab !== 'calendar');
+ document.getElementById('view-planner').classList.toggle('hidden', tab !== 'planner');
+
+ ['calendar', 'planner'].forEach(t => {
+ const btn = document.getElementById(`tab-btn-${t}`);
+ if (t === tab) {
+ btn.classList.add('bg-white', 'dark:bg-slate-600', 'text-gray-900', 'dark:text-white', 'shadow');
+ btn.classList.remove('text-gray-600', 'dark:text-gray-300', 'hover:text-gray-900', 'dark:hover:text-white');
+ } else {
+ btn.classList.remove('bg-white', 'dark:bg-slate-600', 'text-gray-900', 'dark:text-white', 'shadow');
+ btn.classList.add('text-gray-600', 'dark:text-gray-300', 'hover:text-gray-900', 'dark:hover:text-white');
+ }
+ });
+}
+
+// ============================================================
+// Reservation Planner
+// ============================================================
+let plannerState = {
+ reservation_id: null, // null = creating new
+ slots: [], // array of {unit_id: string|null, power_type: string|null, notes: string|null}
+ allUnits: [] // full list from server
+};
+let dragSrcIdx = null;
+
+function plannerDatesChanged() {
+ plannerLoadUnits();
+}
+
+async function plannerLoadUnits() {
+ const start = document.getElementById('planner-start').value;
+ const end = document.getElementById('planner-end').value;
+ const excludeId = plannerState.reservation_id || '';
+ const plannerDeviceType = document.querySelector('input[name="planner_device_type"]:checked')?.value || 'seismograph';
+
+ let url = `/api/fleet-calendar/planner-availability?device_type=${plannerDeviceType}`;
+ if (start && end && end >= start) {
+ url += `&start_date=${start}&end_date=${end}`;
+ }
+ if (excludeId) url += `&exclude_reservation_id=${excludeId}`;
+
+ try {
+ const resp = await fetch(url);
+ const data = await resp.json();
+ plannerState.allUnits = data.units || [];
+ const hasDates = start && end;
+ document.getElementById('planner-avail-count').textContent =
+ hasDates ? `(${plannerState.allUnits.length} available for period)` : `(${plannerState.allUnits.length} total)`;
+ plannerRenderUnits();
+ } catch (e) {
+ console.error('Planner load error', e);
+ }
+}
+
+function plannerFilterUnits() {
+ // Mutually exclusive checkboxes
+ const deployedOnly = document.getElementById('planner-deployed-only');
+ const benchedOnly = document.getElementById('planner-benched-only');
+ if (event && event.target === deployedOnly && deployedOnly.checked) benchedOnly.checked = false;
+ if (event && event.target === benchedOnly && benchedOnly.checked) deployedOnly.checked = false;
+ plannerRenderUnits();
+}
+
+function plannerRenderUnits() {
+ const search = document.getElementById('planner-search').value.toLowerCase();
+ const deployedOnly = document.getElementById('planner-deployed-only').checked;
+ const benchedOnly = document.getElementById('planner-benched-only').checked;
+ const slottedIds = new Set(plannerState.slots.map(s => s.unit_id).filter(Boolean));
+ const start = document.getElementById('planner-start').value;
+ const end = document.getElementById('planner-end').value;
+
+ let units = plannerState.allUnits.filter(u => {
+ if (deployedOnly && !u.deployed) return false;
+ if (benchedOnly && u.deployed) return false;
+ if (search && !u.id.toLowerCase().includes(search)) return false;
+ return true;
+ });
+
+ const placeholder = document.getElementById('planner-units-placeholder');
+ const list = document.getElementById('planner-units-list');
+
+ if (plannerState.allUnits.length === 0) {
+ placeholder.classList.remove('hidden');
+ placeholder.textContent = 'Loading units...';
+ list.querySelectorAll('.planner-unit-row').forEach(el => el.remove());
+ return;
+ }
+ placeholder.classList.add('hidden');
+ list.querySelectorAll('.planner-unit-row').forEach(el => el.remove());
+
+ if (units.length === 0) {
+ const empty = document.createElement('p');
+ empty.className = 'planner-unit-row text-sm text-gray-400 dark:text-gray-500 text-center py-8';
+ empty.textContent = 'No units match your filter';
+ list.appendChild(empty);
+ return;
+ }
+
+ for (const unit of units) {
+ const isSlotted = slottedIds.has(unit.id);
+ const row = document.createElement('div');
+ row.className = `planner-unit-row flex items-center justify-between px-3 py-2 rounded-lg border transition-colors ${
+ isSlotted
+ ? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800 opacity-60 cursor-default'
+ : 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer'
+ }`;
+ row.dataset.unitId = unit.id;
+ if (!isSlotted) row.onclick = () => plannerAssignUnit(unit.id);
+
+ const calDate = unit.last_calibrated
+ ? new Date(unit.last_calibrated + 'T00:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'})
+ : 'No cal date';
+
+ // Calibration expiry warning during deployment
+ let expiryWarning = '';
+ if (start && end && unit.expiry_date) {
+ const expiry = new Date(unit.expiry_date + 'T00:00:00');
+ const jobStart = new Date(start + 'T00:00:00');
+ const jobEnd = new Date(end + 'T00:00:00');
+ if (expiry >= jobStart && expiry <= jobEnd) {
+ const expiryStr = expiry.toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'});
+ expiryWarning = `
cal expires ${expiryStr}`;
+ }
+ }
+
+ const deployedBadge = unit.deployed
+ ? '
Deployed'
+ : '
Benched';
+
+ row.innerHTML = `
+
+
+
+ ${deployedBadge}
+ ${expiryWarning}
+
+
Cal: ${calDate}
+
+
+ ${isSlotted
+ ? 'Assigned'
+ : ''
+ }
+
+ `;
+ list.appendChild(row);
+ }
+}
+
+function openUnitDetailModal(unitId) {
+ document.getElementById('unit-detail-modal-title').textContent = unitId;
+ document.getElementById('unit-detail-iframe').src = `/unit/${unitId}?embed=1`;
+ document.getElementById('unit-detail-modal').classList.remove('hidden');
+}
+
+function closeUnitDetailModal() {
+ document.getElementById('unit-detail-modal').classList.add('hidden');
+ document.getElementById('unit-detail-iframe').src = '';
+}
+
+function plannerAddSlot() {
+ plannerState.slots.push({ unit_id: null, power_type: null, notes: null });
+ plannerRenderSlots();
+}
+
+function plannerAssignUnit(unitId) {
+ const emptyIdx = plannerState.slots.findIndex(s => !s.unit_id);
+ if (emptyIdx >= 0) {
+ plannerState.slots[emptyIdx].unit_id = unitId;
+ } else {
+ plannerState.slots.push({ unit_id: unitId, power_type: null, notes: null });
+ }
+ plannerRenderSlots();
+ plannerRenderUnits();
+}
+
+function plannerRemoveSlot(idx) {
+ plannerState.slots.splice(idx, 1);
+ plannerRenderSlots();
+ plannerRenderUnits();
+}
+
+function plannerSetPowerType(idx, value) {
+ plannerState.slots[idx].power_type = value || null;
+}
+
+function plannerSetSlotNotes(idx, value) {
+ plannerState.slots[idx].notes = value || null;
+}
+
+function plannerRenderSlots() {
+ const container = document.getElementById('planner-slots');
+ const emptyMsg = document.getElementById('planner-slots-empty');
+ container.querySelectorAll('.planner-slot-row').forEach(el => el.remove());
+
+ if (plannerState.slots.length === 0) {
+ emptyMsg.classList.remove('hidden');
+ return;
+ }
+ emptyMsg.classList.add('hidden');
+
+ plannerState.slots.forEach((slot, idx) => {
+ const row = document.createElement('div');
+ row.className = 'planner-slot-row flex flex-col gap-1.5 px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-slate-700/50';
+ row.dataset.idx = idx;
+ row.draggable = !!slot.unit_id;
+
+ // Drag events
+ if (slot.unit_id) {
+ row.addEventListener('dragstart', e => {
+ dragSrcIdx = idx;
+ e.dataTransfer.effectAllowed = 'move';
+ row.classList.add('opacity-50');
+ });
+ row.addEventListener('dragend', () => row.classList.remove('opacity-50'));
+ }
+ row.addEventListener('dragover', e => {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'move';
+ container.querySelectorAll('.planner-slot-row').forEach(r => r.classList.remove('ring-2', 'ring-blue-400'));
+ row.classList.add('ring-2', 'ring-blue-400');
+ });
+ row.addEventListener('dragleave', () => row.classList.remove('ring-2', 'ring-blue-400'));
+ row.addEventListener('drop', e => {
+ e.preventDefault();
+ row.classList.remove('ring-2', 'ring-blue-400');
+ if (dragSrcIdx === null || dragSrcIdx === idx) return;
+ // Swap unit_id and power_type only (keep location notes in place)
+ const srcSlot = plannerState.slots[dragSrcIdx];
+ const dstSlot = plannerState.slots[idx];
+ [srcSlot.unit_id, dstSlot.unit_id] = [dstSlot.unit_id, srcSlot.unit_id];
+ [srcSlot.power_type, dstSlot.power_type] = [dstSlot.power_type, srcSlot.power_type];
+ dragSrcIdx = null;
+ plannerRenderSlots();
+ plannerRenderUnits();
+ });
+
+ const powerSelect = `
+
`;
+
+ const dragHandle = slot.unit_id
+ ? `
⠿`
+ : `
`;
+
+ row.innerHTML = `
+
+ ${dragHandle}
+ Loc. ${idx + 1}
+ ${slot.unit_id
+ ? `${slot.unit_id}
+ ${powerSelect}
+ `
+ : `Empty — click a unit
+ ${powerSelect}
+ `
+ }
+
+
+
+
+ `;
+ container.appendChild(row);
+ });
+}
+
+function plannerClearSlot(idx) {
+ plannerState.slots[idx].unit_id = null;
+ plannerState.slots[idx].power_type = null;
+ plannerRenderSlots();
+ plannerRenderUnits();
+}
+
+function plannerReset() {
+ plannerState = { reservation_id: null, slots: [], allUnits: [] };
+ document.getElementById('planner-name').value = '';
+ document.getElementById('planner-project').value = '';
+ document.getElementById('planner-start').value = '';
+ document.getElementById('planner-end').value = '';
+ document.getElementById('planner-notes').value = '';
+ document.getElementById('planner-est-units').value = '';
+ document.getElementById('planner-search').value = '';
+ const defaultDt = document.querySelector('input[name="planner_device_type"][value="seismograph"]');
+ if (defaultDt) defaultDt.checked = true;
+ document.getElementById('planner-deployed-only').checked = false;
+ document.getElementById('planner-avail-count').textContent = '';
+ document.querySelector('input[name="planner_color"][value="#3B82F6"]').checked = true;
+ const titleEl = document.getElementById('planner-form-title');
+ if (titleEl) titleEl.textContent = 'New Reservation';
+ document.getElementById('planner-save-btn').textContent = 'Save Reservation';
+ plannerRenderSlots();
+ plannerRenderUnits();
+}
+
+async function plannerSave() {
+ const name = document.getElementById('planner-name').value.trim();
+ const start = document.getElementById('planner-start').value;
+ const end = document.getElementById('planner-end').value;
+ const projectId = document.getElementById('planner-project').value;
+ const notes = document.getElementById('planner-notes').value.trim();
+ const color = document.querySelector('input[name="planner_color"]:checked')?.value || '#3B82F6';
+ const estUnits = parseInt(document.getElementById('planner-est-units').value) || null;
+ const filledSlots = plannerState.slots.filter(s => s.unit_id);
+
+ if (!name) { alert('Please enter a reservation name.'); return; }
+ if (!start || !end) { alert('Please set start and end dates.'); return; }
+ if (end < start) { alert('End date must be after start date.'); return; }
+
+ const btn = document.getElementById('planner-save-btn');
+ btn.disabled = true;
+ btn.textContent = 'Saving...';
+
+ try {
+ const isEdit = !!plannerState.reservation_id;
+ const url = isEdit
+ ? `/api/fleet-calendar/reservations/${plannerState.reservation_id}`
+ : '/api/fleet-calendar/reservations';
+ const method = isEdit ? 'PUT' : 'POST';
+
+ const plannerDeviceType = document.querySelector('input[name="planner_device_type"]:checked')?.value || 'seismograph';
+ const payload = {
+ name, start_date: start, end_date: end,
+ project_id: projectId || null,
+ assignment_type: 'specific',
+ device_type: plannerDeviceType,
+ color, notes: notes || null,
+ quantity_needed: estUnits
+ };
+
+ const resp = await fetch(url, {
+ method,
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload)
+ });
+ const result = await resp.json();
+ if (!result.success) throw new Error(result.detail || 'Save failed');
+
+ const reservationId = isEdit ? plannerState.reservation_id : result.reservation_id;
+
+ // Always call assign-units (even with empty list) — endpoint does a full replace
+ const unitIds = filledSlots.map(s => s.unit_id);
+ const powerTypes = {};
+ filledSlots.forEach(s => { if (s.power_type) powerTypes[s.unit_id] = s.power_type; });
+ const assignResp = await fetch(
+ `/api/fleet-calendar/reservations/${reservationId}/assign-units`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ unit_ids: unitIds, power_types: powerTypes })
+ }
+ );
+ const assignResult = await assignResp.json();
+ if (assignResult.conflicts && assignResult.conflicts.length > 0) {
+ const conflictIds = assignResult.conflicts.map(c => c.unit_id).join(', ');
+ alert(`Saved! Note: ${assignResult.conflicts.length} unit(s) had conflicts and were not assigned: ${conflictIds}`);
+ }
+
+ plannerReset();
+ switchPlannerTab('list');
+ // Reload the reservations list partial
+ htmx.trigger('#planner-reservations-list', 'load');
+ } catch (e) {
+ console.error('Planner save error', e);
+ alert('Error saving reservation: ' + e.message);
+ } finally {
+ btn.disabled = false;
+ btn.textContent = plannerState.reservation_id ? 'Save Changes' : 'Save Reservation';
+ }
+}
+
+async function openPlanner(reservationId) {
+ plannerReset();
+ if (reservationId) {
+ try {
+ const resp = await fetch(`/api/fleet-calendar/reservations/${reservationId}`);
+ const res = await resp.json();
+ plannerState.reservation_id = reservationId;
+ document.getElementById('planner-name').value = res.name;
+ document.getElementById('planner-project').value = res.project_id || '';
+ document.getElementById('planner-start').value = res.start_date;
+ document.getElementById('planner-end').value = res.end_date || '';
+ document.getElementById('planner-notes').value = res.notes || '';
+ document.getElementById('planner-est-units').value = res.quantity_needed || '';
+ const colorRadio = document.querySelector(`input[name="planner_color"][value="${res.color}"]`);
+ if (colorRadio) colorRadio.checked = true;
+ const dtRadio = document.querySelector(`input[name="planner_device_type"][value="${res.device_type || 'seismograph'}"]`);
+ if (dtRadio) dtRadio.checked = true;
+ // Pre-fill slots from existing assigned units
+ for (const u of (res.assigned_units || [])) {
+ plannerState.slots.push({ unit_id: u.id, power_type: u.power_type || null, notes: u.notes || null });
+ }
+ const titleEl = document.getElementById('planner-form-title');
+ if (titleEl) titleEl.textContent = 'Edit: ' + res.name;
+ document.getElementById('planner-save-btn').textContent = 'Save Changes';
+ plannerRenderSlots();
+ if (res.start_date && res.end_date) plannerLoadUnits();
+ } catch (e) {
+ console.error('Error loading reservation for planner', e);
+ }
+ }
+ switchTab('planner');
+ switchPlannerTab('assign');
+}
{% endblock %}
diff --git a/templates/partials/fleet_calendar/reservations_list.html b/templates/partials/fleet_calendar/reservations_list.html
index 060f598..ba30d34 100644
--- a/templates/partials/fleet_calendar/reservations_list.html
+++ b/templates/partials/fleet_calendar/reservations_list.html
@@ -1,100 +1,158 @@
{% if reservations %}
-
+
{% for item in reservations %}
{% set res = item.reservation %}
-
-
-
-
{{ res.name }}
+
+
+
+
+
+
+
+ {% if res.notes %}
+
{{ res.notes }}
+ {% endif %}
+
+
+ {% if res.quantity_needed %}
+
Est. units needed
+
{{ res.quantity_needed }}
+ {% endif %}
+
Assigned
+
{{ item.assigned_count }} unit{{ 's' if item.assigned_count != 1 else '' }}
+ {% if res.quantity_needed and item.assigned_count < res.quantity_needed %}
+
Still needed
+
{{ res.quantity_needed - item.assigned_count }} more
+ {% endif %}
{% if item.has_conflicts %}
-
- {{ item.conflict_count }} conflict{{ 's' if item.conflict_count != 1 else '' }}
-
+
Cal swaps
+
{{ item.conflict_count }} unit{{ 's' if item.conflict_count != 1 else '' }} will need swapping during job
{% endif %}
-
- {{ res.start_date.strftime('%b %d, %Y') }} -
- {% if res.end_date %}
- {{ res.end_date.strftime('%b %d, %Y') }}
- {% elif res.end_date_tbd %}
- TBD
- {% if res.estimated_end_date %}
- (est. {{ res.estimated_end_date.strftime('%b %d, %Y') }})
+
+ {% if item.assigned_units %}
+
Monitoring Locations
+
+ {% for u in item.assigned_units %}
+
+
Loc. {{ loop.index }}
+
+
+ {% if u.power_type == 'ac' %}
+
A/C
+ {% elif u.power_type == 'solar' %}
+
Solar
{% endif %}
- {% else %}
-
Ongoing
- {% endif %}
-
- {% if res.notes %}
-
{{ res.notes }}
+ {% if u.deployed %}
+
Deployed
+ {% else %}
+
Benched
+ {% endif %}
+ {% if u.last_calibrated %}
+
Cal: {{ u.last_calibrated.strftime('%b %d, %Y') }}
+ {% endif %}
+
+ {% endfor %}
+
+ {% else %}
+
No units assigned yet. Click the clipboard icon to plan.
{% endif %}
-
-
- {% if res.assignment_type == 'quantity' %}
- {{ item.assigned_count }}/{{ res.quantity_needed or '?' }}
- {% else %}
- {{ item.assigned_count }}
- {% endif %}
-
-
- {{ 'units needed' if res.assignment_type == 'quantity' else 'units assigned' }}
-
-
-
+
{% endfor %}
-
+
{% else %}
-
No reservations for {{ year }}
+
No reservations found
Click "New Reservation" to plan unit assignments
{% endif %}