add reservation mechanic to dev #35
@@ -518,3 +518,7 @@ class JobReservationUnit(Base):
|
||||
|
||||
# Power requirements for this deployment slot
|
||||
power_type = Column(String, nullable=True) # "ac" | "solar" | None
|
||||
|
||||
# Location identity
|
||||
location_name = Column(String, nullable=True) # e.g. "North Gate", "Main Entrance"
|
||||
slot_index = Column(Integer, nullable=True) # Order within reservation (0-based)
|
||||
|
||||
@@ -19,7 +19,7 @@ import logging
|
||||
from backend.database import get_db
|
||||
from backend.models import (
|
||||
RosterUnit, JobReservation, JobReservationUnit,
|
||||
UserPreferences, Project
|
||||
UserPreferences, Project, MonitoringLocation, UnitAssignment
|
||||
)
|
||||
from backend.templates_config import templates
|
||||
from backend.services.fleet_calendar_service import (
|
||||
@@ -221,12 +221,13 @@ async def get_reservation(
|
||||
reservation_id=reservation_id
|
||||
).all()
|
||||
|
||||
unit_ids = [a.unit_id for a in assignments]
|
||||
# Sort assignments by slot_index so order is preserved
|
||||
assignments_sorted = sorted(assignments, key=lambda a: (a.slot_index if a.slot_index is not None else 999))
|
||||
unit_ids = [a.unit_id for a in assignments_sorted]
|
||||
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 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}
|
||||
# Build per-unit lookups from assignments
|
||||
assignment_map = {a.unit_id: a for a in assignments_sorted}
|
||||
|
||||
return {
|
||||
"id": reservation.id,
|
||||
@@ -246,8 +247,10 @@ 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),
|
||||
"notes": notes_map.get(uid)
|
||||
"power_type": assignment_map[uid].power_type,
|
||||
"notes": assignment_map[uid].notes,
|
||||
"location_name": assignment_map[uid].location_name,
|
||||
"slot_index": assignment_map[uid].slot_index,
|
||||
}
|
||||
for uid in unit_ids
|
||||
]
|
||||
@@ -343,9 +346,12 @@ async def assign_units_to_reservation(
|
||||
|
||||
data = await request.json()
|
||||
unit_ids = data.get("unit_ids", [])
|
||||
# Optional per-unit power types: {"BE17354": "ac", "BE9441": "solar"}
|
||||
# Optional per-unit dicts keyed by unit_id
|
||||
power_types = data.get("power_types", {})
|
||||
location_notes = data.get("location_notes", {})
|
||||
location_names = data.get("location_names", {})
|
||||
# slot_indices: {"BE17354": 0, "BE9441": 1, ...}
|
||||
slot_indices = data.get("slot_indices", {})
|
||||
|
||||
# Verify units exist (allow empty list to clear all assignments)
|
||||
if unit_ids:
|
||||
@@ -388,7 +394,9 @@ async def assign_units_to_reservation(
|
||||
unit_id=unit_id,
|
||||
assignment_source="filled" if reservation.assignment_type == "quantity" else "specific",
|
||||
power_type=power_types.get(unit_id),
|
||||
notes=location_notes.get(unit_id)
|
||||
notes=location_notes.get(unit_id),
|
||||
location_name=location_names.get(unit_id),
|
||||
slot_index=slot_indices.get(unit_id),
|
||||
)
|
||||
db.add(assignment)
|
||||
|
||||
@@ -534,8 +542,9 @@ async def get_reservations_list(
|
||||
).all()
|
||||
assigned_count = len(assignments)
|
||||
|
||||
# Enrich assignments with unit details
|
||||
unit_ids = [a.unit_id for a in assignments]
|
||||
# Enrich assignments with unit details, sorted by slot_index
|
||||
assignments_sorted = sorted(assignments, key=lambda a: (a.slot_index if a.slot_index is not None else 999))
|
||||
unit_ids = [a.unit_id for a in assignments_sorted]
|
||||
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}
|
||||
assigned_units = [
|
||||
@@ -543,10 +552,12 @@ async def get_reservations_list(
|
||||
"id": a.unit_id,
|
||||
"power_type": a.power_type,
|
||||
"notes": a.notes,
|
||||
"location_name": a.location_name,
|
||||
"slot_index": a.slot_index,
|
||||
"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,
|
||||
}
|
||||
for a in assignments
|
||||
for a in assignments_sorted
|
||||
]
|
||||
|
||||
# Check for calibration conflicts
|
||||
@@ -680,3 +691,102 @@ async def get_month_partial(
|
||||
"today": date.today().isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Promote Reservation to Project
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/api/fleet-calendar/reservations/{reservation_id}/promote-to-project", response_class=JSONResponse)
|
||||
async def promote_reservation_to_project(
|
||||
reservation_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Promote a job reservation to a full project in the projects DB.
|
||||
Creates: Project + MonitoringLocations + UnitAssignments.
|
||||
"""
|
||||
reservation = db.query(JobReservation).filter_by(id=reservation_id).first()
|
||||
if not reservation:
|
||||
raise HTTPException(status_code=404, detail="Reservation not found")
|
||||
|
||||
data = await request.json()
|
||||
project_number = data.get("project_number") or None
|
||||
client_name = data.get("client_name") or None
|
||||
|
||||
# Map device_type to project_type_id
|
||||
if reservation.device_type == "slm":
|
||||
project_type_id = "sound_monitoring"
|
||||
location_type = "sound"
|
||||
else:
|
||||
project_type_id = "vibration_monitoring"
|
||||
location_type = "vibration"
|
||||
|
||||
# Check for duplicate project name
|
||||
existing = db.query(Project).filter_by(name=reservation.name).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=409, detail=f"A project named '{reservation.name}' already exists.")
|
||||
|
||||
# Create the project
|
||||
project_id = str(uuid.uuid4())
|
||||
project = Project(
|
||||
id=project_id,
|
||||
name=reservation.name,
|
||||
project_number=project_number,
|
||||
client_name=client_name,
|
||||
project_type_id=project_type_id,
|
||||
status="active",
|
||||
start_date=reservation.start_date,
|
||||
end_date=reservation.end_date,
|
||||
description=reservation.notes,
|
||||
)
|
||||
db.add(project)
|
||||
db.flush()
|
||||
|
||||
# Load assignments sorted by slot_index
|
||||
assignments = db.query(JobReservationUnit).filter_by(reservation_id=reservation_id).all()
|
||||
assignments_sorted = sorted(assignments, key=lambda a: (a.slot_index if a.slot_index is not None else 999))
|
||||
|
||||
locations_created = 0
|
||||
units_assigned = 0
|
||||
|
||||
for i, assignment in enumerate(assignments_sorted):
|
||||
loc_num = str(i + 1).zfill(3)
|
||||
loc_name = assignment.location_name or f"Location {i + 1}"
|
||||
|
||||
location = MonitoringLocation(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=project_id,
|
||||
location_type=location_type,
|
||||
name=loc_name,
|
||||
description=assignment.notes,
|
||||
)
|
||||
db.add(location)
|
||||
db.flush()
|
||||
locations_created += 1
|
||||
|
||||
if assignment.unit_id:
|
||||
unit_assignment = UnitAssignment(
|
||||
id=str(uuid.uuid4()),
|
||||
unit_id=assignment.unit_id,
|
||||
location_id=location.id,
|
||||
project_id=project_id,
|
||||
device_type=reservation.device_type or "seismograph",
|
||||
status="active",
|
||||
notes=f"Power: {assignment.power_type}" if assignment.power_type else None,
|
||||
)
|
||||
db.add(unit_assignment)
|
||||
units_assigned += 1
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Promoted reservation '{reservation.name}' to project {project_id}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"project_id": project_id,
|
||||
"project_name": reservation.name,
|
||||
"locations_created": locations_created,
|
||||
"units_assigned": units_assigned,
|
||||
}
|
||||
|
||||
@@ -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, '"') : ''}"
|
||||
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, '"') : ''}"
|
||||
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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user