1582 lines
71 KiB
HTML
1582 lines
71 KiB
HTML
{% 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 -->
|
||
<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');
|
||
}
|
||
}
|
||
|
||
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}
|
||
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 });
|
||
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 = `
|
||
<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">
|
||
<input type="text" value="${slot.notes ? slot.notes.replace(/"/g, '"') : ''}"
|
||
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 = {};
|
||
filledSlots.forEach(s => {
|
||
if (s.power_type) powerTypes[s.unit_id] = s.power_type;
|
||
if (s.notes) locationNotes[s.unit_id] = s.notes;
|
||
});
|
||
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 })
|
||
}
|
||
);
|
||
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 = 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 %}
|