Merge 0.9.0 #36

Merged
serversdown merged 7 commits from dev into main 2026-03-20 00:51:43 -04:00
3 changed files with 71 additions and 48 deletions
Showing only changes of commit 0e3f512203 - Show all commits

View File

@@ -224,8 +224,9 @@ async def get_reservation(
unit_ids = [a.unit_id for a in assignments]
units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all() if unit_ids else []
units_by_id = {u.id: u for u in units}
# Build power_type lookup from assignments
# Build power_type and notes lookup from assignments
power_type_map = {a.unit_id: a.power_type for a in assignments}
notes_map = {a.unit_id: a.notes for a in assignments}
return {
"id": reservation.id,
@@ -245,7 +246,8 @@ async def get_reservation(
"id": uid,
"last_calibrated": units_by_id[uid].last_calibrated.isoformat() if uid in units_by_id and units_by_id[uid].last_calibrated else None,
"deployed": units_by_id[uid].deployed if uid in units_by_id else False,
"power_type": power_type_map.get(uid)
"power_type": power_type_map.get(uid),
"notes": notes_map.get(uid)
}
for uid in unit_ids
]
@@ -343,6 +345,7 @@ async def assign_units_to_reservation(
unit_ids = data.get("unit_ids", [])
# Optional per-unit power types: {"BE17354": "ac", "BE9441": "solar"}
power_types = data.get("power_types", {})
location_notes = data.get("location_notes", {})
# Verify units exist (allow empty list to clear all assignments)
if unit_ids:
@@ -384,7 +387,8 @@ async def assign_units_to_reservation(
reservation_id=reservation_id,
unit_id=unit_id,
assignment_source="filled" if reservation.assignment_type == "quantity" else "specific",
power_type=power_types.get(unit_id)
power_type=power_types.get(unit_id),
notes=location_notes.get(unit_id)
)
db.add(assignment)
@@ -538,6 +542,7 @@ async def get_reservations_list(
{
"id": a.unit_id,
"power_type": a.power_type,
"notes": a.notes,
"deployed": units_by_id[a.unit_id].deployed if a.unit_id in units_by_id else False,
"last_calibrated": units_by_id[a.unit_id].last_calibrated if a.unit_id in units_by_id else None,
}

View File

@@ -389,7 +389,7 @@
<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="load"
hx-trigger="calendar-reservations-refresh from:body"
hx-swap="innerHTML">
<p class="text-gray-500">Loading reservations...</p>
</div>
@@ -417,7 +417,7 @@
</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 id="ptab-list" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 flex flex-col gap-4">
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Project Reservations</h2>
<button onclick="plannerReset(); switchPlannerTab('assign')"
@@ -428,7 +428,7 @@
New
</button>
</div>
<div id="planner-reservations-list" class="overflow-y-auto" style="max-height: 60vh;"
<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">
@@ -447,15 +447,18 @@
<h2 class="text-xl font-semibold text-gray-900 dark:text-white" id="planner-form-title">New Reservation</h2>
</div>
<!-- Metadata fields: only shown when creating a new reservation -->
<div id="planner-meta-fields">
<!-- Name -->
<div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Job / Reservation Name *</label>
<input type="text" id="planner-name" placeholder="e.g., Pine Street May Deployment"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
</div>
<!-- Device Type -->
<div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Device Type *</label>
<div class="flex rounded-lg border border-gray-300 dark:border-gray-600 overflow-hidden">
<label class="flex-1 cursor-pointer">
@@ -474,7 +477,7 @@
</div>
<!-- Project -->
<div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Link to Project (optional)</label>
<select id="planner-project"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
@@ -486,7 +489,7 @@
</div>
<!-- Dates -->
<div class="grid grid-cols-2 gap-3">
<div class="grid grid-cols-2 gap-3 mb-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Start Date *</label>
<input type="date" id="planner-start"
@@ -502,7 +505,7 @@
</div>
<!-- Color -->
<div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Color</label>
<div class="flex gap-2" id="planner-colors">
{% for color in ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899'] %}
@@ -516,12 +519,14 @@
</div>
<!-- Estimated Units Needed -->
<div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Estimated Units Needed</label>
<input type="number" id="planner-est-units" min="1" placeholder="e.g. 5"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
</div>
</div><!-- end #planner-meta-fields -->
<!-- Monitoring Locations -->
<div class="flex items-center justify-between mt-2">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Monitoring Locations</h3>
@@ -851,17 +856,16 @@ function toggleResCard(id) {
const chevron = document.getElementById('chevron-' + id);
if (!detail) return;
const isHidden = detail.classList.contains('hidden');
detail.classList.toggle('hidden', !isHidden);
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)' : '';
}
// 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;
@@ -1156,6 +1160,7 @@ function switchPlannerTab(tab) {
function switchTab(tab) {
document.getElementById('view-calendar').classList.toggle('hidden', tab !== 'calendar');
document.getElementById('view-planner').classList.toggle('hidden', tab !== 'planner');
if (tab === 'calendar') htmx.trigger(document.body, 'calendar-reservations-refresh');
['calendar', 'planner'].forEach(t => {
const btn = document.getElementById(`tab-btn-${t}`);
@@ -1452,6 +1457,7 @@ function plannerReset() {
const titleEl = document.getElementById('planner-form-title');
if (titleEl) titleEl.textContent = 'New Reservation';
document.getElementById('planner-save-btn').textContent = 'Save Reservation';
document.getElementById('planner-meta-fields').style.display = '';
plannerRenderSlots();
plannerRenderUnits();
}
@@ -1504,13 +1510,17 @@ async function plannerSave() {
// 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 locationNotes = {};
filledSlots.forEach(s => {
if (s.power_type) powerTypes[s.unit_id] = s.power_type;
if (s.notes) locationNotes[s.unit_id] = s.notes;
});
const assignResp = await fetch(
`/api/fleet-calendar/reservations/${reservationId}/assign-units`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ unit_ids: unitIds, power_types: powerTypes })
body: JSON.stringify({ unit_ids: unitIds, power_types: powerTypes, location_notes: locationNotes })
}
);
const assignResult = await assignResp.json();
@@ -1553,9 +1563,11 @@ async function openPlanner(reservationId) {
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;
if (titleEl) titleEl.textContent = res.name;
document.getElementById('planner-save-btn').textContent = 'Save Changes';
document.getElementById('planner-meta-fields').style.display = 'none';
plannerRenderSlots();
if (res.start_date && res.end_date) plannerLoadUnits();
} catch (e) {

View File

@@ -6,12 +6,13 @@
{% set card_id = "res-card-" ~ res.id %}
{% set detail_id = "res-detail-" ~ res.id %}
<div class="rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden"
<div class="rounded-lg border border-gray-200 dark:border-gray-700"
style="border-left: 4px solid {{ res.color }};">
<!-- Header row (always visible, clickable) -->
<div class="res-card-header flex items-center justify-between p-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors select-none"
data-res-id="{{ res.id }}">
data-res-id="{{ res.id }}"
onclick="toggleResCard('{{ res.id }}')">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
@@ -59,30 +60,30 @@
</div>
<!-- Action buttons (stop propagation so clicks don't toggle card) -->
<div class="flex items-center gap-1 flex-shrink-0" onclick="event.stopPropagation(); event.preventDefault();">
<button onclick="openPlanner('{{ res.id }}')"
<div class="flex items-center gap-1 flex-shrink-0">
<button onclick="event.stopPropagation(); openPlanner('{{ res.id }}')"
class="p-2 text-gray-400 hover:text-green-600 dark:hover:text-green-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
title="Plan units">
<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="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/>
</svg>
</button>
<button onclick="editReservation('{{ res.id }}')"
<button onclick="event.stopPropagation(); editReservation('{{ res.id }}')"
class="p-2 text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
title="Edit">
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</button>
<button onclick="deleteReservation('{{ res.id }}', '{{ res.name }}')"
<button onclick="event.stopPropagation(); deleteReservation('{{ res.id }}', '{{ res.name }}')"
class="p-2 text-gray-400 hover:text-red-600 dark:hover:text-red-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
title="Delete">
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
<!-- Chevron -->
<svg id="chevron-{{ res.id }}" class="w-4 h-4 text-gray-400 transition-transform duration-200 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<!-- Chevron (not in stopPropagation zone so clicking it still toggles the card) -->
<svg id="chevron-{{ res.id }}" class="w-4 h-4 text-gray-400 transition-transform duration-200 ml-1 pointer-events-none" 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>
</div>
@@ -116,23 +117,28 @@
<p class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500 mb-2">Monitoring Locations</p>
<div class="flex flex-col gap-1">
{% for u in item.assigned_units %}
<div class="flex items-center gap-3 px-3 py-1.5 rounded bg-white dark:bg-slate-700 border border-gray-100 dark:border-gray-600 text-sm">
<span class="text-gray-400 dark:text-gray-500 text-xs w-12 flex-shrink-0">Loc. {{ loop.index }}</span>
<button onclick="openUnitDetailModal('{{ u.id }}')"
class="font-medium text-blue-600 dark:text-blue-400 hover:underline">{{ u.id }}</button>
<span class="flex-1"></span>
{% if u.power_type == 'ac' %}
<span class="text-xs px-1.5 py-0.5 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 rounded">A/C</span>
{% elif u.power_type == 'solar' %}
<span class="text-xs px-1.5 py-0.5 bg-yellow-50 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400 rounded">Solar</span>
{% endif %}
{% if u.deployed %}
<span class="text-xs px-1.5 py-0.5 bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 rounded">Deployed</span>
{% else %}
<span class="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-600 text-gray-500 dark:text-gray-400 rounded">Benched</span>
{% endif %}
{% if u.last_calibrated %}
<span class="text-xs text-gray-400 dark:text-gray-500">Cal: {{ u.last_calibrated.strftime('%b %d, %Y') }}</span>
<div class="rounded bg-white dark:bg-slate-700 border border-gray-100 dark:border-gray-600 text-sm">
<div class="flex items-center gap-3 px-3 py-1.5">
<span class="text-gray-400 dark:text-gray-500 text-xs w-12 flex-shrink-0">Loc. {{ loop.index }}</span>
<button onclick="openUnitDetailModal('{{ u.id }}')"
class="font-medium text-blue-600 dark:text-blue-400 hover:underline">{{ u.id }}</button>
<span class="flex-1"></span>
{% if u.power_type == 'ac' %}
<span class="text-xs px-1.5 py-0.5 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 rounded">A/C</span>
{% elif u.power_type == 'solar' %}
<span class="text-xs px-1.5 py-0.5 bg-yellow-50 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400 rounded">Solar</span>
{% endif %}
{% if u.deployed %}
<span class="text-xs px-1.5 py-0.5 bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 rounded">Deployed</span>
{% else %}
<span class="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-600 text-gray-500 dark:text-gray-400 rounded">Benched</span>
{% endif %}
{% if u.last_calibrated %}
<span class="text-xs text-gray-400 dark:text-gray-500">Cal: {{ u.last_calibrated.strftime('%b %d, %Y') }}</span>
{% endif %}
</div>
{% if u.notes %}
<p class="px-3 pb-1.5 text-xs text-gray-400 dark:text-gray-500 italic">{{ u.notes }}</p>
{% endif %}
</div>
{% endfor %}