feat: start build of listed reservation system

This commit is contained in:
2026-03-13 21:37:06 +00:00
parent b571dc29bc
commit e4d1f0d684
6 changed files with 956 additions and 134 deletions

View File

@@ -223,10 +223,23 @@
<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">
@@ -382,6 +395,223 @@
</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 flex-1">
<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-auto" style="max-height: 60vh;"
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>
<!-- Name -->
<div>
<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>
<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>
<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()"
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>
<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>
<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>
<!-- 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">
@@ -616,6 +846,39 @@ function openReservationModal() {
updateCalendarAvailability();
}
function toggleResCard(id) {
const detail = document.getElementById('res-detail-' + id);
const chevron = document.getElementById('chevron-' + id);
if (!detail) return;
const isHidden = detail.classList.contains('hidden');
detail.classList.toggle('hidden', !isHidden);
if (chevron) chevron.style.transform = isHidden ? 'rotate(180deg)' : '';
}
// Event delegation for reservation cards (handles HTMX-loaded content)
document.addEventListener('click', function(e) {
const header = e.target.closest('.res-card-header');
if (!header) return;
const id = header.dataset.resId;
if (id) toggleResCard(id);
});
async function deleteReservation(id, name) {
if (!confirm(`Delete reservation "${name}"?\n\nThis will remove all unit assignments.`)) return;
try {
const response = await fetch(`/api/fleet-calendar/reservations/${id}`, { method: 'DELETE' });
if (response.ok) {
htmx.trigger('#planner-reservations-list', 'load');
} else {
const data = await response.json();
alert('Error: ' + (data.detail || 'Failed to delete'));
}
} catch (error) {
console.error('Error:', error);
alert('Error deleting reservation');
}
}
async function editReservation(id) {
try {
const response = await fetch(`/api/fleet-calendar/reservations/${id}`);
@@ -869,5 +1132,438 @@ document.addEventListener('keydown', function(e) {
closeReservationModal();
}
});
// ============================================================
// Tab + sub-tab switching
// ============================================================
function switchPlannerTab(tab) {
const isAssign = tab === 'assign';
document.getElementById('ptab-list').classList.toggle('hidden', isAssign);
document.getElementById('ptab-assign').classList.toggle('hidden', !isAssign);
['list', 'assign'].forEach(t => {
const btn = document.getElementById(`ptab-btn-${t}`);
if (t === tab) {
btn.classList.add('bg-white', 'dark:bg-slate-600', 'text-gray-900', 'dark:text-white', 'shadow');
btn.classList.remove('text-gray-600', 'dark:text-gray-300', 'hover:text-gray-900', 'dark:hover:text-white');
} else {
btn.classList.remove('bg-white', 'dark:bg-slate-600', 'text-gray-900', 'dark:text-white', 'shadow');
btn.classList.add('text-gray-600', 'dark:text-gray-300', 'hover:text-gray-900', 'dark:hover:text-white');
}
});
}
function switchTab(tab) {
document.getElementById('view-calendar').classList.toggle('hidden', tab !== 'calendar');
document.getElementById('view-planner').classList.toggle('hidden', tab !== 'planner');
['calendar', 'planner'].forEach(t => {
const btn = document.getElementById(`tab-btn-${t}`);
if (t === tab) {
btn.classList.add('bg-white', 'dark:bg-slate-600', 'text-gray-900', 'dark:text-white', 'shadow');
btn.classList.remove('text-gray-600', 'dark:text-gray-300', 'hover:text-gray-900', 'dark:hover:text-white');
} else {
btn.classList.remove('bg-white', 'dark:bg-slate-600', 'text-gray-900', 'dark:text-white', 'shadow');
btn.classList.add('text-gray-600', 'dark:text-gray-300', 'hover:text-gray-900', 'dark:hover:text-white');
}
});
}
// ============================================================
// Reservation Planner
// ============================================================
let plannerState = {
reservation_id: null, // null = creating new
slots: [], // array of {unit_id: string|null, power_type: string|null, notes: string|null}
allUnits: [] // full list from server
};
let dragSrcIdx = null;
function plannerDatesChanged() {
plannerLoadUnits();
}
async function plannerLoadUnits() {
const start = document.getElementById('planner-start').value;
const end = document.getElementById('planner-end').value;
const excludeId = plannerState.reservation_id || '';
const plannerDeviceType = document.querySelector('input[name="planner_device_type"]:checked')?.value || 'seismograph';
let url = `/api/fleet-calendar/planner-availability?device_type=${plannerDeviceType}`;
if (start && end && end >= start) {
url += `&start_date=${start}&end_date=${end}`;
}
if (excludeId) url += `&exclude_reservation_id=${excludeId}`;
try {
const resp = await fetch(url);
const data = await resp.json();
plannerState.allUnits = data.units || [];
const hasDates = start && end;
document.getElementById('planner-avail-count').textContent =
hasDates ? `(${plannerState.allUnits.length} available for period)` : `(${plannerState.allUnits.length} total)`;
plannerRenderUnits();
} catch (e) {
console.error('Planner load error', e);
}
}
function plannerFilterUnits() {
// Mutually exclusive checkboxes
const deployedOnly = document.getElementById('planner-deployed-only');
const benchedOnly = document.getElementById('planner-benched-only');
if (event && event.target === deployedOnly && deployedOnly.checked) benchedOnly.checked = false;
if (event && event.target === benchedOnly && benchedOnly.checked) deployedOnly.checked = false;
plannerRenderUnits();
}
function plannerRenderUnits() {
const search = document.getElementById('planner-search').value.toLowerCase();
const deployedOnly = document.getElementById('planner-deployed-only').checked;
const benchedOnly = document.getElementById('planner-benched-only').checked;
const slottedIds = new Set(plannerState.slots.map(s => s.unit_id).filter(Boolean));
const start = document.getElementById('planner-start').value;
const end = document.getElementById('planner-end').value;
let units = plannerState.allUnits.filter(u => {
if (deployedOnly && !u.deployed) return false;
if (benchedOnly && u.deployed) return false;
if (search && !u.id.toLowerCase().includes(search)) return false;
return true;
});
const placeholder = document.getElementById('planner-units-placeholder');
const list = document.getElementById('planner-units-list');
if (plannerState.allUnits.length === 0) {
placeholder.classList.remove('hidden');
placeholder.textContent = 'Loading units...';
list.querySelectorAll('.planner-unit-row').forEach(el => el.remove());
return;
}
placeholder.classList.add('hidden');
list.querySelectorAll('.planner-unit-row').forEach(el => el.remove());
if (units.length === 0) {
const empty = document.createElement('p');
empty.className = 'planner-unit-row text-sm text-gray-400 dark:text-gray-500 text-center py-8';
empty.textContent = 'No units match your filter';
list.appendChild(empty);
return;
}
for (const unit of units) {
const isSlotted = slottedIds.has(unit.id);
const row = document.createElement('div');
row.className = `planner-unit-row flex items-center justify-between px-3 py-2 rounded-lg border transition-colors ${
isSlotted
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800 opacity-60 cursor-default'
: 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer'
}`;
row.dataset.unitId = unit.id;
if (!isSlotted) row.onclick = () => plannerAssignUnit(unit.id);
const calDate = unit.last_calibrated
? new Date(unit.last_calibrated + 'T00:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'})
: 'No cal date';
// Calibration expiry warning during deployment
let expiryWarning = '';
if (start && end && unit.expiry_date) {
const expiry = new Date(unit.expiry_date + 'T00:00:00');
const jobStart = new Date(start + 'T00:00:00');
const jobEnd = new Date(end + 'T00:00:00');
if (expiry >= jobStart && expiry <= jobEnd) {
const expiryStr = expiry.toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'});
expiryWarning = `<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, '&quot;') : ''}"
oninput="plannerSetSlotNotes(${idx}, this.value)"
placeholder="Location notes (optional)"
class="w-full text-xs px-2 py-1 border border-gray-200 dark:border-gray-600 rounded bg-white dark:bg-slate-700 text-gray-600 dark:text-gray-400 placeholder-gray-300 dark:placeholder-gray-600 focus:ring-1 focus:ring-blue-500">
</div>
`;
container.appendChild(row);
});
}
function plannerClearSlot(idx) {
plannerState.slots[idx].unit_id = null;
plannerState.slots[idx].power_type = null;
plannerRenderSlots();
plannerRenderUnits();
}
function plannerReset() {
plannerState = { reservation_id: null, slots: [], allUnits: [] };
document.getElementById('planner-name').value = '';
document.getElementById('planner-project').value = '';
document.getElementById('planner-start').value = '';
document.getElementById('planner-end').value = '';
document.getElementById('planner-notes').value = '';
document.getElementById('planner-est-units').value = '';
document.getElementById('planner-search').value = '';
const defaultDt = document.querySelector('input[name="planner_device_type"][value="seismograph"]');
if (defaultDt) defaultDt.checked = true;
document.getElementById('planner-deployed-only').checked = false;
document.getElementById('planner-avail-count').textContent = '';
document.querySelector('input[name="planner_color"][value="#3B82F6"]').checked = true;
const titleEl = document.getElementById('planner-form-title');
if (titleEl) titleEl.textContent = 'New Reservation';
document.getElementById('planner-save-btn').textContent = 'Save Reservation';
plannerRenderSlots();
plannerRenderUnits();
}
async function plannerSave() {
const name = document.getElementById('planner-name').value.trim();
const start = document.getElementById('planner-start').value;
const end = document.getElementById('planner-end').value;
const projectId = document.getElementById('planner-project').value;
const notes = document.getElementById('planner-notes').value.trim();
const color = document.querySelector('input[name="planner_color"]:checked')?.value || '#3B82F6';
const estUnits = parseInt(document.getElementById('planner-est-units').value) || null;
const filledSlots = plannerState.slots.filter(s => s.unit_id);
if (!name) { alert('Please enter a reservation name.'); return; }
if (!start || !end) { alert('Please set start and end dates.'); return; }
if (end < start) { alert('End date must be after start date.'); return; }
const btn = document.getElementById('planner-save-btn');
btn.disabled = true;
btn.textContent = 'Saving...';
try {
const isEdit = !!plannerState.reservation_id;
const url = isEdit
? `/api/fleet-calendar/reservations/${plannerState.reservation_id}`
: '/api/fleet-calendar/reservations';
const method = isEdit ? 'PUT' : 'POST';
const plannerDeviceType = document.querySelector('input[name="planner_device_type"]:checked')?.value || 'seismograph';
const payload = {
name, start_date: start, end_date: end,
project_id: projectId || null,
assignment_type: 'specific',
device_type: plannerDeviceType,
color, notes: notes || null,
quantity_needed: estUnits
};
const resp = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const result = await resp.json();
if (!result.success) throw new Error(result.detail || 'Save failed');
const reservationId = isEdit ? plannerState.reservation_id : result.reservation_id;
// Always call assign-units (even with empty list) — endpoint does a full replace
const unitIds = filledSlots.map(s => s.unit_id);
const powerTypes = {};
filledSlots.forEach(s => { if (s.power_type) powerTypes[s.unit_id] = s.power_type; });
const assignResp = await fetch(
`/api/fleet-calendar/reservations/${reservationId}/assign-units`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ unit_ids: unitIds, power_types: powerTypes })
}
);
const assignResult = await assignResp.json();
if (assignResult.conflicts && assignResult.conflicts.length > 0) {
const conflictIds = assignResult.conflicts.map(c => c.unit_id).join(', ');
alert(`Saved! Note: ${assignResult.conflicts.length} unit(s) had conflicts and were not assigned: ${conflictIds}`);
}
plannerReset();
switchPlannerTab('list');
// Reload the reservations list partial
htmx.trigger('#planner-reservations-list', 'load');
} catch (e) {
console.error('Planner save error', e);
alert('Error saving reservation: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = plannerState.reservation_id ? 'Save Changes' : 'Save Reservation';
}
}
async function openPlanner(reservationId) {
plannerReset();
if (reservationId) {
try {
const resp = await fetch(`/api/fleet-calendar/reservations/${reservationId}`);
const res = await resp.json();
plannerState.reservation_id = reservationId;
document.getElementById('planner-name').value = res.name;
document.getElementById('planner-project').value = res.project_id || '';
document.getElementById('planner-start').value = res.start_date;
document.getElementById('planner-end').value = res.end_date || '';
document.getElementById('planner-notes').value = res.notes || '';
document.getElementById('planner-est-units').value = res.quantity_needed || '';
const colorRadio = document.querySelector(`input[name="planner_color"][value="${res.color}"]`);
if (colorRadio) colorRadio.checked = true;
const dtRadio = document.querySelector(`input[name="planner_device_type"][value="${res.device_type || 'seismograph'}"]`);
if (dtRadio) dtRadio.checked = true;
// Pre-fill slots from existing assigned units
for (const u of (res.assigned_units || [])) {
plannerState.slots.push({ unit_id: u.id, power_type: u.power_type || null, notes: u.notes || null });
}
const titleEl = document.getElementById('planner-form-title');
if (titleEl) titleEl.textContent = 'Edit: ' + res.name;
document.getElementById('planner-save-btn').textContent = 'Save Changes';
plannerRenderSlots();
if (res.start_date && res.end_date) plannerLoadUnits();
} catch (e) {
console.error('Error loading reservation for planner', e);
}
}
switchTab('planner');
switchPlannerTab('assign');
}
</script>
{% endblock %}