feat: add location names to reservation slots and promote-to-project

- Each monitoring location slot can now have a named location (e.g. "North Gate")
- Location names and slot order are persisted and restored in the planner
- Location names display in the expanded reservation card view
- Added "Promote to Project" button that converts a reservation into a
  tracked project with monitoring locations and unit assignments pre-filled

Requires DB migration on prod:
  ALTER TABLE job_reservation_units ADD COLUMN location_name TEXT;
  ALTER TABLE job_reservation_units ADD COLUMN slot_index INTEGER;
This commit is contained in:
2026-03-18 22:15:46 +00:00
parent b6e74258f1
commit b3ec249c5e
4 changed files with 236 additions and 21 deletions

View File

@@ -626,6 +626,37 @@
</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">
@@ -936,6 +967,52 @@ async function editReservation(id) {
}
}
// ---- Promote to Project ----
let promoteReservationId = null;
function openPromoteModal(id, name) {
promoteReservationId = id;
document.getElementById('promote-modal-subtitle').textContent = `"${name}" will become a tracked project.`;
document.getElementById('promote-project-number').value = '';
document.getElementById('promote-client-name').value = '';
document.getElementById('promote-error').classList.add('hidden');
document.getElementById('promote-modal').classList.remove('hidden');
}
function closePromoteModal() {
document.getElementById('promote-modal').classList.add('hidden');
promoteReservationId = null;
}
async function confirmPromote() {
if (!promoteReservationId) return;
const btn = document.querySelector('#promote-modal button[onclick="confirmPromote()"]');
const errEl = document.getElementById('promote-error');
btn.textContent = 'Creating…';
btn.disabled = true;
errEl.classList.add('hidden');
try {
const resp = await fetch(`/api/fleet-calendar/reservations/${promoteReservationId}/promote-to-project`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project_number: document.getElementById('promote-project-number').value.trim() || null,
client_name: document.getElementById('promote-client-name').value.trim() || null,
})
});
const result = await resp.json();
if (!resp.ok) throw new Error(result.detail || 'Failed to promote');
closePromoteModal();
// Navigate to the new project page
window.location.href = `/projects/${result.project_id}`;
} catch (e) {
errEl.textContent = e.message;
errEl.classList.remove('hidden');
btn.textContent = 'Create Project';
btn.disabled = false;
}
}
function closeReservationModal() {
document.getElementById('reservation-modal').classList.add('hidden');
document.getElementById('reservation-form').reset();
@@ -1179,7 +1256,7 @@ function switchTab(tab) {
// ============================================================
let plannerState = {
reservation_id: null, // null = creating new
slots: [], // array of {unit_id: string|null, power_type: string|null, notes: string|null}
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;
@@ -1321,7 +1398,7 @@ function closeUnitDetailModal() {
}
function plannerAddSlot() {
plannerState.slots.push({ unit_id: null, power_type: null, notes: null });
plannerState.slots.push({ unit_id: null, power_type: null, notes: null, location_name: null });
plannerRenderSlots();
}
@@ -1330,7 +1407,7 @@ function plannerAssignUnit(unitId) {
if (emptyIdx >= 0) {
plannerState.slots[emptyIdx].unit_id = unitId;
} else {
plannerState.slots.push({ unit_id: unitId, power_type: null, notes: null });
plannerState.slots.push({ unit_id: unitId, power_type: null, notes: null, location_name: null });
}
plannerRenderSlots();
plannerRenderUnits();
@@ -1350,6 +1427,10 @@ 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');
@@ -1422,7 +1503,11 @@ function plannerRenderSlots() {
<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">
<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)"
@@ -1511,16 +1596,20 @@ async function plannerSave() {
const unitIds = filledSlots.map(s => s.unit_id);
const powerTypes = {};
const locationNotes = {};
filledSlots.forEach(s => {
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 })
body: JSON.stringify({ unit_ids: unitIds, power_types: powerTypes, location_notes: locationNotes, location_names: locationNames, slot_indices: slotIndices })
}
);
const assignResult = await assignResp.json();
@@ -1561,7 +1650,7 @@ async function openPlanner(reservationId) {
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 });
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');

View File

@@ -68,6 +68,13 @@
<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="event.stopPropagation(); openPromoteModal('{{ res.id }}', '{{ res.name }}')"
class="p-2 text-gray-400 hover:text-emerald-600 dark:hover:text-emerald-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
title="Promote to Project">
<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="M5 10l7-7m0 0l7 7m-7-7v18"/>
</svg>
</button>
<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">
@@ -120,8 +127,13 @@
<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>
<div class="flex flex-col min-w-0">
{% if u.location_name %}
<span class="text-xs font-semibold text-gray-700 dark:text-gray-300 truncate">{{ u.location_name }}</span>
{% endif %}
<button onclick="openUnitDetailModal('{{ u.id }}')"
class="font-medium text-blue-600 dark:text-blue-400 hover:underline text-left text-sm">{{ u.id }}</button>
</div>
<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>