Files
terra-view/templates/fleet_calendar.html
serversdown b3ec249c5e feat: add location names to reservation slots and promote-to-project
- Each monitoring location slot can now have a named location (e.g. "North Gate")
- Location names and slot order are persisted and restored in the planner
- Location names display in the expanded reservation card view
- Added "Promote to Project" button that converts a reservation into a
  tracked project with monitoring locations and unit assignments pre-filled

Requires DB migration on prod:
  ALTER TABLE job_reservation_units ADD COLUMN location_name TEXT;
  ALTER TABLE job_reservation_units ADD COLUMN slot_index INTEGER;
2026-03-18 22:15:46 +00:00

1671 lines
76 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}Fleet Calendar - Terra-View{% endblock %}
{% block extra_head %}
<style>
/* Calendar grid layout */
.calendar-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
}
@media (max-width: 1280px) {
.calendar-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 768px) {
.calendar-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.calendar-grid {
grid-template-columns: 1fr;
}
}
/* Month card */
.month-card {
background: white;
border-radius: 0.5rem;
padding: 0.75rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.dark .month-card {
background: rgb(30 41 59);
}
/* Day grid */
.day-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
font-size: 0.75rem;
}
.day-header {
text-align: center;
font-weight: 600;
color: #6b7280;
padding: 0.25rem 0;
}
.dark .day-header {
color: #9ca3af;
}
/* Day cell */
.day-cell {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
cursor: pointer;
position: relative;
transition: all 0.15s ease;
font-size: 0.7rem;
}
.day-cell:hover {
transform: scale(1.1);
z-index: 10;
}
.day-cell.today {
ring: 2px;
ring-color: #f48b1c;
font-weight: bold;
}
/* Default neutral day style */
.day-cell.neutral {
background-color: #f3f4f6;
color: #374151;
}
.dark .day-cell.neutral {
background-color: rgba(55, 65, 81, 0.5);
color: #d1d5db;
}
/* Status indicator dot */
.day-cell .status-dot {
position: absolute;
top: 2px;
right: 2px;
width: 5px;
height: 5px;
border-radius: 50%;
}
.day-cell .status-dot.expired {
background-color: #ef4444;
}
/* Reservation mode colors - applied dynamically */
.day-cell.reservation-available {
background-color: #dcfce7;
color: #166534;
}
.dark .day-cell.reservation-available {
background-color: rgba(34, 197, 94, 0.2);
color: #86efac;
}
.day-cell.reservation-partial {
background-color: #fef3c7;
color: #92400e;
}
.dark .day-cell.reservation-partial {
background-color: rgba(245, 158, 11, 0.2);
color: #fcd34d;
}
.day-cell.reservation-unavailable {
background-color: #fee2e2;
color: #991b1b;
}
.dark .day-cell.reservation-unavailable {
background-color: rgba(239, 68, 68, 0.2);
color: #fca5a5;
}
/* Legacy status colors (kept for day detail) */
.day-cell.some-reserved {
background-color: #dbeafe;
color: #1e40af;
}
.dark .day-cell.some-reserved {
background-color: rgba(59, 130, 246, 0.2);
color: #93c5fd;
}
.day-cell.empty {
background: transparent;
cursor: default;
}
.day-cell.empty:hover {
transform: none;
}
/* Reservation bar */
.reservation-bar {
display: flex;
align-items: center;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
margin-bottom: 0.5rem;
cursor: pointer;
transition: opacity 0.15s ease;
}
.reservation-bar:hover {
opacity: 0.8;
}
/* Slide-over panel */
.slide-panel {
position: fixed;
top: 0;
right: 0;
width: 100%;
max-width: 28rem;
height: 100vh;
background: white;
box-shadow: -4px 0 15px rgba(0,0,0,0.1);
transform: translateX(100%);
transition: transform 0.3s ease;
z-index: 50;
overflow-y: auto;
}
.dark .slide-panel {
background: rgb(30 41 59);
}
.slide-panel.open {
transform: translateX(0);
}
.panel-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.3);
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
z-index: 40;
}
.panel-backdrop.open {
opacity: 1;
visibility: visible;
}
</style>
{% endblock %}
{% block content %}
<div class="mb-6">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Fleet Calendar</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">Plan unit assignments and track calibrations</p>
</div>
</div>
</div>
<!-- View Tabs -->
<div class="flex rounded-lg bg-gray-100 dark:bg-gray-700 p-1 mb-6 w-fit">
<button id="tab-btn-planner" onclick="switchTab('planner')"
class="px-4 py-2 rounded-md text-sm font-medium transition-colors bg-white dark:bg-slate-600 text-gray-900 dark:text-white shadow">
Reservation Planner
</button>
<button id="tab-btn-calendar" onclick="switchTab('calendar')"
class="px-4 py-2 rounded-md text-sm font-medium transition-colors text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">
Calendar
</button>
</div>
<div id="view-calendar" class="hidden">
<!-- Summary Stats -->
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
<div class="bg-white dark:bg-slate-800 rounded-lg p-4 shadow">
<p class="text-sm text-gray-500 dark:text-gray-400">Total Units</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ calendar_data.total_units }}</p>
</div>
<div class="bg-green-50 dark:bg-green-900/20 rounded-lg p-4 shadow">
<p class="text-sm text-green-600 dark:text-green-400">Available Today</p>
<p class="text-2xl font-bold text-green-700 dark:text-green-300" id="available-today">--</p>
</div>
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 shadow">
<p class="text-sm text-blue-600 dark:text-blue-400">Reserved Today</p>
<p class="text-2xl font-bold text-blue-700 dark:text-blue-300" id="reserved-today">--</p>
</div>
<div class="bg-yellow-50 dark:bg-yellow-900/20 rounded-lg p-4 shadow">
<p class="text-sm text-yellow-600 dark:text-yellow-400">Expiring Soon</p>
<p class="text-2xl font-bold text-yellow-700 dark:text-yellow-300" id="expiring-today">--</p>
</div>
<div class="bg-red-50 dark:bg-red-900/20 rounded-lg p-4 shadow">
<p class="text-sm text-red-600 dark:text-red-400">Cal. Expired</p>
<p class="text-2xl font-bold text-red-700 dark:text-red-300" id="expired-today">--</p>
</div>
</div>
<!-- Action Bar -->
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<div class="flex items-center gap-4">
<!-- Device Type Toggle -->
<div class="flex rounded-lg bg-gray-100 dark:bg-gray-700 p-1">
<a href="/fleet-calendar?year={{ start_year }}&month={{ start_month }}&device_type=seismograph"
class="px-4 py-2 rounded-md text-sm font-medium transition-colors {% if device_type == 'seismograph' %}bg-white dark:bg-slate-600 text-gray-900 dark:text-white shadow{% else %}text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white{% endif %}">
Seismographs
</a>
<a href="/fleet-calendar?year={{ start_year }}&month={{ start_month }}&device_type=slm"
class="px-4 py-2 rounded-md text-sm font-medium transition-colors {% if device_type == 'slm' %}bg-white dark:bg-slate-600 text-gray-900 dark:text-white shadow{% else %}text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white{% endif %}">
SLMs
</a>
</div>
<!-- Legend -->
<div class="hidden md:flex items-center gap-4 text-sm" id="main-legend">
<span class="flex items-center gap-1">
<span class="w-3 h-3 rounded bg-gray-200 dark:bg-gray-600 relative">
<span class="absolute top-0 right-0 w-1.5 h-1.5 rounded-full bg-red-500"></span>
</span>
Cal expires
</span>
</div>
<!-- Reservation mode legend (hidden by default) -->
<div class="hidden md:hidden items-center gap-4 text-sm" id="reservation-legend">
<span class="flex items-center gap-1">
<span class="w-3 h-3 rounded bg-green-200 dark:bg-green-700"></span>
Available
</span>
<span class="flex items-center gap-1">
<span class="w-3 h-3 rounded bg-yellow-200 dark:bg-yellow-700"></span>
Partial
</span>
<span class="flex items-center gap-1">
<span class="w-3 h-3 rounded bg-red-200 dark:bg-red-700"></span>
Unavailable
</span>
</div>
</div>
<button onclick="openReservationModal()"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center gap-2">
<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="M12 4v16m8-8H4"/>
</svg>
New Reservation
</button>
</div>
<!-- Calendar Grid -->
<div class="calendar-grid mb-8">
{% for month_data in calendar_data.months %}
<div class="month-card">
<h3 class="font-semibold text-gray-900 dark:text-white mb-2 text-center">
{{ month_data.short_name }} '{{ month_data.year_short }}
</h3>
<div class="day-grid">
<!-- Day headers -->
<div class="day-header">S</div>
<div class="day-header">M</div>
<div class="day-header">T</div>
<div class="day-header">W</div>
<div class="day-header">T</div>
<div class="day-header">F</div>
<div class="day-header">S</div>
<!-- Empty cells for alignment (first_weekday is 0=Mon, we need 0=Sun) -->
{% set first_day_offset = (month_data.first_weekday + 1) % 7 %}
{% for i in range(first_day_offset) %}
<div class="day-cell empty"></div>
{% endfor %}
<!-- Day cells -->
{% for day_num in range(1, month_data.num_days + 1) %}
{% set day_data = month_data.days[day_num] %}
{% set date_str = '%04d-%02d-%02d'|format(month_data.year, month_data.month, day_num) %}
{% set is_today = date_str == today %}
{% set has_cal_expiring = day_data.cal_expiring_on_day is defined and day_data.cal_expiring_on_day > 0 %}
<div class="day-cell neutral {% if is_today %}today ring-2 ring-seismo-orange{% endif %}"
data-date="{{ date_str }}"
data-available="{{ day_data.available }}"
onclick="openDayPanel('{{ date_str }}')"
title="Available: {{ day_data.available }}, Reserved: {{ day_data.reserved }}{% if has_cal_expiring %}, Cal expires: {{ day_data.cal_expiring_on_day }}{% endif %}">
{{ day_num }}
{% if has_cal_expiring %}
<span class="status-dot expired"></span>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
<!-- Month Navigation (centered below calendar) -->
<div class="flex items-center justify-center gap-3 mb-8">
<a href="/fleet-calendar?year={{ prev_year }}&month={{ prev_month }}&device_type={{ device_type }}"
class="px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
title="Previous month">
<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="M15 19l-7-7 7-7"/>
</svg>
</a>
<span class="text-lg font-bold text-gray-900 dark:text-white px-3">
{{ calendar_data.months[0].name }} {{ calendar_data.months[0].year }} - {{ calendar_data.months[11].name }} {{ calendar_data.months[11].year }}
</span>
<a href="/fleet-calendar?year={{ next_year }}&month={{ next_month }}&device_type={{ device_type }}"
class="px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
title="Next month">
<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="M9 5l7 7-7 7"/>
</svg>
</a>
<a href="/fleet-calendar?device_type={{ device_type }}"
class="ml-2 px-4 py-2 rounded-lg bg-seismo-orange text-white hover:bg-orange-600">
Today
</a>
</div>
<!-- Active Reservations -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-8">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Active Reservations</h2>
<div id="reservations-list"
hx-get="/api/fleet-calendar/reservations-list?year={{ start_year }}&month={{ start_month }}&device_type={{ device_type }}"
hx-trigger="calendar-reservations-refresh from:body"
hx-swap="innerHTML">
<p class="text-gray-500">Loading reservations...</p>
</div>
</div>
</div><!-- end #view-calendar -->
<!-- Reservation Planner View -->
<div id="view-planner">
<div class="flex flex-col lg:flex-row gap-6 min-h-[70vh]">
<!-- LEFT PANEL: sub-tabs switch content here only -->
<div class="lg:w-2/5 flex flex-col gap-4">
<!-- Sub-tab bar -->
<div class="flex rounded-lg bg-gray-100 dark:bg-gray-700 p-1 w-fit">
<button id="ptab-btn-list" onclick="switchPlannerTab('list')"
class="px-4 py-2 rounded-md text-sm font-medium transition-colors bg-white dark:bg-slate-600 text-gray-900 dark:text-white shadow">
Reservations
</button>
<button id="ptab-btn-assign" onclick="switchPlannerTab('assign')"
class="px-4 py-2 rounded-md text-sm font-medium transition-colors text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">
Assign Units
</button>
</div>
<!-- Sub-tab: Reservations list -->
<div id="ptab-list" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 flex flex-col gap-4">
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Project Reservations</h2>
<button onclick="plannerReset(); switchPlannerTab('assign')"
class="px-3 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm flex items-center gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
New
</button>
</div>
<div id="planner-reservations-list" class="overflow-y-visible"
hx-get="/api/fleet-calendar/reservations-list?year={{ start_year }}&month={{ start_month }}&device_type={{ device_type }}"
hx-trigger="load"
hx-swap="innerHTML">
<p class="text-gray-500">Loading reservations...</p>
</div>
</div>
<!-- Sub-tab: Assign Units form -->
<div id="ptab-assign" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 flex flex-col gap-4 flex-1">
<div class="flex items-center gap-3">
<button onclick="switchPlannerTab('list')" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" title="Back to reservations">
<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="M15 19l-7-7 7-7"/>
</svg>
</button>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white" id="planner-form-title">New Reservation</h2>
</div>
<!-- Metadata fields: only shown when creating a new reservation -->
<div id="planner-meta-fields">
<!-- Name -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Job / Reservation Name *</label>
<input type="text" id="planner-name" placeholder="e.g., Pine Street May Deployment"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
</div>
<!-- Device Type -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Device Type *</label>
<div class="flex rounded-lg border border-gray-300 dark:border-gray-600 overflow-hidden">
<label class="flex-1 cursor-pointer">
<input type="radio" name="planner_device_type" value="seismograph" checked class="sr-only peer" onchange="plannerDatesChanged()">
<span class="block text-center px-4 py-2 text-sm font-medium bg-white dark:bg-slate-700 peer-checked:bg-blue-600 peer-checked:text-white text-gray-700 dark:text-gray-300 transition-colors">
Seismograph
</span>
</label>
<label class="flex-1 cursor-pointer border-l border-gray-300 dark:border-gray-600">
<input type="radio" name="planner_device_type" value="slm" class="sr-only peer" onchange="plannerDatesChanged()">
<span class="block text-center px-4 py-2 text-sm font-medium bg-white dark:bg-slate-700 peer-checked:bg-blue-600 peer-checked:text-white text-gray-700 dark:text-gray-300 transition-colors">
Sound Level Meter
</span>
</label>
</div>
</div>
<!-- Project -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Link to Project (optional)</label>
<select id="planner-project"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
<option value="">-- No project --</option>
{% for project in projects %}
<option value="{{ project.id }}">{{ project.name }}</option>
{% endfor %}
</select>
</div>
<!-- Dates -->
<div class="grid grid-cols-2 gap-3 mb-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Start Date *</label>
<input type="date" id="planner-start"
onchange="plannerDatesChanged()"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">End Date *</label>
<input type="date" id="planner-end"
onchange="plannerDatesChanged()"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
</div>
</div>
<!-- Color -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Color</label>
<div class="flex gap-2" id="planner-colors">
{% for color in ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899'] %}
<label class="cursor-pointer">
<input type="radio" name="planner_color" value="{{ color }}" {% if loop.first %}checked{% endif %} class="sr-only peer">
<span class="block w-7 h-7 rounded-full peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-gray-900 dark:peer-checked:ring-white"
style="background-color: {{ color }}"></span>
</label>
{% endfor %}
</div>
</div>
<!-- Estimated Units Needed -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Estimated Units Needed</label>
<input type="number" id="planner-est-units" min="1" placeholder="e.g. 5"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
</div>
</div><!-- end #planner-meta-fields -->
<!-- Monitoring Locations -->
<div class="flex items-center justify-between mt-2">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Monitoring Locations</h3>
<button onclick="plannerAddSlot()"
class="text-xs px-2 py-1 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded text-gray-700 dark:text-gray-300">
+ Add Location
</button>
</div>
<div id="planner-slots" class="flex flex-col gap-2 overflow-y-auto max-h-72">
<!-- Locations rendered by JS -->
<p class="text-sm text-gray-400 dark:text-gray-500 text-center py-4" id="planner-slots-empty">
Set dates and click "+ Add Location" to start adding units
</p>
</div>
<!-- Notes -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Notes (optional)</label>
<textarea id="planner-notes" rows="2" placeholder="Optional notes"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"></textarea>
</div>
<!-- Save -->
<div class="flex gap-3 pt-2 border-t border-gray-200 dark:border-gray-700 mt-auto">
<button onclick="plannerReset()"
class="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg text-sm">
Clear
</button>
<button onclick="plannerSave()"
class="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium" id="planner-save-btn">
Save Reservation
</button>
</div>
</div><!-- end ptab-assign -->
</div><!-- end left panel -->
<!-- RIGHT: Available Units (always visible) -->
<div class="lg:w-3/5 bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 flex flex-col gap-4">
<div class="flex items-center justify-between flex-wrap gap-2">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Available Units
<span id="planner-avail-count" class="ml-2 text-sm font-normal text-gray-500 dark:text-gray-400"></span>
</h2>
<div class="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" id="planner-deployed-only" onchange="plannerFilterUnits()"
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500">
Deployed only
</label>
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" id="planner-benched-only" onchange="plannerFilterUnits()"
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500">
Benched only
</label>
</div>
</div>
<input type="text" id="planner-search" placeholder="Search by unit ID..."
oninput="plannerFilterUnits()"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
<div id="planner-units-list" class="flex flex-col gap-1 overflow-y-auto flex-1" style="max-height: 55vh;">
<p class="text-sm text-gray-400 dark:text-gray-500 text-center py-8" id="planner-units-placeholder">
Set start and end dates to see available units
</p>
</div>
</div><!-- end right panel -->
</div><!-- end flex row -->
</div><!-- end view-planner -->
<!-- Unit Detail Modal (planner) -->
<div id="unit-detail-modal" class="fixed inset-0 z-50 hidden">
<div class="fixed inset-0 bg-black/50" onclick="closeUnitDetailModal()"></div>
<div class="fixed inset-0 flex items-center justify-center p-4">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] flex flex-col" onclick="event.stopPropagation()">
<div class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="font-semibold text-gray-900 dark:text-white" id="unit-detail-modal-title">Unit Detail</h3>
<button onclick="closeUnitDetailModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<svg class="w-6 h-6" 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>
<iframe id="unit-detail-iframe" src="" class="flex-1 rounded-b-xl" style="min-height: 70vh; border: none;"></iframe>
</div>
</div>
</div>
<!-- Day Detail Slide Panel -->
<div id="panel-backdrop" class="panel-backdrop" onclick="closeDayPanel()"></div>
<div id="day-panel" class="slide-panel">
<div class="p-6" id="day-panel-content">
<!-- Content loaded via HTMX -->
</div>
</div>
<!-- Reservation Modal -->
<!-- Promote to Project Modal -->
<div id="promote-modal" class="fixed inset-0 z-50 hidden">
<div class="fixed inset-0 bg-black/50" onclick="closePromoteModal()"></div>
<div class="fixed inset-0 flex items-center justify-center p-4">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl max-w-md w-full" onclick="event.stopPropagation()">
<div class="p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">Promote to Project</h2>
<p id="promote-modal-subtitle" class="text-sm text-gray-500 dark:text-gray-400 mb-4"></p>
<div class="flex flex-col gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Project Number <span class="text-gray-400">(optional)</span></label>
<input id="promote-project-number" type="text" placeholder="e.g. 2567-26"
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 text-sm focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Client Name <span class="text-gray-400">(optional)</span></label>
<input id="promote-client-name" type="text" placeholder="e.g. PJ Dick"
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 text-sm focus:ring-2 focus:ring-blue-500">
</div>
</div>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-3">This will create a new project with monitoring locations and unit assignments matching this reservation.</p>
<div id="promote-error" class="hidden mt-3 p-2 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm rounded"></div>
<div class="flex gap-3 mt-5">
<button onclick="closePromoteModal()" class="flex-1 px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-slate-700">Cancel</button>
<button onclick="confirmPromote()" class="flex-1 px-4 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-medium">Create Project</button>
</div>
</div>
</div>
</div>
</div>
<div id="reservation-modal" class="fixed inset-0 z-50 hidden">
<div class="fixed inset-0 bg-black/50" onclick="closeReservationModal()"></div>
<div class="fixed inset-0 flex items-center justify-center p-4">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl max-w-lg w-full max-h-[90vh] overflow-y-auto" onclick="event.stopPropagation()">
<div class="p-6">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white" id="modal-title">New Reservation</h2>
<button onclick="closeReservationModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<svg class="w-6 h-6" 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>
<input type="hidden" id="editing-reservation-id" value="">
<form id="reservation-form" onsubmit="submitReservation(event)">
<!-- Name -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Reservation Name *</label>
<input type="text" name="name" required
placeholder="e.g., Job A - March Deployment"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
</div>
<!-- Project (optional) -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Link to Project (optional)</label>
<select name="project_id"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
<option value="">-- No project --</option>
{% for project in projects %}
<option value="{{ project.id }}">{{ project.name }}</option>
{% endfor %}
</select>
</div>
<!-- Date Range -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Start Date *</label>
<input type="date" name="start_date" required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
</div>
<div class="mb-4">
<div class="flex items-center justify-between mb-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">End Date</label>
<label class="flex items-center gap-2 cursor-pointer text-sm">
<input type="checkbox" name="end_date_tbd" id="end_date_tbd"
onchange="toggleEndDateTBD()"
class="w-4 h-4 text-blue-600 focus:ring-blue-500 rounded border-gray-300 dark:border-gray-600">
<span class="text-gray-600 dark:text-gray-400">TBD / Ongoing</span>
</label>
</div>
<input type="date" name="end_date" id="end_date_input"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
</div>
<!-- Estimated End Date (shown when TBD is checked) -->
<div id="estimated-end-section" class="mb-4 hidden">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Estimated End Date <span class="text-gray-400 font-normal">(for planning)</span>
</label>
<input type="date" name="estimated_end_date"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
<p class="text-xs text-gray-500 mt-1">Used for calendar visualization only. Can be updated later.</p>
</div>
<!-- Assignment Type -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Assignment Type *</label>
<div class="flex gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="assignment_type" value="quantity" checked
onchange="toggleAssignmentType(this.value)"
class="text-blue-600 focus:ring-blue-500">
<span class="text-gray-700 dark:text-gray-300">Reserve quantity</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="assignment_type" value="specific"
onchange="toggleAssignmentType(this.value)"
class="text-blue-600 focus:ring-blue-500">
<span class="text-gray-700 dark:text-gray-300">Pick specific units</span>
</label>
</div>
</div>
<!-- Quantity (shown for quantity type) -->
<div id="quantity-section" class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Units Needed</label>
<input type="number" name="quantity_needed" min="1" value="1"
onchange="updateCalendarAvailability()"
oninput="updateCalendarAvailability()"
class="w-32 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
<p class="text-xs text-gray-500 mt-1">Calendar will highlight availability based on quantity</p>
</div>
<!-- Specific Units (shown for specific type) -->
<div id="specific-section" class="mb-4 hidden">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Select Units</label>
<div id="available-units-list" class="max-h-48 overflow-y-auto border border-gray-300 dark:border-gray-600 rounded-lg p-2">
<p class="text-gray-500 text-sm">Select dates first to see available units</p>
</div>
</div>
<!-- Color -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Color</label>
<div class="flex gap-2">
{% for color in ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899'] %}
<label class="cursor-pointer">
<input type="radio" name="color" value="{{ color }}" {% if loop.first %}checked{% endif %} class="sr-only peer">
<span class="block w-8 h-8 rounded-full peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-gray-900 dark:peer-checked:ring-white"
style="background-color: {{ color }}"></span>
</label>
{% endfor %}
</div>
</div>
<!-- Notes -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Notes</label>
<textarea name="notes" rows="2"
placeholder="Optional notes about this reservation"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"></textarea>
</div>
<!-- Actions -->
<div class="flex justify-end gap-3">
<button type="button" onclick="closeReservationModal()"
class="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg">
Cancel
</button>
<button type="submit" id="modal-submit-btn"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
Create Reservation
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<script>
const deviceType = '{{ device_type }}';
const startYear = {{ start_year }};
const startMonth = {{ start_month }};
// Load today's stats
document.addEventListener('DOMContentLoaded', function() {
loadTodayStats();
});
async function loadTodayStats() {
try {
const today = new Date().toISOString().split('T')[0];
const response = await fetch(`/api/fleet-calendar/day/${today}?device_type=${deviceType}`);
// Just update the stats from the day data response headers or a separate call
// For now, calculate from calendar data
const todayData = findTodayInCalendar();
if (todayData) {
document.getElementById('available-today').textContent = todayData.available;
document.getElementById('reserved-today').textContent = todayData.reserved;
document.getElementById('expiring-today').textContent = todayData.expiring_soon;
document.getElementById('expired-today').textContent = todayData.expired;
}
} catch (error) {
console.error('Error loading today stats:', error);
}
}
function findTodayInCalendar() {
const today = new Date();
const year = today.getFullYear();
const month = today.getMonth() + 1;
const day = today.getDate();
const calendarData = {{ calendar_data | tojson }};
// months is now an array, find the matching month
const monthData = calendarData.months.find(m => m.year === year && m.month === month);
if (monthData && monthData.days[day]) {
return monthData.days[day];
}
return null;
}
function openDayPanel(dateStr) {
const panel = document.getElementById('day-panel');
const backdrop = document.getElementById('panel-backdrop');
const content = document.getElementById('day-panel-content');
// Load content via HTMX
htmx.ajax('GET', `/api/fleet-calendar/day/${dateStr}?device_type=${deviceType}`, {
target: '#day-panel-content',
swap: 'innerHTML'
});
panel.classList.add('open');
backdrop.classList.add('open');
}
function closeDayPanel() {
const panel = document.getElementById('day-panel');
const backdrop = document.getElementById('panel-backdrop');
panel.classList.remove('open');
backdrop.classList.remove('open');
}
let reservationModeActive = false;
function openReservationModal() {
// Reset to "create" mode
document.getElementById('modal-title').textContent = 'New Reservation';
document.getElementById('modal-submit-btn').textContent = 'Create Reservation';
document.getElementById('editing-reservation-id').value = '';
document.getElementById('reservation-form').reset();
document.getElementById('reservation-modal').classList.remove('hidden');
reservationModeActive = true;
document.getElementById('main-legend').classList.add('md:hidden');
document.getElementById('main-legend').classList.remove('md:flex');
document.getElementById('reservation-legend').classList.remove('md:hidden');
document.getElementById('reservation-legend').classList.add('md:flex');
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');
if (isHidden) {
detail.classList.remove('hidden');
detail.style.display = 'block';
} else {
detail.classList.add('hidden');
detail.style.display = 'none';
}
if (chevron) chevron.style.transform = isHidden ? 'rotate(180deg)' : '';
}
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}`);
if (!response.ok) { alert('Failed to load reservation'); return; }
const res = await response.json();
// Switch modal to "edit" mode
document.getElementById('modal-title').textContent = 'Edit Reservation';
document.getElementById('modal-submit-btn').textContent = 'Save Changes';
document.getElementById('editing-reservation-id').value = id;
// Populate fields
const form = document.getElementById('reservation-form');
form.querySelector('input[name="name"]').value = res.name;
form.querySelector('select[name="project_id"]').value = res.project_id || '';
form.querySelector('input[name="start_date"]').value = res.start_date;
form.querySelector('textarea[name="notes"]').value = res.notes || '';
// Color radio
const colorRadio = form.querySelector(`input[name="color"][value="${res.color}"]`);
if (colorRadio) colorRadio.checked = true;
// Assignment type
const atRadio = form.querySelector(`input[name="assignment_type"][value="${res.assignment_type}"]`);
if (atRadio) { atRadio.checked = true; toggleAssignmentType(res.assignment_type); }
if (res.assignment_type === 'quantity') {
form.querySelector('input[name="quantity_needed"]').value = res.quantity_needed || 1;
}
// End date / TBD
const tbdCheckbox = document.getElementById('end_date_tbd');
if (res.end_date_tbd) {
tbdCheckbox.checked = true;
form.querySelector('input[name="estimated_end_date"]').value = res.estimated_end_date || '';
} else {
tbdCheckbox.checked = false;
document.getElementById('end_date_input').value = res.end_date || '';
}
toggleEndDateTBD();
document.getElementById('reservation-modal').classList.remove('hidden');
reservationModeActive = true;
document.getElementById('main-legend').classList.add('md:hidden');
document.getElementById('main-legend').classList.remove('md:flex');
document.getElementById('reservation-legend').classList.remove('md:hidden');
document.getElementById('reservation-legend').classList.add('md:flex');
updateCalendarAvailability();
} catch (error) {
console.error('Error loading reservation:', error);
alert('Error loading reservation');
}
}
// ---- Promote to Project ----
let promoteReservationId = null;
function openPromoteModal(id, name) {
promoteReservationId = id;
document.getElementById('promote-modal-subtitle').textContent = `"${name}" will become a tracked project.`;
document.getElementById('promote-project-number').value = '';
document.getElementById('promote-client-name').value = '';
document.getElementById('promote-error').classList.add('hidden');
document.getElementById('promote-modal').classList.remove('hidden');
}
function closePromoteModal() {
document.getElementById('promote-modal').classList.add('hidden');
promoteReservationId = null;
}
async function confirmPromote() {
if (!promoteReservationId) return;
const btn = document.querySelector('#promote-modal button[onclick="confirmPromote()"]');
const errEl = document.getElementById('promote-error');
btn.textContent = 'Creating…';
btn.disabled = true;
errEl.classList.add('hidden');
try {
const resp = await fetch(`/api/fleet-calendar/reservations/${promoteReservationId}/promote-to-project`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project_number: document.getElementById('promote-project-number').value.trim() || null,
client_name: document.getElementById('promote-client-name').value.trim() || null,
})
});
const result = await resp.json();
if (!resp.ok) throw new Error(result.detail || 'Failed to promote');
closePromoteModal();
// Navigate to the new project page
window.location.href = `/projects/${result.project_id}`;
} catch (e) {
errEl.textContent = e.message;
errEl.classList.remove('hidden');
btn.textContent = 'Create Project';
btn.disabled = false;
}
}
function closeReservationModal() {
document.getElementById('reservation-modal').classList.add('hidden');
document.getElementById('reservation-form').reset();
document.getElementById('editing-reservation-id').value = '';
reservationModeActive = false;
document.getElementById('main-legend').classList.remove('md:hidden');
document.getElementById('main-legend').classList.add('md:flex');
document.getElementById('reservation-legend').classList.add('md:hidden');
document.getElementById('reservation-legend').classList.remove('md:flex');
resetCalendarColors();
}
function resetCalendarColors() {
document.querySelectorAll('.day-cell:not(.empty)').forEach(cell => {
cell.classList.remove('reservation-available', 'reservation-partial', 'reservation-unavailable');
cell.classList.add('neutral');
});
}
function updateCalendarAvailability() {
if (!reservationModeActive) return;
const quantityInput = document.querySelector('input[name="quantity_needed"]');
const quantity = parseInt(quantityInput?.value) || 1;
// Update each day cell based on available units vs quantity needed
document.querySelectorAll('.day-cell:not(.empty)').forEach(cell => {
const available = parseInt(cell.dataset.available) || 0;
// Remove all status classes
cell.classList.remove('neutral', 'reservation-available', 'reservation-partial', 'reservation-unavailable');
if (available >= quantity) {
cell.classList.add('reservation-available');
} else if (available > 0) {
cell.classList.add('reservation-partial');
} else {
cell.classList.add('reservation-unavailable');
}
});
}
function toggleEndDateTBD() {
const checkbox = document.getElementById('end_date_tbd');
const endDateInput = document.getElementById('end_date_input');
const estimatedSection = document.getElementById('estimated-end-section');
if (checkbox.checked) {
endDateInput.disabled = true;
endDateInput.value = '';
endDateInput.classList.add('opacity-50', 'cursor-not-allowed');
estimatedSection.classList.remove('hidden');
} else {
endDateInput.disabled = false;
endDateInput.classList.remove('opacity-50', 'cursor-not-allowed');
estimatedSection.classList.add('hidden');
}
}
function toggleAssignmentType(type) {
const quantitySection = document.getElementById('quantity-section');
const specificSection = document.getElementById('specific-section');
if (type === 'quantity') {
quantitySection.classList.remove('hidden');
specificSection.classList.add('hidden');
} else {
quantitySection.classList.add('hidden');
specificSection.classList.remove('hidden');
// Load available units based on selected dates
loadAvailableUnits();
}
}
async function loadAvailableUnits() {
const startDate = document.querySelector('input[name="start_date"]').value;
const endDate = document.querySelector('input[name="end_date"]').value;
if (!startDate || !endDate) {
document.getElementById('available-units-list').innerHTML =
'<p class="text-gray-500 text-sm">Select dates first to see available units</p>';
return;
}
try {
const response = await fetch(
`/api/fleet-calendar/availability?start_date=${startDate}&end_date=${endDate}&device_type=${deviceType}`
);
const data = await response.json();
if (data.available_units.length === 0) {
document.getElementById('available-units-list').innerHTML =
'<p class="text-gray-500 text-sm">No units available for this period</p>';
return;
}
let html = '';
for (const unit of data.available_units) {
html += `
<label class="flex items-center gap-2 p-2 hover:bg-gray-50 dark:hover:bg-gray-700 rounded cursor-pointer">
<input type="checkbox" name="unit_ids" value="${unit.id}"
class="text-blue-600 focus:ring-blue-500 rounded">
<span class="text-gray-900 dark:text-white font-medium">${unit.id}</span>
<span class="text-gray-500 text-sm">Cal: ${unit.last_calibrated || 'N/A'}</span>
${unit.calibration_status === 'expiring_soon' ?
'<span class="text-yellow-600 text-xs">Expiring soon</span>' : ''}
</label>
`;
}
document.getElementById('available-units-list').innerHTML = html;
} catch (error) {
console.error('Error loading available units:', error);
}
}
// Watch for date changes to reload available units
document.addEventListener('DOMContentLoaded', function() {
const startInput = document.querySelector('input[name="start_date"]');
const endInput = document.querySelector('input[name="end_date"]');
if (startInput && endInput) {
startInput.addEventListener('change', function() {
if (document.querySelector('input[name="assignment_type"]:checked').value === 'specific') {
loadAvailableUnits();
}
});
endInput.addEventListener('change', function() {
if (document.querySelector('input[name="assignment_type"]:checked').value === 'specific') {
loadAvailableUnits();
}
});
}
});
async function submitReservation(event) {
event.preventDefault();
const form = event.target;
const formData = new FormData(form);
const endDateTbd = formData.get('end_date_tbd') === 'on';
const editingId = document.getElementById('editing-reservation-id').value;
const data = {
name: formData.get('name'),
project_id: formData.get('project_id') || null,
start_date: formData.get('start_date'),
end_date: endDateTbd ? null : formData.get('end_date'),
end_date_tbd: endDateTbd,
estimated_end_date: endDateTbd ? (formData.get('estimated_end_date') || null) : null,
assignment_type: formData.get('assignment_type'),
device_type: deviceType,
color: formData.get('color'),
notes: formData.get('notes') || null
};
if (!data.end_date && !data.end_date_tbd) {
alert('Please enter an end date or check "TBD / Ongoing"');
return;
}
if (data.assignment_type === 'quantity') {
data.quantity_needed = parseInt(formData.get('quantity_needed'));
} else {
data.unit_ids = formData.getAll('unit_ids');
}
const isEdit = editingId !== '';
const url = isEdit
? `/api/fleet-calendar/reservations/${editingId}`
: '/api/fleet-calendar/reservations';
const method = isEdit ? 'PUT' : 'POST';
try {
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
closeReservationModal();
window.location.reload();
} else {
alert(`Error ${isEdit ? 'saving' : 'creating'} reservation: ` + (result.detail || 'Unknown error'));
}
} catch (error) {
console.error('Error:', error);
alert(`Error ${isEdit ? 'saving' : 'creating'} reservation`);
}
}
// Close panels on escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeDayPanel();
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');
if (tab === 'calendar') htmx.trigger(document.body, 'calendar-reservations-refresh');
['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, location_name: 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 = `<span class="text-xs 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" title="Will need swap during job">cal expires ${expiryStr}</span>`;
}
}
const deployedBadge = unit.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>';
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}')"
class="font-medium text-blue-600 dark:text-blue-400 hover:underline text-sm">${unit.id}</button>
${deployedBadge}
${expiryWarning}
</div>
<span class="text-xs text-gray-400 dark:text-gray-500">Cal: ${calDate}</span>
</div>
<div class="flex-shrink-0 ml-2">
${isSlotted
? '<span class="text-xs text-blue-600 dark:text-blue-400 font-medium">Assigned</span>'
: '<button class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700 whitespace-nowrap">Assign →</button>'
}
</div>
`;
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, location_name: 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, location_name: 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 plannerSetLocationName(idx, value) {
plannerState.slots[idx].location_name = 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 = `
<select onchange="plannerSetPowerType(${idx}, this.value)"
class="text-xs px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-slate-700 text-gray-700 dark:text-gray-300 focus:ring-1 focus:ring-blue-500">
<option value="" ${!slot.power_type ? 'selected' : ''}>— power —</option>
<option value="ac" ${slot.power_type === 'ac' ? 'selected' : ''}>A/C Power</option>
<option value="solar" ${slot.power_type === 'solar' ? 'selected' : ''}>Solar</option>
</select>`;
const dragHandle = slot.unit_id
? `<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>`;
row.innerHTML = `
<div class="flex items-center gap-2">
${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>
${powerSelect}
<button onclick="plannerClearSlot(${idx})" class="text-xs text-gray-400 hover:text-red-500 flex-shrink-0" title="Remove unit">✕</button>`
: `<span class="flex-1 text-sm text-gray-400 dark:text-gray-500 italic">Empty — click a unit</span>
${powerSelect}
<button onclick="plannerRemoveSlot(${idx})" class="text-xs text-gray-400 hover:text-red-500 flex-shrink-0" title="Remove location">✕</button>`
}
</div>
<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)"
placeholder="Location name (e.g. North Gate)"
class="w-full text-xs px-2 py-1 border border-gray-200 dark:border-gray-600 rounded bg-white dark:bg-slate-700 text-gray-600 dark:text-gray-400 placeholder-gray-300 dark:placeholder-gray-600 focus:ring-1 focus:ring-blue-500">
<input type="text" value="${slot.notes ? slot.notes.replace(/"/g, '&quot;') : ''}"
oninput="plannerSetSlotNotes(${idx}, this.value)"
placeholder="Location notes (optional)"
class="w-full text-xs px-2 py-1 border border-gray-200 dark:border-gray-600 rounded bg-white dark:bg-slate-700 text-gray-600 dark:text-gray-400 placeholder-gray-300 dark:placeholder-gray-600 focus:ring-1 focus:ring-blue-500">
</div>
`;
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';
document.getElementById('planner-meta-fields').style.display = '';
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 = {};
const locationNotes = {};
const locationNames = {};
const slotIndices = {};
filledSlots.forEach((s, i) => {
if (s.power_type) powerTypes[s.unit_id] = s.power_type;
if (s.notes) locationNotes[s.unit_id] = s.notes;
if (s.location_name) locationNames[s.unit_id] = s.location_name;
slotIndices[s.unit_id] = i;
});
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, location_notes: locationNotes, location_names: locationNames, slot_indices: slotIndices })
}
);
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, location_name: u.location_name || null });
}
const titleEl = document.getElementById('planner-form-title');
if (titleEl) titleEl.textContent = res.name;
document.getElementById('planner-save-btn').textContent = 'Save Changes';
document.getElementById('planner-meta-fields').style.display = 'none';
plannerRenderSlots();
if (res.start_date && res.end_date) plannerLoadUnits();
} catch (e) {
console.error('Error loading reservation for planner', e);
}
}
switchTab('planner');
switchPlannerTab('assign');
}
</script>
{% endblock %}