Files
terra-view/templates/fleet_calendar.html
serversdown 57a85f565b feat: add location_slots to job_reservations for full slot persistence and update version to 0.9.1
Fix: modems do not show as "missing" any more, cleans up the dashboard.
2026-03-24 01:13:29 +00:00

2167 lines
102 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 %}Job Planner - 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;
}
/* Cal expire dots hidden by default; shown when toggle is active */
.cal-expire-dots-hidden .status-dot.expired {
display: none;
}
/* Job bar visibility toggles */
.hide-confirmed .job-bar-confirmed { display: none; }
.hide-planned .job-bar-planned { display: none; }
/* Planned = dashed appearance via repeating-linear-gradient */
.job-bar-planned {
background-image: repeating-linear-gradient(
90deg,
var(--bar-color) 0px,
var(--bar-color) 4px,
transparent 4px,
transparent 7px
) !important;
background-color: transparent !important;
opacity: 0.75;
}
</style>
{% endblock %}
{% block content %}
<div class="mb-6">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Job Planner</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">Plan and manage field deployments</p>
</div>
<!-- Main tab bar -->
<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">
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">
<!-- Stats + Legend row -->
<div class="flex flex-wrap items-center gap-4 mb-6">
<!-- Compact stat pills -->
<div class="flex items-center gap-3 text-sm bg-white dark:bg-slate-800 rounded-lg px-4 py-2 shadow flex-shrink-0">
<span class="text-gray-500 dark:text-gray-400">{{ calendar_data.total_units }} total</span>
<span class="text-gray-300 dark:text-gray-600">|</span>
<span class="text-green-600 dark:text-green-400 font-medium"><span id="available-today">--</span> avail</span>
<span class="text-gray-300 dark:text-gray-600">|</span>
<span class="text-blue-600 dark:text-blue-400 font-medium"><span id="reserved-today">--</span> reserved</span>
<span class="text-gray-300 dark:text-gray-600">|</span>
<span class="text-yellow-600 dark:text-yellow-400 font-medium"><span id="expiring-today">--</span> expiring</span>
<span class="text-gray-300 dark:text-gray-600">|</span>
<span class="text-red-600 dark:text-red-400 font-medium"><span id="expired-today">--</span> cal expired</span>
</div>
<!-- Job legend -->
{% if calendar_projects %}
<div class="flex flex-wrap items-center gap-3">
{% for item in calendar_projects %}
<span class="flex items-center gap-1.5 text-sm text-gray-700 dark:text-gray-300">
<span class="w-3 h-1.5 rounded-full flex-shrink-0" style="background-color: {{ item.color }};"></span>
{{ item.name }}
</span>
{% endfor %}
</div>
{% endif %}
</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">
<button onclick="switchDeviceType('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
</button>
<button onclick="switchDeviceType('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
</button>
</div>
<!-- Legend / toggles -->
<div class="hidden md:flex items-center gap-2 text-sm" id="main-legend">
<!-- Confirmed jobs toggle -->
<button onclick="toggleJobLayer('confirmed')" id="toggle-confirmed"
class="flex items-center gap-1.5 px-2 py-1 rounded transition-colors text-gray-700 dark:text-gray-200 bg-gray-100 dark:bg-gray-700"
title="Show/hide confirmed jobs">
<span class="w-4 h-1.5 rounded-full bg-gray-500 dark:bg-gray-300 flex-shrink-0"></span>
Confirmed
</button>
<!-- Planned jobs toggle -->
<button onclick="toggleJobLayer('planned')" id="toggle-planned"
class="flex items-center gap-1.5 px-2 py-1 rounded transition-colors text-gray-700 dark:text-gray-200 bg-gray-100 dark:bg-gray-700"
title="Show/hide planned jobs">
<span class="w-4 h-1.5 flex-shrink-0" style="background-image: repeating-linear-gradient(90deg, #6b7280 0px, #6b7280 4px, transparent 4px, transparent 7px);"></span>
Planned
</button>
<!-- Cal expire dots toggle -->
<button onclick="toggleCalExpireDots()" id="cal-expire-toggle"
class="flex items-center gap-1.5 px-2 py-1 rounded text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Show/hide calibration expiry dots">
<span class="w-3 h-3 rounded bg-gray-200 dark:bg-gray-600 relative flex-shrink-0">
<span class="absolute top-0 right-0 w-1.5 h-1.5 rounded-full bg-red-500"></span>
</span>
Cal expires
</button>
</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 %}
{% set day_projects = [] %}
{% for p in calendar_projects %}
{% if p.start_date <= date_str and (p.end_date is none or p.end_date >= date_str) %}
{% set _ = day_projects.append(p) %}
{% endif %}
{% endfor %}
<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 %}{% for p in day_projects %} | {{ p.name }}{% endfor %}">
{{ day_num }}
{% if has_cal_expiring %}
<span class="status-dot expired"></span>
{% endif %}
{% if day_projects %}
<span class="absolute bottom-0.5 left-0.5 right-0.5 flex flex-col gap-px">
{% for p in day_projects %}
<span class="job-bar-wrap group/bar relative block">
<span class="block h-1 rounded-full {% if p.confirmed %}job-bar-confirmed{% else %}job-bar-planned{% endif %}"
style="--bar-color: {{ p.color }}; background-color: {{ p.color }};"></span>
<span class="job-bar-tip pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-2 py-1 rounded bg-gray-900 text-white text-xs whitespace-nowrap opacity-0 group-hover/bar:opacity-100 transition-opacity z-50 shadow-lg">
{{ p.name }}{% if p.start_date and p.end_date %} · {{ p.start_date }} {{ p.end_date }}{% endif %}
</span>
</span>
{% endfor %}
</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 -->
<!-- Job Planner View -->
<div id="view-planner">
<div class="flex flex-col lg:flex-row gap-6 min-h-[70vh]">
<!-- LEFT PANEL -->
<div class="lg:w-2/5 flex flex-col gap-4">
<!-- Reservations list panel -->
<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">Jobs</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 Job
</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>
<!-- Job form panel -->
<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 justify-between gap-2 flex-wrap">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white" id="planner-form-title">New Job</h2>
<div class="flex items-center gap-2">
<!-- Edit-mode actions (shown when editing an existing job) -->
<div id="planner-edit-actions" class="hidden flex items-center gap-2">
<button onclick="plannerPromote()"
class="text-xs px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white rounded-lg font-medium">
Promote to Project
</button>
<button onclick="plannerDeleteCurrent()"
class="text-xs px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded-lg font-medium">
Delete
</button>
</div>
<button onclick="switchPlannerTab('list')"
class="text-sm text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 flex items-center gap-1">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
Cancel
</button>
</div>
</div>
<!-- Job details collapsible card -->
<div id="planner-meta-fields" class="rounded-lg border border-gray-200 dark:border-gray-700">
<!-- Collapsed header (summary row) -->
<button type="button" onclick="togglePlannerMeta()"
class="w-full flex items-center justify-between px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors rounded-lg">
<div class="flex items-center gap-2 min-w-0">
<span id="planner-meta-color-dot" class="w-3 h-3 rounded-full flex-shrink-0" style="background:#3B82F6"></span>
<span id="planner-meta-summary" class="text-sm font-medium text-gray-700 dark:text-gray-300 truncate">New Job</span>
</div>
<svg id="planner-meta-chevron" class="w-4 h-4 text-gray-400 flex-shrink-0 transition-transform"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<!-- Expanded fields -->
<div id="planner-meta-body" class="hidden px-4 pb-4 flex flex-col gap-4 border-t border-gray-200 dark:border-gray-700">
<!-- Name -->
<div class="pt-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Job Name *</label>
<input type="text" id="planner-name" placeholder="e.g., Pine Street May Deployment"
oninput="plannerUpdateMetaSummary()"
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>
<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="plannerDeviceTypeChanged()">
<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="plannerDeviceTypeChanged()">
<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>
<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">
<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(); plannerUpdateMetaSummary()"
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(); plannerUpdateMetaSummary()"
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>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Color</label>
<input type="hidden" id="planner-color-value" value="#3B82F6">
<div class="flex flex-wrap gap-1.5 items-center" id="planner-color-swatches"></div>
<div class="flex items-center gap-2 mt-2">
<label class="text-xs text-gray-500 dark:text-gray-400">Custom:</label>
<input type="color" id="planner-color-wheel" value="#3B82F6"
oninput="plannerSetColor(this.value, false)"
class="w-8 h-8 rounded cursor-pointer border border-gray-300 dark:border-gray-600 p-0.5 bg-white dark:bg-slate-700">
</div>
</div>
<!-- Estimated Units -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Estimated Units</label>
<p class="text-xs text-gray-400 dark:text-gray-500 mb-1">Rough planning number — how many units you think this job will need</p>
<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-body -->
</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">
<!-- 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 Job
</button>
</div>
</div><!-- end ptab-assign -->
</div><!-- end left panel -->
<!-- RIGHT PANEL -->
<div class="lg:w-3/5 flex flex-col gap-4">
<!-- Fleet Summary (shown on jobs list) -->
<div id="right-fleet-summary" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 flex flex-col gap-4">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Summary</h2>
<div id="fleet-summary-stats" class="grid grid-cols-2 sm:grid-cols-4 gap-3 text-center">
<!-- Populated by JS -->
</div>
<input type="text" id="summary-search" placeholder="Search by unit ID..."
oninput="summaryFilterUnits()"
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="fleet-summary-list" class="flex flex-col gap-1 overflow-y-auto" style="max-height: 55vh;">
<p class="text-sm text-gray-400 dark:text-gray-500 text-center py-8">Loading fleet...</p>
</div>
</div>
<!-- Available Units (shown when assigning) -->
<div id="right-available-units" class="hidden 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>
</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 }};
// Cal expire dot toggle — off by default
let calExpireDotsVisible = false;
function toggleCalExpireDots() {
calExpireDotsVisible = !calExpireDotsVisible;
document.getElementById('view-calendar').classList.toggle('cal-expire-dots-hidden', !calExpireDotsVisible);
const btn = document.getElementById('cal-expire-toggle');
if (calExpireDotsVisible) {
btn.classList.remove('text-gray-400', 'dark:text-gray-500');
btn.classList.add('text-gray-700', 'dark:text-gray-200', 'bg-gray-100', 'dark:bg-gray-700');
} else {
btn.classList.add('text-gray-400', 'dark:text-gray-500');
btn.classList.remove('text-gray-700', 'dark:text-gray-200', 'bg-gray-100', 'dark:bg-gray-700');
}
}
// Load today's stats
document.addEventListener('DOMContentLoaded', function() {
loadTodayStats();
// Apply hidden class immediately (dots off by default)
document.getElementById('view-calendar').classList.add('cal-expire-dots-hidden');
// Load fleet summary for right panel
loadFleetSummary();
// Restore active tab from hash
if (window.location.hash === '#cal') {
switchTab('calendar');
// Scroll today's month into view
const todayCell = document.querySelector('.day-cell.today');
if (todayCell) {
const monthCard = todayCell.closest('.month-card');
if (monthCard) monthCard.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
});
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 toggleResMenu(id) {
const menu = document.getElementById('res-menu-' + id);
if (!menu) return;
const isHidden = menu.classList.contains('hidden');
// Close all other open menus first
document.querySelectorAll('[id^="res-menu-"]').forEach(m => m.classList.add('hidden'));
if (isHidden) menu.classList.remove('hidden');
}
// Close any open res menu when clicking elsewhere
document.addEventListener('click', () => {
document.querySelectorAll('[id^="res-menu-"]').forEach(m => m.classList.add('hidden'));
});
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();
showPlannerToast('Error: ' + (data.detail || 'Failed to delete'), true);
}
} catch (error) {
console.error('Error:', error);
showPlannerToast('Error deleting reservation', true);
}
}
async function editReservation(id) {
try {
const response = await fetch(`/api/fleet-calendar/reservations/${id}`);
if (!response.ok) { showPlannerToast('Failed to load reservation', true); 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);
showPlannerToast('Error loading reservation', true);
}
}
// ---- 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();
showPlannerToast('Project created! Redirecting…');
const listUrl = document.getElementById('planner-reservations-list')?.getAttribute('hx-get');
if (listUrl) htmx.ajax('GET', listUrl, {target: '#planner-reservations-list', swap: 'innerHTML'});
setTimeout(() => { window.location.href = `/projects/${result.project_id}`; }, 1200);
} 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) {
showPlannerToast('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 {
showPlannerToast(`Error ${isEdit ? 'saving' : 'creating'} reservation: ` + (result.detail || 'Unknown error'));
}
} catch (error) {
console.error('Error:', error);
showPlannerToast(`Error ${isEdit ? 'saving' : 'creating'} reservation`);
}
}
// Close panels on escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeDayPanel();
closeReservationModal();
}
});
// ============================================================
// Panel switching
// ============================================================
function switchPlannerTab(tab) {
const isAssign = tab === 'assign';
document.getElementById('ptab-list').classList.toggle('hidden', isAssign);
document.getElementById('ptab-assign').classList.toggle('hidden', !isAssign);
showRightPanel(isAssign ? 'available' : 'summary');
}
function switchDeviceType(dt) {
const hash = document.getElementById('view-calendar').classList.contains('hidden') ? '' : '#cal';
window.location.href = `/fleet-calendar?year={{ start_year }}&month={{ start_month }}&device_type=${dt}${hash}`;
}
function switchTab(tab) {
const isCalendar = tab === 'calendar';
document.getElementById('view-calendar').classList.toggle('hidden', !isCalendar);
document.getElementById('view-planner').classList.toggle('hidden', isCalendar);
['planner', 'calendar'].forEach(t => {
const btn = document.getElementById(`tab-btn-${t}`);
const active = t === tab;
btn.classList.toggle('bg-white', active);
btn.classList.toggle('dark:bg-slate-600', active);
btn.classList.toggle('text-gray-900', active);
btn.classList.toggle('dark:text-white', active);
btn.classList.toggle('shadow', active);
btn.classList.toggle('text-gray-600', !active);
btn.classList.toggle('dark:text-gray-300', !active);
btn.classList.toggle('hover:text-gray-900', !active);
btn.classList.toggle('dark:hover:text-white', !active);
});
if (isCalendar) {
htmx.trigger(document.body, 'calendar-reservations-refresh');
// Scroll today into view
requestAnimationFrame(() => {
const todayCell = document.querySelector('.day-cell.today');
if (todayCell) {
const monthCard = todayCell.closest('.month-card');
if (monthCard) monthCard.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
});
}
history.replaceState(null, '', isCalendar ? '#cal' : window.location.pathname + window.location.search);
}
// Job layer toggles
let confirmedVisible = true;
let plannedVisible = true;
function toggleJobLayer(layer) {
if (layer === 'confirmed') {
confirmedVisible = !confirmedVisible;
document.getElementById('view-calendar').classList.toggle('hide-confirmed', !confirmedVisible);
const btn = document.getElementById('toggle-confirmed');
btn.classList.toggle('text-gray-700', confirmedVisible);
btn.classList.toggle('dark:text-gray-200', confirmedVisible);
btn.classList.toggle('bg-gray-100', confirmedVisible);
btn.classList.toggle('dark:bg-gray-700', confirmedVisible);
btn.classList.toggle('text-gray-400', !confirmedVisible);
btn.classList.toggle('dark:text-gray-500', !confirmedVisible);
} else {
plannedVisible = !plannedVisible;
document.getElementById('view-calendar').classList.toggle('hide-planned', !plannedVisible);
const btn = document.getElementById('toggle-planned');
btn.classList.toggle('text-gray-700', plannedVisible);
btn.classList.toggle('dark:text-gray-200', plannedVisible);
btn.classList.toggle('bg-gray-100', plannedVisible);
btn.classList.toggle('dark:bg-gray-700', plannedVisible);
btn.classList.toggle('text-gray-400', !plannedVisible);
btn.classList.toggle('dark:text-gray-500', !plannedVisible);
}
}
// ============================================================
// 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;
// ---- Color picker ----
const PLANNER_PALETTE = [
'#3B82F6','#2563EB','#06B6D4','#0D9488',
'#10B981','#84CC16','#EAB308','#F97316',
'#EF4444','#DC2626','#EC4899','#A855F7',
'#8B5CF6','#6366F1','#78716C','#94A3B8',
'#F59E0B','#14B8A6'
];
// Colors in use by existing reservations (populated from Jinja at page load)
const EXISTING_JOB_COLORS = {{ calendar_projects | map(attribute='color') | list | tojson }};
function plannerPickSmartColor() {
// Parse hex to HSL, pick the palette color that maximizes minimum hue distance from existing colors
function hexToHsl(hex) {
const r = parseInt(hex.slice(1,3),16)/255, g = parseInt(hex.slice(3,5),16)/255, b = parseInt(hex.slice(5,7),16)/255;
const max = Math.max(r,g,b), min = Math.min(r,g,b), l = (max+min)/2;
if (max === min) return [0, 0, l];
const d = max - min, s = l > 0.5 ? d/(2-max-min) : d/(max+min);
let h;
if (max === r) h = ((g-b)/d + (g<b?6:0))/6;
else if (max === g) h = ((b-r)/d + 2)/6;
else h = ((r-g)/d + 4)/6;
return [h*360, s, l];
}
function hueDist(a, b) { const d = Math.abs(a-b); return Math.min(d, 360-d); }
const usedHues = EXISTING_JOB_COLORS
.filter(c => c && c.startsWith('#'))
.map(c => hexToHsl(c)[0]);
// Also exclude the current planner color if editing
const cur = document.getElementById('planner-color-value')?.value;
if (cur && plannerState.reservation_id) usedHues.push(hexToHsl(cur)[0]);
let best = PLANNER_PALETTE[0], bestDist = -1;
for (const c of PLANNER_PALETTE) {
const h = hexToHsl(c)[0];
const minDist = usedHues.length ? Math.min(...usedHues.map(uh => hueDist(h, uh))) : 999;
if (minDist > bestDist) { bestDist = minDist; best = c; }
}
return best;
}
function plannerRenderColorSwatches(selected) {
const container = document.getElementById('planner-color-swatches');
if (!container) return;
container.innerHTML = PLANNER_PALETTE.map(c => {
const active = c.toLowerCase() === (selected||'').toLowerCase();
return `<button type="button" onclick="plannerSetColor('${c}', true)"
title="${c}"
class="w-6 h-6 rounded-full border-2 transition-all ${active ? 'border-gray-900 dark:border-white scale-125' : 'border-transparent hover:scale-110'}"
style="background-color:${c}"></button>`;
}).join('');
}
function plannerSetColor(hex, fromSwatch) {
document.getElementById('planner-color-value').value = hex;
document.getElementById('planner-color-wheel').value = hex.length === 7 ? hex : '#3B82F6';
plannerRenderColorSwatches(hex);
const dot = document.getElementById('planner-meta-color-dot');
if (dot) dot.style.background = hex;
}
function togglePlannerMeta() {
const body = document.getElementById('planner-meta-body');
const chevron = document.getElementById('planner-meta-chevron');
const open = !body.classList.contains('hidden');
body.classList.toggle('hidden', open);
chevron.style.transform = open ? '' : 'rotate(180deg)';
}
function plannerSetMetaOpen(open) {
const body = document.getElementById('planner-meta-body');
const chevron = document.getElementById('planner-meta-chevron');
body.classList.toggle('hidden', !open);
chevron.style.transform = open ? 'rotate(180deg)' : '';
}
function plannerUpdateMetaSummary() {
const name = document.getElementById('planner-name')?.value.trim();
const start = document.getElementById('planner-start')?.value;
const end = document.getElementById('planner-end')?.value;
const summary = document.getElementById('planner-meta-summary');
if (!summary) return;
let text = name || 'New Job';
if (start) {
const s = new Date(start + 'T00:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric'});
const e = end ? new Date(end + 'T00:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric'}) : '…';
text += ` · ${s} ${e}`;
}
summary.textContent = text;
}
// Initialize swatches and meta summary on page load
document.addEventListener('DOMContentLoaded', () => {
plannerRenderColorSwatches('#3B82F6');
plannerUpdateMetaSummary();
});
function plannerDatesChanged() {
plannerLoadUnits();
}
function plannerDeviceTypeChanged() {
plannerDatesChanged();
loadFleetSummary();
}
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}`;
// Show loading state
const placeholder = document.getElementById('planner-units-placeholder');
if (placeholder) { placeholder.classList.remove('hidden'); placeholder.textContent = 'Loading units...'; }
document.getElementById('planner-units-list')?.querySelectorAll('.planner-unit-row').forEach(el => el.remove());
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');
const start = document.getElementById('planner-start').value;
const end = document.getElementById('planner-end').value;
placeholder.textContent = (start && end) ? 'No units available for this period' : 'Set start and end dates to see available 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();
}
// ============================================================
// Fleet Summary (right panel on jobs list)
// ============================================================
let summaryAllUnits = [];
let summaryActiveFilter = null; // null | 'deployed' | 'benched' | 'cal_expired'
async function loadFleetSummary() {
const plannerDeviceType = document.querySelector('input[name="planner_device_type"]:checked')?.value || 'seismograph';
try {
const resp = await fetch(`/api/fleet-calendar/planner-availability?device_type=${plannerDeviceType}`);
const data = await resp.json();
summaryAllUnits = data.units || [];
summaryActiveFilter = null;
renderFleetSummary();
} catch(e) { console.error('Fleet summary load error', e); }
}
function summaryFilterUnits() {
renderFleetSummary();
}
function summarySetFilter(f) {
summaryActiveFilter = summaryActiveFilter === f ? null : f;
renderFleetSummary();
}
function renderFleetSummary() {
const search = document.getElementById('summary-search')?.value.toLowerCase() || '';
// Stats (always against full list)
const total = summaryAllUnits.length;
const deployed = summaryAllUnits.filter(u => u.deployed).length;
const benched = summaryAllUnits.filter(u => !u.deployed).length;
const calExpired = summaryAllUnits.filter(u => u.expiry_date && new Date(u.expiry_date + 'T00:00:00') < new Date()).length;
const cardBase = 'rounded-lg p-3 text-left w-full cursor-pointer transition-all ring-2';
const active = summaryActiveFilter;
document.getElementById('fleet-summary-stats').innerHTML = `
<button onclick="summarySetFilter(null)"
class="${cardBase} ${!active ? 'ring-gray-400 dark:ring-gray-300' : 'ring-transparent'} bg-gray-50 dark:bg-slate-700 hover:bg-gray-100 dark:hover:bg-slate-600">
<p class="text-2xl font-bold text-gray-900 dark:text-white">${total}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Total</p>
</button>
<button onclick="summarySetFilter('deployed')"
class="${cardBase} ${active === 'deployed' ? 'ring-green-500' : 'ring-transparent'} bg-green-50 dark:bg-green-900/20 hover:bg-green-100 dark:hover:bg-green-900/40">
<p class="text-2xl font-bold text-green-700 dark:text-green-400">${deployed}</p>
<p class="text-xs text-green-600 dark:text-green-500">Deployed</p>
</button>
<button onclick="summarySetFilter('benched')"
class="${cardBase} ${active === 'benched' ? 'ring-blue-500' : 'ring-transparent'} bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/40">
<p class="text-2xl font-bold text-blue-700 dark:text-blue-400">${benched}</p>
<p class="text-xs text-blue-600 dark:text-blue-500">Benched</p>
</button>
<button onclick="summarySetFilter('cal_expired')"
class="${cardBase} ${active === 'cal_expired' ? 'ring-red-500' : 'ring-transparent'} bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/40">
<p class="text-2xl font-bold text-red-700 dark:text-red-400">${calExpired}</p>
<p class="text-xs text-red-600 dark:text-red-500">Cal Expired</p>
</button>
`;
// Apply filter + search to the list
let units = summaryAllUnits;
if (active === 'deployed') units = units.filter(u => u.deployed);
else if (active === 'benched') units = units.filter(u => !u.deployed);
else if (active === 'cal_expired') units = units.filter(u => u.expiry_date && new Date(u.expiry_date + 'T00:00:00') < new Date());
if (search) units = units.filter(u => u.id.toLowerCase().includes(search));
// Unit list
const list = document.getElementById('fleet-summary-list');
if (units.length === 0) {
list.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 text-center py-8">No units found</p>';
return;
}
list.innerHTML = units.map(u => {
const calDate = u.last_calibrated
? new Date(u.last_calibrated + 'T00:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'})
: 'No cal date';
const expired = u.expiry_date && new Date(u.expiry_date + 'T00:00:00') < new Date();
const deployedBadge = u.deployed
? '<span class="text-xs px-1.5 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded">Deployed</span>'
: '<span class="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded">Benched</span>';
const calBadge = expired
? `<span class="text-xs px-1.5 py-0.5 bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded">Cal expired</span>`
: `<span class="text-xs text-gray-400 dark:text-gray-500">Cal: ${calDate}</span>`;
const resBadges = (u.reservations || []).map(r => {
const s = r.start_date ? new Date(r.start_date + 'T00:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric'}) : '';
const e = r.end_date ? new Date(r.end_date + 'T00:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric'}) : 'TBD';
return `<span class="text-xs px-1.5 py-0.5 rounded font-medium" style="background-color:${r.color}22; color:${r.color}; border:1px solid ${r.color}66;"><span class="opacity-60">Reserved:</span> ${r.reservation_name} ${s}${e}</span>`;
}).join('');
return `
<div class="flex flex-col gap-1 px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50">
<div class="flex items-center gap-2 flex-wrap">
<button onclick="openUnitDetailModal('${u.id}')"
class="font-medium text-blue-600 dark:text-blue-400 hover:underline text-sm">${u.id}</button>
${deployedBadge}
${calBadge}
</div>
${resBadges ? `<div class="flex flex-wrap gap-1">${resBadges}</div>` : ''}
</div>`;
}).join('');
}
function showRightPanel(panel) {
document.getElementById('right-fleet-summary').classList.toggle('hidden', panel !== 'summary');
document.getElementById('right-available-units').classList.toggle('hidden', panel !== 'available');
}
function showPlannerToast(msg, isError = false) {
let toast = document.getElementById('planner-toast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'planner-toast';
toast.className = 'fixed bottom-6 left-1/2 -translate-x-1/2 z-50 px-5 py-2.5 rounded-lg shadow-lg text-white text-sm font-medium transition-opacity duration-300 pointer-events-none';
document.body.appendChild(toast);
}
toast.style.background = isError ? '#ef4444' : '#22c55e';
toast.textContent = (isError ? '✕ ' : '✓ ') + msg;
toast.style.opacity = '1';
clearTimeout(toast._hideTimer);
toast._hideTimer = setTimeout(() => { toast.style.opacity = '0'; }, isError ? 4000 : 2500);
}
function plannerSyncSlotsToEstimate() {
const target = parseInt(document.getElementById('planner-est-units').value);
if (!target || target < 1) return;
const current = plannerState.slots.length;
if (target > current) {
// Add empty slots up to target
for (let i = current; i < target; i++) {
plannerState.slots.push({ unit_id: null, power_type: null, notes: null, location_name: null });
}
} else if (target < current) {
// Remove empty slots from the bottom, never remove filled ones
for (let i = plannerState.slots.length - 1; i >= target; i--) {
if (!plannerState.slots[i].unit_id) {
plannerState.slots.splice(i, 1);
}
if (plannerState.slots.length <= target) break;
}
}
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 = '';
const smartColor = plannerPickSmartColor();
plannerSetColor(smartColor, true);
const titleEl = document.getElementById('planner-form-title');
if (titleEl) titleEl.textContent = 'New Job';
document.getElementById('planner-save-btn').textContent = 'Save Job';
document.getElementById('planner-meta-fields').style.display = '';
document.getElementById('planner-edit-actions').classList.add('hidden');
plannerSetMetaOpen(true);
const summaryEl = document.getElementById('planner-meta-summary');
if (summaryEl) summaryEl.textContent = 'New Job';
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.getElementById('planner-color-value')?.value || '#3B82F6';
const estUnits = parseInt(document.getElementById('planner-est-units').value) || null;
const totalSlots = plannerState.slots.length || null;
const filledSlots = plannerState.slots.filter(s => s.unit_id);
if (!name) { showPlannerToast('Please enter a job name.', true); return; }
if (!start || !end) { showPlannerToast('Please set start and end dates.', true); return; }
if (end < start) { showPlannerToast('End date must be after start date.', true); 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';
// Save full slot list including empties so they survive round-trips
const allSlots = plannerState.slots.map(s => ({
unit_id: s.unit_id || null,
location_name: s.location_name || null,
power_type: s.power_type || null,
notes: s.notes || null
}));
const payload = {
name, start_date: start, end_date: end,
project_id: projectId || null,
assignment_type: 'specific',
device_type: plannerDeviceType,
color, notes: notes || null,
estimated_units: estUnits,
quantity_needed: totalSlots,
location_slots: allSlots
};
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(', ');
showPlannerToast(`Saved! ${assignResult.conflicts.length} unit(s) had conflicts and were skipped: ${conflictIds}`);
}
plannerReset();
switchPlannerTab('list');
// Reload the reservations list partial
const listUrl = document.getElementById('planner-reservations-list').getAttribute('hx-get');
htmx.ajax('GET', listUrl, {target: '#planner-reservations-list', swap: 'innerHTML'});
showPlannerToast(isEdit ? 'Job updated!' : 'Job created!');
} catch (e) {
console.error('Planner save error', e);
showPlannerToast('Error saving job: ' + e.message, true);
} finally {
btn.disabled = false;
btn.textContent = plannerState.reservation_id ? 'Save Changes' : 'Save Job';
}
}
function plannerPromote() {
if (!plannerState.reservation_id) return;
const name = document.getElementById('planner-form-title').textContent;
openPromoteModal(plannerState.reservation_id, name);
}
async function plannerDeleteCurrent() {
const name = document.getElementById('planner-form-title').textContent;
if (!confirm(`Delete job "${name}"?\n\nThis will remove all unit assignments.`)) return;
try {
const resp = await fetch(`/api/fleet-calendar/reservations/${plannerState.reservation_id}`, { method: 'DELETE' });
if (resp.ok) {
switchPlannerTab('list');
plannerReset();
const listUrl = document.getElementById('planner-reservations-list').getAttribute('hx-get');
htmx.ajax('GET', listUrl, {target: '#planner-reservations-list', swap: 'innerHTML'});
showPlannerToast('Job deleted');
} else {
const data = await resp.json();
showPlannerToast('Error: ' + (data.detail || 'Failed to delete'), true);
}
} catch (e) {
showPlannerToast('Error deleting job', true);
}
}
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.estimated_units || '';
plannerSetColor(res.color || '#3B82F6', true);
const dtRadio = document.querySelector(`input[name="planner_device_type"][value="${res.device_type || 'seismograph'}"]`);
if (dtRadio) dtRadio.checked = true;
// Restore full slot list — use location_slots if available, else fall back to assigned_units only
const assignedById = {};
for (const u of (res.assigned_units || [])) assignedById[u.id] = u;
if (res.location_slots && res.location_slots.length > 0) {
for (const s of res.location_slots) {
const filled = s.unit_id ? (assignedById[s.unit_id] || {}) : {};
plannerState.slots.push({
unit_id: s.unit_id || null,
power_type: s.power_type || filled.power_type || null,
notes: s.notes || filled.notes || null,
location_name: s.location_name || filled.location_name || null
});
}
} else {
// Legacy: no location_slots stored, just load filled ones
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 = '';
document.getElementById('planner-edit-actions').classList.remove('hidden');
plannerSetMetaOpen(false);
plannerUpdateMetaSummary();
plannerRenderSlots();
plannerLoadUnits();
} catch (e) {
console.error('Error loading reservation for planner', e);
}
}
switchTab('planner');
switchPlannerTab('assign');
}
</script>
{% endblock %}