Compare commits
7 Commits
275a168046
...
d0685baed5
| Author | SHA1 | Date | |
|---|---|---|---|
| d0685baed5 | |||
| d64b9450a1 | |||
| a073b9b06e | |||
| 502bf5bbeb | |||
| 472c25372d | |||
| 6d37bd759e | |||
| 44ab4d8427 |
@@ -5,6 +5,45 @@ All notable changes to Terra-View will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.12.1] - 2026-05-20
|
||||||
|
|
||||||
|
Field-operations polish — three small features and two correctness fixes that smooth out the deployment workflow added in v0.12.0. The new Unit Swap wizard and editable deployment timeline are the operator-facing items; the swap/unassign/promote roster-flag fix closes a long-standing data-consistency hole.
|
||||||
|
|
||||||
|
### Added — Unit Swap wizard (`/tools/unit-swap`)
|
||||||
|
|
||||||
|
- **Mobile-first 4-step wizard** for the common field operation: pick project → pick location → choose incoming unit (with optional modem swap) → review + confirm. Designed for tap-driven use on a phone in the field; works on desktop too.
|
||||||
|
- **Benched-candidate awareness**: `GET /api/projects/.../available-units?include_benched=true` and `available-modems?include_benched=true` now return units/modems with `deployed=False` alongside the active fleet — exactly the inventory a tech pulls off the shelf. Each row carries a `deployed` boolean for badge rendering. Default (`include_benched=false`) is unchanged, so the existing location-detail swap modal isn't affected.
|
||||||
|
- **`POST /locations/{loc}/swap` enhancements**:
|
||||||
|
- Flips the incoming unit (and modem) back to `deployed=True` if either was on the bench, keeping the legacy `RosterUnit.deployed` flag consistent with the active-assignment signal.
|
||||||
|
- Adds the symmetric half of the orphan-pairing fix: when a newly-paired modem still claims a different seismograph (whose `deployed_with_modem_id` was never cleared in a past swap), the stale back-reference is broken before re-pairing.
|
||||||
|
- **`locations-with-assignments`** response now includes `modem.deployed`, so the wizard can badge the current modem in the location card, "Keep current modem" choice, picker rows, and review screen.
|
||||||
|
- Tile on `/tools` for discovery; sidebar entry in the Tools nav cluster.
|
||||||
|
|
||||||
|
### Added — Editable deployment timeline on `/unit/{id}`
|
||||||
|
|
||||||
|
- **Per-row inline edit (pencil icon)** on each assignment in the unit's Deployment Timeline. Opens a modal with `assigned_at`, `assigned_until` (with an "open-ended" checkbox that clears the end date), and notes. Saves via the existing `PATCH /api/projects/{pid}/assignments/{aid}`; delete (for misclicks) via the existing `DELETE`.
|
||||||
|
- **"+ Add deployment record" button** at the top of the timeline for backfilling historical windows — useful when orphan events sit outside any assignment. Modal flow: project → location → assigned_at → assigned_until (optional open-ended) → notes.
|
||||||
|
- **Closed-window assignments** now accepted by `POST /api/projects/.../locations/{loc}/assign`: the blanket "location already has an active assignment" check became overlap detection against same-location windows. Closed historical assignments that don't overlap an existing one are accepted (the backfill case).
|
||||||
|
- After any save/delete the timeline reloads and the SFM-events list re-fetches, so previously-orphaned events flip to "attributed" when their timestamp now falls inside an assignment window.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **`RosterUnit.deployed` now flips correctly on swap / unassign / promote-pending** (`POST /locations/{loc}/swap`, `POST /assignments/{aid}/unassign`, `POST /deployments/pending/{id}/promote`). The legacy `deployed` flag drives heartbeat polling and benched-vs-deployed roster filters; before this fix, those three workflows ended an assignment without flipping the flag, so the outgoing unit kept being polled and showed up as "deployed" forever. All three now: close the previous active assignment, break the outgoing unit's modem pairing (both directions), and set `deployed = False` on the outgoing unit. Unassign and swap also clear the modem's back-reference. Promote-pending additionally handles the case where the target location already has an active assignment — previously this silently created two active assignments at the same location; now the old one is closed (`assigned_until = pending.capture_time`, `status = completed`), the old unit benched + unpaired, and an `assignment_swapped` `UnitHistory` row is written.
|
||||||
|
- **Deployment timeline now respects user timezone for display *and* edits.** Timestamps were stored correctly as UTC but rendered raw — a 1:30 PM EDT swap displayed as "5:30" because the frontend sliced the naive UTC ISO string straight to the screen. Two-sided fix:
|
||||||
|
- **Display**: `services/deployment_timeline.py` converts every emitted timestamp (`starts_at`, `ends_at`, `event_overlay.peak_pvs_at`, `last_event`) through `utc_to_local()` using the user's configured timezone from `UserPreferences` before serializing. Frontend slicing keeps working — it just slices a local-time string now.
|
||||||
|
- **Write**: `PATCH /api/projects/{pid}/assignments/{aid}` and `POST /locations/{loc}/assign` interpret a *naive* `assigned_at` / `assigned_until` ISO string as the user's local time and convert to UTC via `local_to_utc()`. Explicit tz-aware strings (`...Z` or `...+00:00`) skip the conversion, so programmatic callers that already speak UTC keep working.
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
|
||||||
|
No schema changes. Static code-only release — pull and restart:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/serversdown/terra-view
|
||||||
|
docker compose build terra-view && docker compose up -d terra-view
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.12.0] - 2026-05-17
|
## [0.12.0] - 2026-05-17
|
||||||
|
|
||||||
Field-deployment workflow + fleet-wide deployment views + SFM event DB management. The headline is the mobile capture flow: a field tech can now arrive on site, take one photo of the installed seismograph, and walk away — classification (which project, which location) happens later at a desk through the new pending-deployment hopper. EXIF GPS is auto-extracted on capture, so the resulting `UnitAssignment` lands with coordinates without anyone typing them.
|
Field-deployment workflow + fleet-wide deployment views + SFM event DB management. The headline is the mobile capture flow: a field tech can now arrive on site, take one photo of the installed seismograph, and walk away — classification (which project, which location) happens later at a desk through the new pending-deployment hopper. EXIF GPS is auto-extracted on capture, so the resulting `UnitAssignment` lands with coordinates without anyone typing them.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Terra-View v0.12.0
|
# Terra-View v0.12.1
|
||||||
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
|
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
@@ -9,15 +9,20 @@ Backend API and HTMX-powered web interface for managing a mixed fleet of seismog
|
|||||||
- **Touch Optimized**: 44x44px minimum touch targets, hamburger menu, bottom navigation bar
|
- **Touch Optimized**: 44x44px minimum touch targets, hamburger menu, bottom navigation bar
|
||||||
- **Mobile Card View**: Compact unit cards with status dots, tap-to-navigate locations, and detail modals
|
- **Mobile Card View**: Compact unit cards with status dots, tap-to-navigate locations, and detail modals
|
||||||
- **Background Sync**: Queue edits while offline and automatically sync when connection returns
|
- **Background Sync**: Queue edits while offline and automatically sync when connection returns
|
||||||
|
- **Field-Deployment Workflow**: One-photo mobile capture at `/deploy` → desk-side classification at `/tools/pending-deployments` → automatic UnitAssignment creation with EXIF GPS
|
||||||
|
- **Unit Swap Wizard** (`/tools/unit-swap`): mobile-first 4-step flow for swapping a vibration unit (and optionally its modem) at a monitoring location. Surfaces benched-fleet candidates as eligible incoming units; cleans up stale modem back-references on swap
|
||||||
|
- **Editable Deployment Timeline** on every unit detail page: inline edit / delete each assignment, plus an "Add deployment record" button for backfilling historical windows. Frees-up previously-orphaned events when their timestamp now falls inside an assignment
|
||||||
- **Web Dashboard**: Modern, responsive UI with dark/light mode, live HTMX updates, and integrated fleet map
|
- **Web Dashboard**: Modern, responsive UI with dark/light mode, live HTMX updates, and integrated fleet map
|
||||||
- **Fleet Monitoring**: Track deployed, benched, retired, and ignored units in separate buckets with unknown-emitter triage
|
- **Fleet Monitoring**: Track deployed, benched, retired, and ignored units in separate buckets with unknown-emitter triage
|
||||||
- **Roster Management**: Full CRUD + CSV import/export, device-type aware metadata, and inline actions from the roster tables
|
- **Roster Management**: Full CRUD + CSV import/export, device-type aware metadata, and inline actions from the roster tables
|
||||||
- **Settings & Safeguards**: `/settings` page exposes roster stats, exports, replace-all imports, and danger-zone reset tools
|
- **Settings & Safeguards**: `/settings` page exposes roster stats, exports, replace-all imports, and danger-zone reset tools
|
||||||
- **Device & Modem Metadata**: Capture calibration windows, modem pairings, phone/IP details, and addresses per unit
|
- **Device & Modem Metadata**: Capture calibration windows, modem pairings, phone/IP details, and addresses per unit
|
||||||
- **Status Management**: Automatically mark deployed units as OK, Pending (>12h), or Missing (>24h) based on recent telemetry
|
- **Status Management**: Automatically mark deployed units as OK, Pending (>12h), or Missing (>24h) based on recent telemetry
|
||||||
- **Data Ingestion**: Accept reports from emitter scripts via REST API
|
- **SFM Event DB Manager** (`/admin/events`): cross-unit event browser with bulk false-trigger flagging and admin-only hard-delete (cleans on-disk binaries + sidecars too) for purging bogus events from misbehaving units
|
||||||
|
- **Deployment-History Calendar + Gantt** (`/tools/deployment-history`): fleet-wide 12-month calendar with side-panel day drill-down, plus "Gantt by Project" / "Gantt by Unit" tabs
|
||||||
- **Photo Management**: Upload and view photos for each unit
|
- **Photo Management**: Upload and view photos for each unit
|
||||||
- **Interactive Maps**: Leaflet-based maps showing unit locations with tap-to-navigate for mobile
|
- **Interactive Maps**: Leaflet-based maps showing unit locations with tap-to-navigate for mobile (reusable location-map partial across project overview + Vibration tab)
|
||||||
|
- **Timezone-Aware Timeline**: deployment assignments display and edit in the user's configured local timezone; UTC stays canonical on disk
|
||||||
- **SQLite Storage**: Lightweight, file-based database for easy deployment
|
- **SQLite Storage**: Lightweight, file-based database for easy deployment
|
||||||
- **Database Management**: Comprehensive backup and restore system
|
- **Database Management**: Comprehensive backup and restore system
|
||||||
- **Manual Snapshots**: Create on-demand backups with descriptions
|
- **Manual Snapshots**: Create on-demand backups with descriptions
|
||||||
|
|||||||
+9
-1
@@ -30,7 +30,7 @@ Base.metadata.create_all(bind=engine)
|
|||||||
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
||||||
|
|
||||||
# Initialize FastAPI app
|
# Initialize FastAPI app
|
||||||
VERSION = "0.12.0"
|
VERSION = "0.12.1"
|
||||||
if ENVIRONMENT == "development":
|
if ENVIRONMENT == "development":
|
||||||
_build = os.getenv("BUILD_NUMBER", "0")
|
_build = os.getenv("BUILD_NUMBER", "0")
|
||||||
if _build and _build != "0":
|
if _build and _build != "0":
|
||||||
@@ -291,6 +291,14 @@ async def pending_deployments_page(request: Request):
|
|||||||
return templates.TemplateResponse("admin/pending_deployments.html", {"request": request})
|
return templates.TemplateResponse("admin/pending_deployments.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/tools/unit-swap", response_class=HTMLResponse)
|
||||||
|
async def unit_swap_page(request: Request):
|
||||||
|
"""Mobile-first wizard for swapping a vibration unit (and optionally its
|
||||||
|
modem) at a monitoring location. Pick project → location → incoming
|
||||||
|
unit → modem decision → confirm → optional photo of the new install."""
|
||||||
|
return templates.TemplateResponse("admin/unit_swap.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/modems", response_class=HTMLResponse)
|
@app.get("/modems", response_class=HTMLResponse)
|
||||||
async def modems_page(request: Request):
|
async def modems_page(request: Request):
|
||||||
"""Field modems management dashboard"""
|
"""Field modems management dashboard"""
|
||||||
|
|||||||
@@ -336,6 +336,36 @@ async def promote_pending(
|
|||||||
db.add(location)
|
db.add(location)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
|
# If this location already has an active assignment, the /deploy
|
||||||
|
# capture means someone replaced that unit in the field — close the
|
||||||
|
# old assignment, break the outgoing unit's modem pairing, and bench
|
||||||
|
# it so the heartbeat / polling subsystem stops chasing it.
|
||||||
|
existing_active = db.query(UnitAssignment).filter(
|
||||||
|
UnitAssignment.location_id == location.id,
|
||||||
|
UnitAssignment.assigned_until == None, # noqa: E711
|
||||||
|
).first()
|
||||||
|
if existing_active and existing_active.unit_id != pd.unit_id:
|
||||||
|
existing_active.assigned_until = pd.captured_at
|
||||||
|
existing_active.status = "completed"
|
||||||
|
old_unit = db.query(RosterUnit).filter_by(id=existing_active.unit_id).first()
|
||||||
|
if old_unit:
|
||||||
|
if old_unit.deployed_with_modem_id:
|
||||||
|
old_modem = db.query(RosterUnit).filter_by(
|
||||||
|
id=old_unit.deployed_with_modem_id, device_type="modem"
|
||||||
|
).first()
|
||||||
|
if old_modem and old_modem.deployed_with_unit_id == old_unit.id:
|
||||||
|
old_modem.deployed_with_unit_id = None
|
||||||
|
old_unit.deployed_with_modem_id = None
|
||||||
|
if old_unit.deployed:
|
||||||
|
old_unit.deployed = False
|
||||||
|
_record_history(
|
||||||
|
db, unit_id=existing_active.unit_id,
|
||||||
|
change_type="assignment_swapped",
|
||||||
|
old_value=location.name,
|
||||||
|
new_value=f"superseded by /deploy capture → {pd.unit_id}",
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
|
||||||
# Create the assignment. assigned_at = pending capture time (so
|
# Create the assignment. assigned_at = pending capture time (so
|
||||||
# events emitted after the install are correctly attributed back
|
# events emitted after the install are correctly attributed back
|
||||||
# to this location).
|
# to this location).
|
||||||
@@ -354,6 +384,12 @@ async def promote_pending(
|
|||||||
db.add(assignment)
|
db.add(assignment)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
|
# Incoming unit is in the field again — flip it back to deployed
|
||||||
|
# if it was on the bench (mirrors the swap endpoint).
|
||||||
|
incoming_unit = db.query(RosterUnit).filter_by(id=pd.unit_id).first()
|
||||||
|
if incoming_unit and not incoming_unit.deployed:
|
||||||
|
incoming_unit.deployed = True
|
||||||
|
|
||||||
# Promote the pending row.
|
# Promote the pending row.
|
||||||
pd.status = "assigned"
|
pd.status = "assigned"
|
||||||
pd.promoted_at = datetime.utcnow()
|
pd.promoted_at = datetime.utcnow()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from fastapi.responses import HTMLResponse, JSONResponse
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import and_, or_
|
from sqlalchemy import and_, or_
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import uuid
|
import uuid
|
||||||
import json
|
import json
|
||||||
@@ -34,7 +35,7 @@ from backend.models import (
|
|||||||
ScheduledAction,
|
ScheduledAction,
|
||||||
)
|
)
|
||||||
from backend.templates_config import templates
|
from backend.templates_config import templates
|
||||||
from backend.utils.timezone import local_to_utc
|
from backend.utils.timezone import local_to_utc, utc_to_local # noqa: F401
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"])
|
router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"])
|
||||||
|
|
||||||
@@ -262,6 +263,84 @@ async def get_project_locations_json(
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/locations-with-assignments")
|
||||||
|
async def get_locations_with_assignments(
|
||||||
|
project_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
location_type: Optional[str] = Query(None),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Locations + their currently-active assignment + current unit + paired modem
|
||||||
|
in one call. Used by the Unit Swap tool's location picker so a field tech
|
||||||
|
can see what's deployed where without N+1 round-trips.
|
||||||
|
|
||||||
|
Empty locations come back with assignment/unit/modem all null.
|
||||||
|
Removed locations are always excluded — you don't swap onto a dead slot.
|
||||||
|
"""
|
||||||
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
query = db.query(MonitoringLocation).filter_by(project_id=project_id).filter(
|
||||||
|
MonitoringLocation.removed_at == None # noqa: E711
|
||||||
|
)
|
||||||
|
if location_type:
|
||||||
|
query = query.filter_by(location_type=location_type)
|
||||||
|
locations = query.order_by(MonitoringLocation.sort_order, MonitoringLocation.name).all()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for loc in locations:
|
||||||
|
assignment = db.query(UnitAssignment).filter(
|
||||||
|
and_(
|
||||||
|
UnitAssignment.location_id == loc.id,
|
||||||
|
UnitAssignment.assigned_until == None, # noqa: E711
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
unit_payload = None
|
||||||
|
modem_payload = None
|
||||||
|
if assignment:
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
||||||
|
if unit:
|
||||||
|
unit_payload = {
|
||||||
|
"id": unit.id,
|
||||||
|
"device_type": unit.device_type,
|
||||||
|
"unit_type": unit.unit_type,
|
||||||
|
"slm_model": unit.slm_model,
|
||||||
|
"deployed_with_modem_id": unit.deployed_with_modem_id,
|
||||||
|
}
|
||||||
|
if unit.deployed_with_modem_id:
|
||||||
|
modem = db.query(RosterUnit).filter_by(
|
||||||
|
id=unit.deployed_with_modem_id, device_type="modem"
|
||||||
|
).first()
|
||||||
|
if modem:
|
||||||
|
modem_payload = {
|
||||||
|
"id": modem.id,
|
||||||
|
"hardware_model": modem.hardware_model,
|
||||||
|
"ip_address": modem.ip_address,
|
||||||
|
"phone_number": modem.phone_number,
|
||||||
|
"deployed": bool(modem.deployed),
|
||||||
|
}
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"id": loc.id,
|
||||||
|
"name": loc.name,
|
||||||
|
"location_type": loc.location_type,
|
||||||
|
"description": loc.description,
|
||||||
|
"address": loc.address,
|
||||||
|
"coordinates": loc.coordinates,
|
||||||
|
"assignment": {
|
||||||
|
"id": assignment.id,
|
||||||
|
"assigned_at": assignment.assigned_at.isoformat() if assignment.assigned_at else None,
|
||||||
|
"notes": assignment.notes,
|
||||||
|
} if assignment else None,
|
||||||
|
"unit": unit_payload,
|
||||||
|
"modem": modem_payload,
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
@router.post("/locations/create")
|
@router.post("/locations/create")
|
||||||
async def create_location(
|
async def create_location(
|
||||||
project_id: str,
|
project_id: str,
|
||||||
@@ -645,6 +724,19 @@ async def assign_unit_to_location(
|
|||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Assign a unit to a monitoring location.
|
Assign a unit to a monitoring location.
|
||||||
|
|
||||||
|
Accepts form fields:
|
||||||
|
- unit_id — required
|
||||||
|
- assigned_at — optional ISO datetime; defaults to now. Set this
|
||||||
|
when backfilling a historical deployment whose
|
||||||
|
events landed in the orphan bucket.
|
||||||
|
- assigned_until — optional ISO datetime; absent = open-ended /
|
||||||
|
active.
|
||||||
|
- notes — optional free text
|
||||||
|
|
||||||
|
Refuses only when the *new window would overlap* an existing active
|
||||||
|
open-ended assignment at the same location. Closed historical windows
|
||||||
|
that don't overlap are allowed (and required for orphan-event backfill).
|
||||||
"""
|
"""
|
||||||
location = db.query(MonitoringLocation).filter_by(
|
location = db.query(MonitoringLocation).filter_by(
|
||||||
id=location_id,
|
id=location_id,
|
||||||
@@ -670,23 +762,50 @@ async def assign_unit_to_location(
|
|||||||
detail=f"Unit type '{unit.device_type}' does not match location type '{location.location_type}'",
|
detail=f"Unit type '{unit.device_type}' does not match location type '{location.location_type}'",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if location already has an active assignment (active = assigned_until IS NULL)
|
# Parse dates. Naive datetimes from datetime-local inputs are
|
||||||
existing_assignment = db.query(UnitAssignment).filter(
|
# interpreted as user-local and converted to UTC for storage; explicit
|
||||||
and_(
|
# tz-aware ISO strings (Z / +00:00) skip the conversion.
|
||||||
UnitAssignment.location_id == location_id,
|
def _parse_user_dt(s: str | None, field: str):
|
||||||
UnitAssignment.assigned_until == None,
|
if not s:
|
||||||
)
|
return None
|
||||||
).first()
|
try:
|
||||||
|
parsed = datetime.fromisoformat(s)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid {field}: {s!r}")
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
return local_to_utc(parsed)
|
||||||
|
return parsed.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
|
||||||
|
|
||||||
if existing_assignment:
|
assigned_at = _parse_user_dt(form_data.get("assigned_at"), "assigned_at") or datetime.utcnow()
|
||||||
|
assigned_until = _parse_user_dt(form_data.get("assigned_until"), "assigned_until")
|
||||||
|
if assigned_until is not None and assigned_until <= assigned_at:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400, detail="assigned_until must be after assigned_at.",
|
||||||
detail=f"Location already has an active unit assignment ({existing_assignment.unit_id}). Use swap to replace it.",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create new assignment
|
# Reject only if the new window overlaps an existing assignment at the
|
||||||
assigned_until_str = form_data.get("assigned_until")
|
# SAME location. Closed historical windows that sit before the current
|
||||||
assigned_until = datetime.fromisoformat(assigned_until_str) if assigned_until_str else None
|
# active assignment are fine — that's the backfill case.
|
||||||
|
new_end_for_overlap = assigned_until or datetime.utcnow()
|
||||||
|
existing = db.query(UnitAssignment).filter(
|
||||||
|
UnitAssignment.location_id == location_id
|
||||||
|
).all()
|
||||||
|
for other in existing:
|
||||||
|
other_start = other.assigned_at
|
||||||
|
other_end = other.assigned_until or datetime.utcnow()
|
||||||
|
if assigned_at < other_end and new_end_for_overlap > other_start:
|
||||||
|
other_window = (
|
||||||
|
f"{other.assigned_at:%Y-%m-%d}"
|
||||||
|
+ (f" → {other.assigned_until:%Y-%m-%d}" if other.assigned_until else " → present")
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=(
|
||||||
|
f"New window overlaps an existing assignment at this "
|
||||||
|
f"location ({other.unit_id} {other_window}). Use swap or "
|
||||||
|
f"edit that record instead."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
assignment = UnitAssignment(
|
assignment = UnitAssignment(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
@@ -694,8 +813,9 @@ async def assign_unit_to_location(
|
|||||||
location_id=location_id,
|
location_id=location_id,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
device_type=unit.device_type,
|
device_type=unit.device_type,
|
||||||
|
assigned_at=assigned_at,
|
||||||
assigned_until=assigned_until,
|
assigned_until=assigned_until,
|
||||||
status="active",
|
status="active" if assigned_until is None else "completed",
|
||||||
notes=form_data.get("notes"),
|
notes=form_data.get("notes"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -752,6 +872,20 @@ async def unassign_unit(
|
|||||||
assignment.status = "completed"
|
assignment.status = "completed"
|
||||||
assignment.assigned_until = datetime.utcnow()
|
assignment.assigned_until = datetime.utcnow()
|
||||||
|
|
||||||
|
# Unit is leaving the field — bench it so the heartbeat / polling
|
||||||
|
# subsystem stops chasing it. Also break the modem pairing both ways.
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
||||||
|
if unit:
|
||||||
|
if unit.deployed_with_modem_id:
|
||||||
|
modem = db.query(RosterUnit).filter_by(
|
||||||
|
id=unit.deployed_with_modem_id, device_type="modem"
|
||||||
|
).first()
|
||||||
|
if modem and modem.deployed_with_unit_id == unit.id:
|
||||||
|
modem.deployed_with_unit_id = None
|
||||||
|
unit.deployed_with_modem_id = None
|
||||||
|
if unit.deployed:
|
||||||
|
unit.deployed = False
|
||||||
|
|
||||||
location = db.query(MonitoringLocation).filter_by(id=assignment.location_id).first()
|
location = db.query(MonitoringLocation).filter_by(id=assignment.location_id).first()
|
||||||
_record_assignment_history(
|
_record_assignment_history(
|
||||||
db,
|
db,
|
||||||
@@ -786,6 +920,11 @@ async def update_assignment(
|
|||||||
- assigned_until: ISO datetime, or null/"" to mark indefinite (active)
|
- assigned_until: ISO datetime, or null/"" to mark indefinite (active)
|
||||||
- notes: string
|
- notes: string
|
||||||
|
|
||||||
|
Naive datetimes (no tz suffix) are interpreted as the user's
|
||||||
|
configured timezone and converted to UTC for storage. Send an
|
||||||
|
explicit "+00:00" / "Z" suffix to skip the conversion (programmatic
|
||||||
|
callers that already have UTC).
|
||||||
|
|
||||||
Sets `status` to "active" when assigned_until is cleared, "completed"
|
Sets `status` to "active" when assigned_until is cleared, "completed"
|
||||||
when it's set in the past.
|
when it's set in the past.
|
||||||
"""
|
"""
|
||||||
@@ -816,12 +955,14 @@ async def update_assignment(
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
# Accept "YYYY-MM-DDTHH:MM" from datetime-local inputs or full ISO.
|
# Accept "YYYY-MM-DDTHH:MM" from datetime-local inputs or full ISO.
|
||||||
new_assigned_at = datetime.fromisoformat(raw)
|
parsed = datetime.fromisoformat(raw)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"Invalid assigned_at datetime: {raw!r}",
|
detail=f"Invalid assigned_at datetime: {raw!r}",
|
||||||
)
|
)
|
||||||
|
# Naive (no tz) → treat as user's local time and store as UTC.
|
||||||
|
new_assigned_at = local_to_utc(parsed) if parsed.tzinfo is None else parsed.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
|
||||||
|
|
||||||
if "assigned_until" in payload:
|
if "assigned_until" in payload:
|
||||||
raw = payload["assigned_until"]
|
raw = payload["assigned_until"]
|
||||||
@@ -829,12 +970,13 @@ async def update_assignment(
|
|||||||
new_assigned_until = None
|
new_assigned_until = None
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
new_assigned_until = datetime.fromisoformat(raw)
|
parsed = datetime.fromisoformat(raw)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"Invalid assigned_until datetime: {raw!r}",
|
detail=f"Invalid assigned_until datetime: {raw!r}",
|
||||||
)
|
)
|
||||||
|
new_assigned_until = local_to_utc(parsed) if parsed.tzinfo is None else parsed.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
|
||||||
|
|
||||||
if "notes" in payload:
|
if "notes" in payload:
|
||||||
raw = payload["notes"]
|
raw = payload["notes"]
|
||||||
@@ -1192,6 +1334,22 @@ async def swap_unit_on_location(
|
|||||||
new_value=f"swapped out → {unit_id}",
|
new_value=f"swapped out → {unit_id}",
|
||||||
notes=notes,
|
notes=notes,
|
||||||
)
|
)
|
||||||
|
# Clear the outgoing unit's modem pairing so the bidirectional
|
||||||
|
# deployed_with_modem_id / deployed_with_unit_id back-reference
|
||||||
|
# doesn't orphan onto the unit that just left the field.
|
||||||
|
old_unit = db.query(RosterUnit).filter_by(id=current.unit_id).first()
|
||||||
|
if old_unit:
|
||||||
|
if old_unit.deployed_with_modem_id:
|
||||||
|
old_modem = db.query(RosterUnit).filter_by(
|
||||||
|
id=old_unit.deployed_with_modem_id, device_type="modem"
|
||||||
|
).first()
|
||||||
|
if old_modem and old_modem.deployed_with_unit_id == current.unit_id:
|
||||||
|
old_modem.deployed_with_unit_id = None
|
||||||
|
old_unit.deployed_with_modem_id = None
|
||||||
|
# Bench the outgoing unit — it's no longer in the field, so
|
||||||
|
# the heartbeat / polling subsystem should stop chasing it.
|
||||||
|
if old_unit.deployed:
|
||||||
|
old_unit.deployed = False
|
||||||
|
|
||||||
# Create new assignment
|
# Create new assignment
|
||||||
new_assignment = UnitAssignment(
|
new_assignment = UnitAssignment(
|
||||||
@@ -1218,12 +1376,28 @@ async def swap_unit_on_location(
|
|||||||
modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first()
|
modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first()
|
||||||
if not modem:
|
if not modem:
|
||||||
raise HTTPException(status_code=404, detail=f"Modem '{modem_id}' not found")
|
raise HTTPException(status_code=404, detail=f"Modem '{modem_id}' not found")
|
||||||
|
# Symmetric cleanup: if this modem still claims a previous partner
|
||||||
|
# (a different seismograph whose deployed_with_modem_id never got
|
||||||
|
# cleared in a past swap), break that stale link before re-pairing.
|
||||||
|
if modem.deployed_with_unit_id and modem.deployed_with_unit_id != unit_id:
|
||||||
|
prev_partner = db.query(RosterUnit).filter_by(id=modem.deployed_with_unit_id).first()
|
||||||
|
if prev_partner and prev_partner.deployed_with_modem_id == modem_id:
|
||||||
|
prev_partner.deployed_with_modem_id = None
|
||||||
unit.deployed_with_modem_id = modem_id
|
unit.deployed_with_modem_id = modem_id
|
||||||
modem.deployed_with_unit_id = unit_id
|
modem.deployed_with_unit_id = unit_id
|
||||||
|
# If the modem was on the bench, swapping it into the field puts it
|
||||||
|
# back in rotation.
|
||||||
|
if not modem.deployed:
|
||||||
|
modem.deployed = True
|
||||||
else:
|
else:
|
||||||
# Clear modem pairing if not provided
|
# Clear modem pairing if not provided
|
||||||
unit.deployed_with_modem_id = None
|
unit.deployed_with_modem_id = None
|
||||||
|
|
||||||
|
# If the incoming unit was benched, putting it in the field flips it
|
||||||
|
# back to deployed (so polling / dashboards see it as in rotation).
|
||||||
|
if not unit.deployed:
|
||||||
|
unit.deployed = True
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return JSONResponse({
|
return JSONResponse({
|
||||||
@@ -1241,23 +1415,32 @@ async def swap_unit_on_location(
|
|||||||
async def get_available_modems(
|
async def get_available_modems(
|
||||||
project_id: str,
|
project_id: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
include_benched: bool = Query(False),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get all deployed, non-retired modems for the modem assignment dropdown.
|
Get all non-retired modems for the modem assignment dropdown.
|
||||||
|
|
||||||
|
By default only deployed (in-rotation) modems are returned, preserving
|
||||||
|
the existing behavior for callers like the location-detail swap modal.
|
||||||
|
Pass ``include_benched=true`` to also include benched modems
|
||||||
|
(``RosterUnit.deployed == False``) — useful when picking a modem to
|
||||||
|
pull off the bench for a field swap. Each row's ``deployed`` flag is
|
||||||
|
returned so the UI can badge benched candidates.
|
||||||
"""
|
"""
|
||||||
modems = db.query(RosterUnit).filter(
|
filters = [
|
||||||
and_(
|
RosterUnit.device_type == "modem",
|
||||||
RosterUnit.device_type == "modem",
|
RosterUnit.retired == False,
|
||||||
RosterUnit.deployed == True,
|
]
|
||||||
RosterUnit.retired == False,
|
if not include_benched:
|
||||||
)
|
filters.append(RosterUnit.deployed == True)
|
||||||
).order_by(RosterUnit.id).all()
|
modems = db.query(RosterUnit).filter(and_(*filters)).order_by(RosterUnit.id).all()
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"id": m.id,
|
"id": m.id,
|
||||||
"hardware_model": m.hardware_model,
|
"hardware_model": m.hardware_model,
|
||||||
"ip_address": m.ip_address,
|
"ip_address": m.ip_address,
|
||||||
|
"deployed": bool(m.deployed),
|
||||||
}
|
}
|
||||||
for m in modems
|
for m in modems
|
||||||
]
|
]
|
||||||
@@ -1268,22 +1451,31 @@ async def get_available_units(
|
|||||||
project_id: str,
|
project_id: str,
|
||||||
location_type: str = Query(...),
|
location_type: str = Query(...),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
include_benched: bool = Query(False),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get list of available units for assignment to a location.
|
Get list of available units for assignment to a location.
|
||||||
Filters by device type matching the location type.
|
Filters by device type matching the location type.
|
||||||
|
|
||||||
|
By default only deployed (in-rotation) units are returned, preserving
|
||||||
|
the existing location-detail swap-modal behavior. Pass
|
||||||
|
``include_benched=true`` to also include benched units
|
||||||
|
(``RosterUnit.deployed == False``) — exactly the candidates you'd
|
||||||
|
pull off the bench for a field swap. Each row carries a ``deployed``
|
||||||
|
flag so the UI can badge benched picks.
|
||||||
"""
|
"""
|
||||||
# Determine required device type
|
# Determine required device type
|
||||||
required_device_type = "slm" if location_type == "sound" else "seismograph"
|
required_device_type = "slm" if location_type == "sound" else "seismograph"
|
||||||
|
|
||||||
# Get all units of the required type that are deployed and not retired
|
# Get all units of the required type that aren't retired (and optionally
|
||||||
all_units = db.query(RosterUnit).filter(
|
# exclude benched units).
|
||||||
and_(
|
filters = [
|
||||||
RosterUnit.device_type == required_device_type,
|
RosterUnit.device_type == required_device_type,
|
||||||
RosterUnit.deployed == True,
|
RosterUnit.retired == False,
|
||||||
RosterUnit.retired == False,
|
]
|
||||||
)
|
if not include_benched:
|
||||||
).all()
|
filters.append(RosterUnit.deployed == True)
|
||||||
|
all_units = db.query(RosterUnit).filter(and_(*filters)).all()
|
||||||
|
|
||||||
# Filter out units that already have active assignments (active = assigned_until IS NULL)
|
# Filter out units that already have active assignments (active = assigned_until IS NULL)
|
||||||
assigned_unit_ids = db.query(UnitAssignment.unit_id).filter(
|
assigned_unit_ids = db.query(UnitAssignment.unit_id).filter(
|
||||||
@@ -1297,6 +1489,7 @@ async def get_available_units(
|
|||||||
"device_type": unit.device_type,
|
"device_type": unit.device_type,
|
||||||
"location": unit.address or unit.location,
|
"location": unit.address or unit.location,
|
||||||
"model": unit.slm_model if unit.device_type == "slm" else unit.unit_type,
|
"model": unit.slm_model if unit.device_type == "slm" else unit.unit_type,
|
||||||
|
"deployed": bool(unit.deployed),
|
||||||
}
|
}
|
||||||
for unit in all_units
|
for unit in all_units
|
||||||
if unit.id not in assigned_unit_ids
|
if unit.id not in assigned_unit_ids
|
||||||
|
|||||||
@@ -39,9 +39,35 @@ from backend.services.sfm_events import (
|
|||||||
_fetch_events_for_serial,
|
_fetch_events_for_serial,
|
||||||
_iso_utc,
|
_iso_utc,
|
||||||
)
|
)
|
||||||
|
from backend.utils.timezone import utc_to_local
|
||||||
|
|
||||||
log = logging.getLogger("backend.services.deployment_timeline")
|
log = logging.getLogger("backend.services.deployment_timeline")
|
||||||
|
|
||||||
|
|
||||||
|
def _iso_local(dt) -> Optional[str]:
|
||||||
|
"""Serialize a datetime / ISO-string in the user's configured timezone.
|
||||||
|
|
||||||
|
The timeline frontend slices these strings to character 19 to produce
|
||||||
|
"YYYY-MM-DD HH:MM:SS" — no JS-side timezone conversion happens. We
|
||||||
|
therefore emit *already-local* timestamps here so the displayed time
|
||||||
|
matches what the operator actually saw on the wall clock.
|
||||||
|
|
||||||
|
Accepts either a ``datetime`` (DB column) or an ISO ``str`` (SFM
|
||||||
|
response). Returns ``None`` for ``None`` input. Naive ISO strings
|
||||||
|
from SFM are interpreted as UTC.
|
||||||
|
"""
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
if isinstance(dt, str):
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(dt.replace("Z", "").replace(" ", "T"))
|
||||||
|
except ValueError:
|
||||||
|
return dt # give up gracefully — emit whatever SFM sent
|
||||||
|
local = utc_to_local(dt)
|
||||||
|
if local is None:
|
||||||
|
return None
|
||||||
|
return local.replace(tzinfo=None).isoformat()
|
||||||
|
|
||||||
# Don't emit synthetic gap entries shorter than this (seconds). Avoids visual
|
# Don't emit synthetic gap entries shorter than this (seconds). Avoids visual
|
||||||
# clutter from a sub-second handoff during a swap workflow.
|
# clutter from a sub-second handoff during a swap workflow.
|
||||||
_MIN_GAP_SECONDS = 24 * 3600 # 1 day
|
_MIN_GAP_SECONDS = 24 * 3600 # 1 day
|
||||||
@@ -185,8 +211,8 @@ async def deployment_timeline_for_unit(
|
|||||||
overlays[a.id] = {
|
overlays[a.id] = {
|
||||||
"event_count": len(events),
|
"event_count": len(events),
|
||||||
"peak_pvs": peak,
|
"peak_pvs": peak,
|
||||||
"peak_pvs_at": peak_at,
|
"peak_pvs_at": _iso_local(peak_at),
|
||||||
"last_event": last_ev,
|
"last_event": _iso_local(last_ev),
|
||||||
}
|
}
|
||||||
|
|
||||||
# 4. Build entries. Start by emitting assignment rows + gap rows between
|
# 4. Build entries. Start by emitting assignment rows + gap rows between
|
||||||
@@ -202,8 +228,8 @@ async def deployment_timeline_for_unit(
|
|||||||
|
|
||||||
entry = {
|
entry = {
|
||||||
"kind": "assignment",
|
"kind": "assignment",
|
||||||
"starts_at": _iso_utc(a.assigned_at),
|
"starts_at": _iso_local(a.assigned_at),
|
||||||
"ends_at": _iso_utc(a.assigned_until),
|
"ends_at": _iso_local(a.assigned_until),
|
||||||
"duration_days": round(duration_days, 1) if duration_days is not None else None,
|
"duration_days": round(duration_days, 1) if duration_days is not None else None,
|
||||||
"assignment_id": a.id,
|
"assignment_id": a.id,
|
||||||
"location_id": a.location_id,
|
"location_id": a.location_id,
|
||||||
@@ -227,8 +253,8 @@ async def deployment_timeline_for_unit(
|
|||||||
if gap_seconds >= _MIN_GAP_SECONDS:
|
if gap_seconds >= _MIN_GAP_SECONDS:
|
||||||
entries.append({
|
entries.append({
|
||||||
"kind": "gap",
|
"kind": "gap",
|
||||||
"starts_at": _iso_utc(gap_start),
|
"starts_at": _iso_local(gap_start),
|
||||||
"ends_at": _iso_utc(gap_end),
|
"ends_at": _iso_local(gap_end),
|
||||||
"duration_days": round(gap_seconds / 86400, 1),
|
"duration_days": round(gap_seconds / 86400, 1),
|
||||||
"context": "between assignments",
|
"context": "between assignments",
|
||||||
})
|
})
|
||||||
@@ -241,7 +267,7 @@ async def deployment_timeline_for_unit(
|
|||||||
continue
|
continue
|
||||||
entries.append({
|
entries.append({
|
||||||
"kind": "state_change",
|
"kind": "state_change",
|
||||||
"starts_at": _iso_utc(h.changed_at),
|
"starts_at": _iso_local(h.changed_at),
|
||||||
"ends_at": None,
|
"ends_at": None,
|
||||||
"duration_days": None,
|
"duration_days": None,
|
||||||
"change_type": h.change_type,
|
"change_type": h.change_type,
|
||||||
|
|||||||
@@ -0,0 +1,728 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Unit Swap - Seismo Fleet Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-md mx-auto">
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Unit Swap</h1>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Swap a vibration unit (and modem) at a monitoring location.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a href="/tools" class="text-xs text-seismo-orange hover:text-seismo-burgundy whitespace-nowrap">← Tools</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stepper -->
|
||||||
|
<div class="flex items-center justify-between mb-4 text-[11px] text-gray-500 dark:text-gray-400">
|
||||||
|
<div id="swap-pill-1" class="flex items-center gap-1 text-seismo-orange font-medium">
|
||||||
|
<span class="w-5 h-5 rounded-full bg-seismo-orange text-white inline-flex items-center justify-center text-[10px]">1</span>
|
||||||
|
Project
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-700 mx-1"></div>
|
||||||
|
<div id="swap-pill-2" class="flex items-center gap-1">
|
||||||
|
<span class="w-5 h-5 rounded-full bg-gray-200 dark:bg-gray-700 inline-flex items-center justify-center text-[10px]">2</span>
|
||||||
|
Location
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-700 mx-1"></div>
|
||||||
|
<div id="swap-pill-3" class="flex items-center gap-1">
|
||||||
|
<span class="w-5 h-5 rounded-full bg-gray-200 dark:bg-gray-700 inline-flex items-center justify-center text-[10px]">3</span>
|
||||||
|
Unit
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-700 mx-1"></div>
|
||||||
|
<div id="swap-pill-4" class="flex items-center gap-1">
|
||||||
|
<span class="w-5 h-5 rounded-full bg-gray-200 dark:bg-gray-700 inline-flex items-center justify-center text-[10px]">4</span>
|
||||||
|
Modem
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-700 mx-1"></div>
|
||||||
|
<div id="swap-pill-5" class="flex items-center gap-1">
|
||||||
|
<span class="w-5 h-5 rounded-full bg-gray-200 dark:bg-gray-700 inline-flex items-center justify-center text-[10px]">5</span>
|
||||||
|
Confirm
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 1: Project picker -->
|
||||||
|
<div id="swap-step-1" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-3">
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Which project?</span>
|
||||||
|
<input id="swap-project-search" type="search" autocomplete="off"
|
||||||
|
placeholder="Filter by number, client, or name…"
|
||||||
|
class="mt-2 w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||||
|
</label>
|
||||||
|
<div id="swap-project-list" class="max-h-96 overflow-y-auto space-y-1.5"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: Location picker -->
|
||||||
|
<div id="swap-step-2" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Project</p>
|
||||||
|
<p class="font-semibold text-seismo-orange truncate" id="swap-project-label">—</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="swapGoToStep(1)" class="text-xs text-gray-500 hover:text-seismo-orange whitespace-nowrap">Change</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Which location?</p>
|
||||||
|
<div id="swap-location-list" class="max-h-96 overflow-y-auto space-y-1.5"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3: New unit picker -->
|
||||||
|
<div id="swap-step-3" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Swapping at</p>
|
||||||
|
<p class="font-semibold text-seismo-orange truncate" id="swap-location-label">—</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Out: <span id="swap-old-unit-label" class="font-mono">—</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="swapGoToStep(2)" class="text-xs text-gray-500 hover:text-seismo-orange whitespace-nowrap">Change</button>
|
||||||
|
</div>
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Incoming unit</span>
|
||||||
|
<input id="swap-unit-search" type="search" autocomplete="off"
|
||||||
|
placeholder="Filter by serial…"
|
||||||
|
class="mt-2 w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Only seismographs without an active assignment.</p>
|
||||||
|
</label>
|
||||||
|
<div id="swap-unit-list" class="max-h-72 overflow-y-auto space-y-1.5"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 4: Modem decision -->
|
||||||
|
<div id="swap-step-4" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Incoming</p>
|
||||||
|
<p class="font-mono font-semibold text-seismo-orange" id="swap-new-unit-label">—</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="swapGoToStep(3)" class="text-xs text-gray-500 hover:text-seismo-orange whitespace-nowrap">Change</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2" id="swap-modem-question">Modem?</p>
|
||||||
|
<div id="swap-modem-choice-list" class="space-y-2"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="swap-modem-picker-wrap" class="hidden">
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Pick a modem</span>
|
||||||
|
<input id="swap-modem-search" type="search" autocomplete="off"
|
||||||
|
placeholder="Filter modems…"
|
||||||
|
class="mt-2 w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
|
||||||
|
</label>
|
||||||
|
<div id="swap-modem-list" class="max-h-60 overflow-y-auto space-y-1.5 mt-2"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="swap-modem-next"
|
||||||
|
onclick="swapAfterModem()"
|
||||||
|
class="w-full px-4 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium text-base disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
disabled>
|
||||||
|
Continue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 5: Review + confirm -->
|
||||||
|
<div id="swap-step-5" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Review the swap</h3>
|
||||||
|
|
||||||
|
<dl class="text-sm space-y-2">
|
||||||
|
<div class="flex justify-between gap-2">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Project</dt>
|
||||||
|
<dd class="text-right text-gray-900 dark:text-white font-medium truncate" id="swap-review-project">—</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-2">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Location</dt>
|
||||||
|
<dd class="text-right text-gray-900 dark:text-white font-medium truncate" id="swap-review-location">—</dd>
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-2 flex justify-between gap-2">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Unit out</dt>
|
||||||
|
<dd class="text-right font-mono text-gray-900 dark:text-white" id="swap-review-old-unit">—</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-2">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Unit in</dt>
|
||||||
|
<dd class="text-right font-mono text-seismo-orange font-semibold" id="swap-review-new-unit">—</dd>
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-2 flex justify-between gap-2">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Modem out</dt>
|
||||||
|
<dd class="text-right font-mono text-gray-900 dark:text-white" id="swap-review-old-modem">—</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-2">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Modem in</dt>
|
||||||
|
<dd class="text-right font-mono text-seismo-orange font-semibold" id="swap-review-new-modem">—</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Notes (optional)</span>
|
||||||
|
<textarea id="swap-notes" rows="2"
|
||||||
|
placeholder="Reason for swap, anything to remember…"
|
||||||
|
class="mt-2 w-full px-3 py-2 text-base border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white"></textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div id="swap-error" class="hidden text-sm text-red-600"></div>
|
||||||
|
|
||||||
|
<button id="swap-confirm-btn"
|
||||||
|
onclick="swapConfirm()"
|
||||||
|
class="w-full px-4 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium text-base">
|
||||||
|
Confirm swap
|
||||||
|
</button>
|
||||||
|
<button onclick="swapGoToStep(4)" class="w-full text-sm text-gray-500 hover:text-seismo-orange">← Back</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 6: Success + optional photo -->
|
||||||
|
<div id="swap-step-done" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 space-y-4">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="w-14 h-14 mx-auto rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||||
|
<svg class="w-8 h-8 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-3">Swap complete</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1" id="swap-done-summary">—</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
|
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">Add a photo of the new install?</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Optional. EXIF GPS will populate the unit's coordinates.</p>
|
||||||
|
<input id="swap-photo-input" type="file" accept="image/*" capture="environment"
|
||||||
|
onchange="swapOnPhotoPicked(event)"
|
||||||
|
class="mt-2 w-full text-sm text-gray-500 file:mr-4 file:py-3 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-seismo-orange file:text-white hover:file:bg-orange-600">
|
||||||
|
<div id="swap-photo-preview-wrap" class="hidden mt-3">
|
||||||
|
<img id="swap-photo-preview" class="w-full rounded-lg border border-gray-200 dark:border-gray-700" alt="">
|
||||||
|
</div>
|
||||||
|
<div id="swap-photo-error" class="hidden text-sm text-red-600 mt-2"></div>
|
||||||
|
<div id="swap-photo-status" class="hidden text-sm text-green-600 mt-2"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<button id="swap-photo-upload-btn"
|
||||||
|
onclick="swapUploadPhoto()"
|
||||||
|
class="px-4 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
disabled>
|
||||||
|
Upload photo
|
||||||
|
</button>
|
||||||
|
<button onclick="swapReset()"
|
||||||
|
class="px-4 py-3 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg font-medium">
|
||||||
|
Done — another swap
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const _swap = {
|
||||||
|
step: 1,
|
||||||
|
project: null, // { id, display, ... }
|
||||||
|
location: null, // { id, name, unit, modem }
|
||||||
|
new_unit: null, // { id, ... }
|
||||||
|
modem_action: null, // 'keep' | 'swap' | 'remove' | 'add' | 'none'
|
||||||
|
new_modem: null, // { id, ... }
|
||||||
|
all_projects: [],
|
||||||
|
all_units: [],
|
||||||
|
all_modems: [],
|
||||||
|
swap_result: null,
|
||||||
|
photo_file: null,
|
||||||
|
photo_preview_url: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function _esc(s) {
|
||||||
|
if (s == null) return '';
|
||||||
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _badge(deployed) {
|
||||||
|
return deployed
|
||||||
|
? '<span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">deployed</span>'
|
||||||
|
: '<span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">benched</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function swapGoToStep(n) {
|
||||||
|
_swap.step = n;
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
const el = document.getElementById('swap-step-' + i);
|
||||||
|
if (el) el.classList.toggle('hidden', i !== n);
|
||||||
|
const pill = document.getElementById('swap-pill-' + i);
|
||||||
|
if (!pill) continue;
|
||||||
|
const dot = pill.querySelector('span');
|
||||||
|
if (i === n) {
|
||||||
|
pill.classList.remove('text-gray-500', 'dark:text-gray-400');
|
||||||
|
pill.classList.add('text-seismo-orange', 'font-medium');
|
||||||
|
dot.classList.remove('bg-gray-200', 'dark:bg-gray-700');
|
||||||
|
dot.classList.add('bg-seismo-orange', 'text-white');
|
||||||
|
} else if (i < n) {
|
||||||
|
pill.classList.remove('text-gray-500', 'dark:text-gray-400');
|
||||||
|
pill.classList.add('text-green-600', 'dark:text-green-400');
|
||||||
|
dot.classList.remove('bg-gray-200', 'dark:bg-gray-700', 'bg-seismo-orange', 'text-white');
|
||||||
|
dot.classList.add('bg-green-100', 'dark:bg-green-900/30', 'text-green-700', 'dark:text-green-300');
|
||||||
|
} else {
|
||||||
|
pill.classList.add('text-gray-500', 'dark:text-gray-400');
|
||||||
|
pill.classList.remove('text-seismo-orange', 'font-medium', 'text-green-600', 'dark:text-green-400');
|
||||||
|
dot.classList.add('bg-gray-200', 'dark:bg-gray-700');
|
||||||
|
dot.classList.remove('bg-seismo-orange', 'text-white', 'bg-green-100', 'dark:bg-green-900/30', 'text-green-700', 'dark:text-green-300');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.getElementById('swap-step-done').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 1: project picker ──────────────────────────────────────────
|
||||||
|
async function _swapLoadProjects() {
|
||||||
|
const list = document.getElementById('swap-project-list');
|
||||||
|
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">Loading…</p>';
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/projects/search-json?limit=50');
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
_swap.all_projects = await r.json();
|
||||||
|
_swapRenderProjects();
|
||||||
|
} catch (e) {
|
||||||
|
list.innerHTML = `<p class="text-sm text-red-500 px-3 py-2">Load failed: ${_esc(e.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _swapRenderProjects() {
|
||||||
|
const q = document.getElementById('swap-project-search').value.trim().toLowerCase();
|
||||||
|
const list = document.getElementById('swap-project-list');
|
||||||
|
let items = _swap.all_projects;
|
||||||
|
if (q) {
|
||||||
|
items = items.filter(p => {
|
||||||
|
const hay = [(p.project_number||''), (p.client_name||''), (p.name||''), (p.display||'')]
|
||||||
|
.join(' ').toLowerCase();
|
||||||
|
return hay.includes(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (items.length === 0) {
|
||||||
|
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">No matching projects.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = items.map(p => {
|
||||||
|
const num = p.project_number ? `<span class="font-mono text-xs text-gray-500 dark:text-gray-400">${_esc(p.project_number)}</span>` : '';
|
||||||
|
const client = p.client_name ? `<div class="text-xs text-gray-500 dark:text-gray-400 truncate">${_esc(p.client_name)}</div>` : '';
|
||||||
|
return `<button onclick='swapPickProject(${JSON.stringify(p.id)})'
|
||||||
|
class="w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-white truncate">${_esc(p.name)}</span>
|
||||||
|
${num}
|
||||||
|
</div>
|
||||||
|
${client}
|
||||||
|
</button>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function swapPickProject(projectId) {
|
||||||
|
const p = _swap.all_projects.find(x => x.id === projectId);
|
||||||
|
if (!p) return;
|
||||||
|
_swap.project = p;
|
||||||
|
document.getElementById('swap-project-label').textContent = p.name;
|
||||||
|
_swapLoadLocations();
|
||||||
|
swapGoToStep(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 2: location picker ─────────────────────────────────────────
|
||||||
|
async function _swapLoadLocations() {
|
||||||
|
const list = document.getElementById('swap-location-list');
|
||||||
|
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">Loading…</p>';
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/projects/${encodeURIComponent(_swap.project.id)}/locations-with-assignments?location_type=vibration`);
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
const data = await r.json();
|
||||||
|
_swapRenderLocations(data);
|
||||||
|
} catch (e) {
|
||||||
|
list.innerHTML = `<p class="text-sm text-red-500 px-3 py-2">Load failed: ${_esc(e.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _swapRenderLocations(locations) {
|
||||||
|
const list = document.getElementById('swap-location-list');
|
||||||
|
if (!locations || locations.length === 0) {
|
||||||
|
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">No vibration locations in this project.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = locations.map(loc => {
|
||||||
|
const unit = loc.unit;
|
||||||
|
const modem = loc.modem;
|
||||||
|
const unitLine = unit
|
||||||
|
? `<div class="text-xs text-gray-600 dark:text-gray-300 font-mono">${_esc(unit.id)}<span class="text-gray-400">${unit.unit_type ? ' · ' + _esc(unit.unit_type) : ''}</span></div>`
|
||||||
|
: `<div class="text-xs italic text-gray-400">Empty — first assign</div>`;
|
||||||
|
const modemLine = modem
|
||||||
|
? `<div class="text-[11px] text-gray-500 dark:text-gray-400 font-mono mt-0.5 flex items-center gap-2">
|
||||||
|
<span>+ modem ${_esc(modem.id)}</span>
|
||||||
|
${_badge(modem.deployed)}
|
||||||
|
</div>`
|
||||||
|
: '';
|
||||||
|
// Pass index for cleaner attribute escaping
|
||||||
|
return `<button data-locidx="${locations.indexOf(loc)}" onclick="_swapPickLocationByIdx(this.dataset.locidx)"
|
||||||
|
class="w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-white truncate">${_esc(loc.name)}</span>
|
||||||
|
</div>
|
||||||
|
${unitLine}
|
||||||
|
${modemLine}
|
||||||
|
</button>`;
|
||||||
|
}).join('');
|
||||||
|
_swap._locations_cache = locations;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _swapPickLocationByIdx(idxStr) {
|
||||||
|
const idx = parseInt(idxStr, 10);
|
||||||
|
const loc = _swap._locations_cache[idx];
|
||||||
|
if (!loc) return;
|
||||||
|
_swap.location = loc;
|
||||||
|
document.getElementById('swap-location-label').textContent = loc.name;
|
||||||
|
document.getElementById('swap-old-unit-label').textContent = loc.unit ? loc.unit.id : '(empty)';
|
||||||
|
_swapLoadUnits();
|
||||||
|
swapGoToStep(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 3: incoming unit picker ────────────────────────────────────
|
||||||
|
async function _swapLoadUnits() {
|
||||||
|
const list = document.getElementById('swap-unit-list');
|
||||||
|
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">Loading…</p>';
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/projects/${encodeURIComponent(_swap.project.id)}/available-units?location_type=vibration&include_benched=true`);
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
_swap.all_units = await r.json();
|
||||||
|
_swapRenderUnits();
|
||||||
|
} catch (e) {
|
||||||
|
list.innerHTML = `<p class="text-sm text-red-500 px-3 py-2">Load failed: ${_esc(e.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _swapRenderUnits() {
|
||||||
|
const q = document.getElementById('swap-unit-search').value.trim().toLowerCase();
|
||||||
|
const list = document.getElementById('swap-unit-list');
|
||||||
|
let items = _swap.all_units;
|
||||||
|
if (q) {
|
||||||
|
items = items.filter(u => {
|
||||||
|
const hay = [(u.id||''), (u.model||''), (u.location||'')].join(' ').toLowerCase();
|
||||||
|
return hay.includes(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (items.length === 0) {
|
||||||
|
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">No available seismographs.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = items.map(u => {
|
||||||
|
const model = u.model ? `<span class="text-xs text-gray-500 dark:text-gray-400">${_esc(u.model)}</span>` : '';
|
||||||
|
const loc = u.location ? `<div class="text-xs text-gray-500 dark:text-gray-400 truncate">${_esc(u.location)}</div>` : '';
|
||||||
|
const badge = _badge(u.deployed);
|
||||||
|
return `<button onclick='swapPickUnit(${JSON.stringify(u.id)})'
|
||||||
|
class="w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-mono font-semibold text-gray-900 dark:text-white">${_esc(u.id)}</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
${badge}
|
||||||
|
${model}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${loc}
|
||||||
|
</button>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function swapPickUnit(unitId) {
|
||||||
|
const u = _swap.all_units.find(x => x.id === unitId);
|
||||||
|
if (!u) return;
|
||||||
|
_swap.new_unit = u;
|
||||||
|
document.getElementById('swap-new-unit-label').textContent = u.id;
|
||||||
|
_swapInitModemStep();
|
||||||
|
swapGoToStep(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 4: modem decision ──────────────────────────────────────────
|
||||||
|
function _swapInitModemStep() {
|
||||||
|
_swap.modem_action = null;
|
||||||
|
_swap.new_modem = null;
|
||||||
|
document.getElementById('swap-modem-picker-wrap').classList.add('hidden');
|
||||||
|
document.getElementById('swap-modem-next').disabled = true;
|
||||||
|
|
||||||
|
const current = _swap.location && _swap.location.modem;
|
||||||
|
const choices = document.getElementById('swap-modem-choice-list');
|
||||||
|
const question = document.getElementById('swap-modem-question');
|
||||||
|
|
||||||
|
if (current) {
|
||||||
|
question.textContent = 'Modem at this location';
|
||||||
|
choices.innerHTML = `
|
||||||
|
<button data-action="keep" onclick="swapPickModemAction('keep')"
|
||||||
|
class="swap-modem-choice w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<div class="font-medium text-gray-900 dark:text-white">Keep <span class="font-mono">${_esc(current.id)}</span></div>
|
||||||
|
${_badge(current.deployed)}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">Re-pair the existing modem to the incoming unit.</div>
|
||||||
|
</button>
|
||||||
|
<button data-action="swap" onclick="swapPickModemAction('swap')"
|
||||||
|
class="swap-modem-choice w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
|
||||||
|
<div class="font-medium text-gray-900 dark:text-white">Swap modem too</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">Pick a different unassigned modem.</div>
|
||||||
|
</button>
|
||||||
|
<button data-action="remove" onclick="swapPickModemAction('remove')"
|
||||||
|
class="swap-modem-choice w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
|
||||||
|
<div class="font-medium text-gray-900 dark:text-white">No modem</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">Incoming unit goes solo (no cellular).</div>
|
||||||
|
</button>`;
|
||||||
|
} else {
|
||||||
|
question.textContent = 'No modem at this location currently.';
|
||||||
|
choices.innerHTML = `
|
||||||
|
<button data-action="none" onclick="swapPickModemAction('none')"
|
||||||
|
class="swap-modem-choice w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
|
||||||
|
<div class="font-medium text-gray-900 dark:text-white">No modem</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">Standalone / manual download.</div>
|
||||||
|
</button>
|
||||||
|
<button data-action="add" onclick="swapPickModemAction('add')"
|
||||||
|
class="swap-modem-choice w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors">
|
||||||
|
<div class="font-medium text-gray-900 dark:text-white">Add a modem</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">Pair an unassigned modem with the incoming unit.</div>
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function swapPickModemAction(action) {
|
||||||
|
_swap.modem_action = action;
|
||||||
|
_swap.new_modem = null;
|
||||||
|
// Highlight the picked choice; dim the others.
|
||||||
|
document.querySelectorAll('.swap-modem-choice').forEach(btn => {
|
||||||
|
if (btn.dataset.action === action) {
|
||||||
|
btn.classList.add('border-seismo-orange', 'bg-amber-50', 'dark:bg-amber-900/10');
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('border-seismo-orange', 'bg-amber-50', 'dark:bg-amber-900/10');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const pickerWrap = document.getElementById('swap-modem-picker-wrap');
|
||||||
|
const nextBtn = document.getElementById('swap-modem-next');
|
||||||
|
|
||||||
|
if (action === 'swap' || action === 'add') {
|
||||||
|
pickerWrap.classList.remove('hidden');
|
||||||
|
nextBtn.disabled = true;
|
||||||
|
_swapLoadModems();
|
||||||
|
} else {
|
||||||
|
pickerWrap.classList.add('hidden');
|
||||||
|
nextBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _swapLoadModems() {
|
||||||
|
const list = document.getElementById('swap-modem-list');
|
||||||
|
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">Loading…</p>';
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/projects/${encodeURIComponent(_swap.project.id)}/available-modems?include_benched=true`);
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
// Filter out the modem that's currently at this location (it's the "keep" option, not "swap").
|
||||||
|
let modems = await r.json();
|
||||||
|
const currentModemId = _swap.location && _swap.location.modem ? _swap.location.modem.id : null;
|
||||||
|
if (currentModemId) modems = modems.filter(m => m.id !== currentModemId);
|
||||||
|
_swap.all_modems = modems;
|
||||||
|
_swapRenderModems();
|
||||||
|
} catch (e) {
|
||||||
|
list.innerHTML = `<p class="text-sm text-red-500 px-3 py-2">Load failed: ${_esc(e.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _swapRenderModems() {
|
||||||
|
const q = document.getElementById('swap-modem-search').value.trim().toLowerCase();
|
||||||
|
const list = document.getElementById('swap-modem-list');
|
||||||
|
let items = _swap.all_modems;
|
||||||
|
if (q) {
|
||||||
|
items = items.filter(m => {
|
||||||
|
const hay = [(m.id||''), (m.hardware_model||''), (m.ip_address||'')].join(' ').toLowerCase();
|
||||||
|
return hay.includes(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (items.length === 0) {
|
||||||
|
list.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 px-3 py-2">No modems available.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = items.map(m => {
|
||||||
|
const hw = m.hardware_model ? `<span class="text-xs text-gray-500 dark:text-gray-400">${_esc(m.hardware_model)}</span>` : '';
|
||||||
|
const ip = m.ip_address ? `<div class="text-xs text-gray-500 dark:text-gray-400 font-mono">${_esc(m.ip_address)}</div>` : '';
|
||||||
|
const badge = _badge(m.deployed);
|
||||||
|
return `<button onclick='swapPickModem(${JSON.stringify(m.id)})'
|
||||||
|
class="swap-modem-pick w-full text-left px-3 py-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-seismo-orange transition-colors"
|
||||||
|
data-modem-id="${_esc(m.id)}">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-mono font-semibold text-gray-900 dark:text-white">${_esc(m.id)}</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
${badge}
|
||||||
|
${hw}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${ip}
|
||||||
|
</button>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function swapPickModem(modemId) {
|
||||||
|
const m = _swap.all_modems.find(x => x.id === modemId);
|
||||||
|
if (!m) return;
|
||||||
|
_swap.new_modem = m;
|
||||||
|
document.querySelectorAll('.swap-modem-pick').forEach(btn => {
|
||||||
|
if (btn.dataset.modemId === modemId) {
|
||||||
|
btn.classList.add('border-seismo-orange', 'bg-amber-50', 'dark:bg-amber-900/10');
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('border-seismo-orange', 'bg-amber-50', 'dark:bg-amber-900/10');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('swap-modem-next').disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function swapAfterModem() {
|
||||||
|
// Populate review screen
|
||||||
|
document.getElementById('swap-review-project').textContent = _swap.project.name;
|
||||||
|
document.getElementById('swap-review-location').textContent = _swap.location.name;
|
||||||
|
document.getElementById('swap-review-old-unit').textContent = _swap.location.unit ? _swap.location.unit.id : '(empty)';
|
||||||
|
document.getElementById('swap-review-new-unit').textContent = _swap.new_unit.id;
|
||||||
|
|
||||||
|
const oldModemEl = document.getElementById('swap-review-old-modem');
|
||||||
|
if (_swap.location.modem) {
|
||||||
|
oldModemEl.innerHTML = `${_esc(_swap.location.modem.id)} ${_badge(_swap.location.modem.deployed)}`;
|
||||||
|
} else {
|
||||||
|
oldModemEl.textContent = '(none)';
|
||||||
|
}
|
||||||
|
|
||||||
|
const newModemEl = document.getElementById('swap-review-new-modem');
|
||||||
|
if (_swap.modem_action === 'keep' && _swap.location.modem) {
|
||||||
|
newModemEl.innerHTML = `${_esc(_swap.location.modem.id)} <span class="text-xs text-gray-500">(kept)</span> ${_badge(_swap.location.modem.deployed)}`;
|
||||||
|
} else if ((_swap.modem_action === 'swap' || _swap.modem_action === 'add') && _swap.new_modem) {
|
||||||
|
newModemEl.innerHTML = `${_esc(_swap.new_modem.id)} ${_badge(_swap.new_modem.deployed)}`;
|
||||||
|
} else {
|
||||||
|
newModemEl.textContent = '(none)';
|
||||||
|
}
|
||||||
|
|
||||||
|
swapGoToStep(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 5: confirm ─────────────────────────────────────────────────
|
||||||
|
async function swapConfirm() {
|
||||||
|
const btn = document.getElementById('swap-confirm-btn');
|
||||||
|
const err = document.getElementById('swap-error');
|
||||||
|
err.classList.add('hidden');
|
||||||
|
|
||||||
|
// Determine modem_id to send:
|
||||||
|
// 'keep' → current modem id (re-pair to new unit)
|
||||||
|
// 'swap' → newly-picked modem id
|
||||||
|
// 'add' → newly-picked modem id
|
||||||
|
// 'remove' → omit (endpoint clears new unit's pairing)
|
||||||
|
// 'none' → omit
|
||||||
|
let modemIdToSend = null;
|
||||||
|
if (_swap.modem_action === 'keep' && _swap.location.modem) {
|
||||||
|
modemIdToSend = _swap.location.modem.id;
|
||||||
|
} else if ((_swap.modem_action === 'swap' || _swap.modem_action === 'add') && _swap.new_modem) {
|
||||||
|
modemIdToSend = _swap.new_modem.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Swapping…';
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('unit_id', _swap.new_unit.id);
|
||||||
|
if (modemIdToSend) fd.append('modem_id', modemIdToSend);
|
||||||
|
const notes = document.getElementById('swap-notes').value.trim();
|
||||||
|
if (notes) fd.append('notes', notes);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `/api/projects/${encodeURIComponent(_swap.project.id)}/locations/${encodeURIComponent(_swap.location.id)}/swap`;
|
||||||
|
const r = await fetch(url, { method: 'POST', body: fd });
|
||||||
|
if (!r.ok) {
|
||||||
|
const e = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
||||||
|
throw new Error(e.detail || 'HTTP ' + r.status);
|
||||||
|
}
|
||||||
|
_swap.swap_result = await r.json();
|
||||||
|
_swapShowDone();
|
||||||
|
} catch (e) {
|
||||||
|
err.textContent = e.message;
|
||||||
|
err.classList.remove('hidden');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Confirm swap';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _swapShowDone() {
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
document.getElementById('swap-step-' + i).classList.add('hidden');
|
||||||
|
}
|
||||||
|
document.getElementById('swap-step-done').classList.remove('hidden');
|
||||||
|
const summary = `${_swap.new_unit.id} is now at ${_swap.location.name}` +
|
||||||
|
(_swap.location.unit ? ` (replacing ${_swap.location.unit.id}).` : '.');
|
||||||
|
document.getElementById('swap-done-summary').textContent = summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Photo upload (optional) ─────────────────────────────────────────
|
||||||
|
function swapOnPhotoPicked(e) {
|
||||||
|
const file = e.target.files && e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
_swap.photo_file = file;
|
||||||
|
if (_swap.photo_preview_url) URL.revokeObjectURL(_swap.photo_preview_url);
|
||||||
|
_swap.photo_preview_url = URL.createObjectURL(file);
|
||||||
|
document.getElementById('swap-photo-preview').src = _swap.photo_preview_url;
|
||||||
|
document.getElementById('swap-photo-preview-wrap').classList.remove('hidden');
|
||||||
|
document.getElementById('swap-photo-upload-btn').disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function swapUploadPhoto() {
|
||||||
|
if (!_swap.photo_file) return;
|
||||||
|
const btn = document.getElementById('swap-photo-upload-btn');
|
||||||
|
const err = document.getElementById('swap-photo-error');
|
||||||
|
const ok = document.getElementById('swap-photo-status');
|
||||||
|
err.classList.add('hidden');
|
||||||
|
ok.classList.add('hidden');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Uploading…';
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('photo', _swap.photo_file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `/api/unit/${encodeURIComponent(_swap.new_unit.id)}/upload-photo?auto_populate_coords=true`;
|
||||||
|
const r = await fetch(url, { method: 'POST', body: fd });
|
||||||
|
if (!r.ok) {
|
||||||
|
const e = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
||||||
|
throw new Error(e.detail || 'HTTP ' + r.status);
|
||||||
|
}
|
||||||
|
const data = await r.json();
|
||||||
|
const coords = data && data.metadata && data.metadata.coordinates;
|
||||||
|
ok.textContent = coords ? `Uploaded. GPS: ${coords}` : 'Uploaded (no GPS in EXIF).';
|
||||||
|
ok.classList.remove('hidden');
|
||||||
|
btn.textContent = 'Uploaded';
|
||||||
|
} catch (e) {
|
||||||
|
err.textContent = e.message;
|
||||||
|
err.classList.remove('hidden');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Upload photo';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function swapReset() {
|
||||||
|
if (_swap.photo_preview_url) URL.revokeObjectURL(_swap.photo_preview_url);
|
||||||
|
Object.assign(_swap, {
|
||||||
|
project: null, location: null, new_unit: null,
|
||||||
|
modem_action: null, new_modem: null,
|
||||||
|
all_projects: [], all_units: [], all_modems: [],
|
||||||
|
swap_result: null, photo_file: null, photo_preview_url: null,
|
||||||
|
});
|
||||||
|
document.getElementById('swap-project-search').value = '';
|
||||||
|
document.getElementById('swap-unit-search').value = '';
|
||||||
|
document.getElementById('swap-modem-search').value = '';
|
||||||
|
document.getElementById('swap-notes').value = '';
|
||||||
|
document.getElementById('swap-photo-input').value = '';
|
||||||
|
document.getElementById('swap-photo-preview-wrap').classList.add('hidden');
|
||||||
|
document.getElementById('swap-photo-error').classList.add('hidden');
|
||||||
|
document.getElementById('swap-photo-status').classList.add('hidden');
|
||||||
|
document.getElementById('swap-error').classList.add('hidden');
|
||||||
|
const confirmBtn = document.getElementById('swap-confirm-btn');
|
||||||
|
confirmBtn.disabled = false; confirmBtn.textContent = 'Confirm swap';
|
||||||
|
const photoBtn = document.getElementById('swap-photo-upload-btn');
|
||||||
|
photoBtn.disabled = true; photoBtn.textContent = 'Upload photo';
|
||||||
|
swapGoToStep(1);
|
||||||
|
_swapLoadProjects();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire up live filtering inputs.
|
||||||
|
document.getElementById('swap-project-search').addEventListener('input', _swapRenderProjects);
|
||||||
|
document.getElementById('swap-unit-search').addEventListener('input', _swapRenderUnits);
|
||||||
|
document.getElementById('swap-modem-search').addEventListener('input', _swapRenderModems);
|
||||||
|
|
||||||
|
// Kick off.
|
||||||
|
_swapLoadProjects();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -150,6 +150,7 @@
|
|||||||
(project tidy, metadata backfill, pair devices). #}
|
(project tidy, metadata backfill, pair devices). #}
|
||||||
{% set _is_tools = (
|
{% set _is_tools = (
|
||||||
request.url.path == '/tools'
|
request.url.path == '/tools'
|
||||||
|
or request.url.path.startswith('/tools/')
|
||||||
or request.url.path == '/pair-devices'
|
or request.url.path == '/pair-devices'
|
||||||
or request.url.path == '/settings/developer/project-tidy'
|
or request.url.path == '/settings/developer/project-tidy'
|
||||||
or request.url.path == '/settings/developer/metadata-backfill'
|
or request.url.path == '/settings/developer/metadata-backfill'
|
||||||
|
|||||||
@@ -141,6 +141,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<!-- Unit Swap -->
|
||||||
|
<a href="/tools/unit-swap"
|
||||||
|
class="block bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 hover:shadow-xl transition-shadow border border-transparent hover:border-seismo-orange">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400 flex items-center justify-center shrink-0">
|
||||||
|
<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="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h3 class="font-semibold text-gray-900 dark:text-white">Unit Swap</h3>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Field-swap a unit (and modem) at a vibration location. Pick project → location → incoming unit → confirm. Optional photo of the new install.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
<!-- Swap Detection (Phase 5c — coming soon) -->
|
<!-- Swap Detection (Phase 5c — coming soon) -->
|
||||||
<div class="bg-gray-50 dark:bg-slate-800/50 rounded-xl shadow p-5 border border-dashed border-gray-300 dark:border-gray-700 cursor-not-allowed">
|
<div class="bg-gray-50 dark:bg-slate-800/50 rounded-xl shadow p-5 border border-dashed border-gray-300 dark:border-gray-700 cursor-not-allowed">
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
|
|||||||
+388
-4
@@ -281,11 +281,16 @@
|
|||||||
<!-- Deployment Timeline (Phase 4 unified view — derived from
|
<!-- Deployment Timeline (Phase 4 unified view — derived from
|
||||||
unit_assignments + unit_history + SFM event overlay) -->
|
unit_assignments + unit_history + SFM event overlay) -->
|
||||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center mb-4 flex-wrap gap-2">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Deployment Timeline</h3>
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Deployment Timeline</h3>
|
||||||
<button onclick="loadDeploymentTimeline()" class="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300">
|
<div class="flex items-center gap-2">
|
||||||
↻ Refresh
|
<button onclick="openAddAssignmentModal()" class="px-3 py-1.5 text-sm rounded-lg bg-seismo-orange hover:bg-orange-600 text-white">
|
||||||
</button>
|
+ Add deployment record
|
||||||
|
</button>
|
||||||
|
<button onclick="loadDeploymentTimeline()" class="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300">
|
||||||
|
↻ Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Gantt chart — visual timeline of all deployments. Click
|
<!-- Gantt chart — visual timeline of all deployments. Click
|
||||||
@@ -302,6 +307,119 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit-assignment modal -->
|
||||||
|
<div id="editAssignmentModal" class="hidden fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md max-h-[90vh] overflow-y-auto">
|
||||||
|
<div class="sticky top-0 bg-white dark:bg-slate-800 border-b border-gray-200 dark:border-gray-700 px-5 py-4 flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Edit deployment record</h3>
|
||||||
|
<button onclick="closeEditAssignmentModal()" class="text-2xl text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="p-5 space-y-4">
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<span id="editAssignmentLocation">—</span>
|
||||||
|
<span class="text-xs">·</span>
|
||||||
|
<span id="editAssignmentProject" class="text-xs">—</span>
|
||||||
|
</div>
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Assigned at</span>
|
||||||
|
<input id="editAssignedAt" type="datetime-local" step="60"
|
||||||
|
class="mt-1 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">
|
||||||
|
</label>
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Assigned until</span>
|
||||||
|
<div class="mt-1 flex items-center gap-2">
|
||||||
|
<input id="editAssignedUntil" type="datetime-local" step="60"
|
||||||
|
class="flex-1 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">
|
||||||
|
<label class="inline-flex items-center gap-1 text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||||
|
<input id="editAssignedUntilOpen" type="checkbox" onchange="_toggleEditOpenEnded()">
|
||||||
|
open-ended
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Check "open-ended" to mark this assignment active (no end date).</p>
|
||||||
|
</label>
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Notes</span>
|
||||||
|
<textarea id="editAssignmentNotes" rows="2"
|
||||||
|
class="mt-1 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"></textarea>
|
||||||
|
</label>
|
||||||
|
<div id="editAssignmentError" class="hidden text-sm text-red-600"></div>
|
||||||
|
</div>
|
||||||
|
<div class="sticky bottom-0 bg-white dark:bg-slate-800 border-t border-gray-200 dark:border-gray-700 px-5 py-3 flex items-center justify-between gap-2">
|
||||||
|
<button onclick="deleteAssignmentFromModal()" class="px-3 py-2 text-sm rounded-lg border border-red-300 text-red-700 dark:border-red-700 dark:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick="closeEditAssignmentModal()" class="px-4 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button id="editAssignmentSaveBtn" onclick="saveEditAssignment()" class="px-4 py-2 text-sm rounded-lg bg-seismo-orange hover:bg-orange-600 text-white">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add-historical-assignment modal -->
|
||||||
|
<div id="addAssignmentModal" class="hidden fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md max-h-[90vh] overflow-y-auto">
|
||||||
|
<div class="sticky top-0 bg-white dark:bg-slate-800 border-b border-gray-200 dark:border-gray-700 px-5 py-4 flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Add deployment record</h3>
|
||||||
|
<button onclick="closeAddAssignmentModal()" class="text-2xl text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="p-5 space-y-4">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Create a deployment record for this unit — usually to backfill a historical window so orphan events get attributed.
|
||||||
|
</p>
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Project</span>
|
||||||
|
<select id="addAssignmentProject" onchange="_addAssignmentProjectChanged()"
|
||||||
|
class="mt-1 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">
|
||||||
|
<option value="">Loading…</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Location</span>
|
||||||
|
<select id="addAssignmentLocation"
|
||||||
|
class="mt-1 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" disabled>
|
||||||
|
<option value="">Pick a project first</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Assigned at</span>
|
||||||
|
<input id="addAssignedAt" type="datetime-local" step="60"
|
||||||
|
class="mt-1 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">
|
||||||
|
</label>
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Assigned until</span>
|
||||||
|
<div class="mt-1 flex items-center gap-2">
|
||||||
|
<input id="addAssignedUntil" type="datetime-local" step="60"
|
||||||
|
class="flex-1 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">
|
||||||
|
<label class="inline-flex items-center gap-1 text-xs text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||||
|
<input id="addAssignedUntilOpen" type="checkbox" onchange="_toggleAddOpenEnded()">
|
||||||
|
open-ended
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Notes</span>
|
||||||
|
<textarea id="addAssignmentNotes" rows="2"
|
||||||
|
placeholder="e.g. Backfilled to attribute orphan events 2026-03-16 — 2026-03-25"
|
||||||
|
class="mt-1 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"></textarea>
|
||||||
|
</label>
|
||||||
|
<div id="addAssignmentError" class="hidden text-sm text-red-600"></div>
|
||||||
|
</div>
|
||||||
|
<div class="sticky bottom-0 bg-white dark:bg-slate-800 border-t border-gray-200 dark:border-gray-700 px-5 py-3 flex justify-end gap-2">
|
||||||
|
<button onclick="closeAddAssignmentModal()" class="px-4 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button id="addAssignmentSaveBtn" onclick="saveAddAssignment()" class="px-4 py-2 text-sm rounded-lg bg-seismo-orange hover:bg-orange-600 text-white">
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- SFM Events (seismographs only) -->
|
<!-- SFM Events (seismographs only) -->
|
||||||
<div id="sfmEventsSection" class="border-t border-gray-200 dark:border-gray-700 pt-6 hidden">
|
<div id="sfmEventsSection" class="border-t border-gray-200 dark:border-gray-700 pt-6 hidden">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
@@ -2141,6 +2259,16 @@ function _dtRenderAssignment(e) {
|
|||||||
? `<div class="mt-2 text-xs text-gray-600 dark:text-gray-400 italic">${_dtEsc(e.notes)}</div>`
|
? `<div class="mt-2 text-xs text-gray-600 dark:text-gray-400 italic">${_dtEsc(e.notes)}</div>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
// Edit/delete actions live on the right of the date row. Only shown
|
||||||
|
// for assignment entries with a real assignment_id (synthesized legacy
|
||||||
|
// entries without one are read-only).
|
||||||
|
const actionButtons = e.assignment_id
|
||||||
|
? `<button type="button" onclick='openEditAssignmentModal(${JSON.stringify(e.assignment_id)})'
|
||||||
|
class="text-xs text-gray-500 hover:text-seismo-orange p-1 rounded" title="Edit dates / notes">
|
||||||
|
✏️
|
||||||
|
</button>`
|
||||||
|
: '';
|
||||||
|
|
||||||
return `<div class="flex gap-3 transition-shadow rounded-lg" data-assignment-row="${_dtEsc(e.assignment_id)}">
|
return `<div class="flex gap-3 transition-shadow rounded-lg" data-assignment-row="${_dtEsc(e.assignment_id)}">
|
||||||
<div class="flex flex-col items-center pt-1">
|
<div class="flex flex-col items-center pt-1">
|
||||||
<span class="w-3 h-3 rounded-full ${e.is_active ? 'bg-green-500' : 'bg-seismo-orange'}"></span>
|
<span class="w-3 h-3 rounded-full ${e.is_active ? 'bg-green-500' : 'bg-seismo-orange'}"></span>
|
||||||
@@ -2153,6 +2281,7 @@ function _dtRenderAssignment(e) {
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
${mergeableBadge}
|
${mergeableBadge}
|
||||||
${activeBadge}
|
${activeBadge}
|
||||||
|
${actionButtons}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1">${locLink}</div>
|
<div class="mt-1">${locLink}</div>
|
||||||
@@ -2455,6 +2584,261 @@ function renderDeploymentTimeline(entries, container, mergeGroups) {
|
|||||||
container.innerHTML = bannerHtml + '<div class="space-y-3">' + html + '</div>';
|
container.innerHTML = bannerHtml + '<div class="space-y-3">' + html + '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Deployment-timeline editor ──────────────────────────────────────────────
|
||||||
|
// Edit / delete an existing UnitAssignment row, or create a historical one
|
||||||
|
// (backfill orphan-event windows). All three operations hit endpoints that
|
||||||
|
// already exist on the project-locations router; after a save we just
|
||||||
|
// reload the timeline + events.
|
||||||
|
|
||||||
|
let _editAssignmentCtx = null; // { assignment_id, project_id, location_name, project_name }
|
||||||
|
|
||||||
|
function _iso_to_local_input(iso) {
|
||||||
|
// The PATCH/POST endpoints accept "YYYY-MM-DDTHH:MM[:SS]" strings
|
||||||
|
// (datetime.fromisoformat). datetime-local inputs emit the same shape
|
||||||
|
// without timezone. We just strip the trailing "Z" if present and
|
||||||
|
// truncate to minutes.
|
||||||
|
if (!iso) return '';
|
||||||
|
let s = String(iso).replace('Z', '');
|
||||||
|
// Slice down to YYYY-MM-DDTHH:MM (16 chars).
|
||||||
|
return s.slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditAssignmentModal(assignmentId) {
|
||||||
|
const entry = (_dtCurrentTimeline.entries || []).find(
|
||||||
|
e => e.kind === 'assignment' && e.assignment_id === assignmentId
|
||||||
|
);
|
||||||
|
if (!entry) {
|
||||||
|
alert('Could not find this assignment in the loaded timeline.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_editAssignmentCtx = {
|
||||||
|
assignment_id: entry.assignment_id,
|
||||||
|
project_id: entry.project_id,
|
||||||
|
location_name: entry.location_name || 'unnamed location',
|
||||||
|
project_name: entry.project_name || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('editAssignmentLocation').textContent = _editAssignmentCtx.location_name;
|
||||||
|
document.getElementById('editAssignmentProject').textContent = _editAssignmentCtx.project_name;
|
||||||
|
|
||||||
|
document.getElementById('editAssignedAt').value = _iso_to_local_input(entry.starts_at);
|
||||||
|
const endsAtInput = document.getElementById('editAssignedUntil');
|
||||||
|
const openCheckbox = document.getElementById('editAssignedUntilOpen');
|
||||||
|
if (entry.is_active) {
|
||||||
|
endsAtInput.value = '';
|
||||||
|
endsAtInput.disabled = true;
|
||||||
|
openCheckbox.checked = true;
|
||||||
|
} else {
|
||||||
|
endsAtInput.value = _iso_to_local_input(entry.ends_at);
|
||||||
|
endsAtInput.disabled = false;
|
||||||
|
openCheckbox.checked = false;
|
||||||
|
}
|
||||||
|
document.getElementById('editAssignmentNotes').value = entry.notes || '';
|
||||||
|
document.getElementById('editAssignmentError').classList.add('hidden');
|
||||||
|
document.getElementById('editAssignmentModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditAssignmentModal() {
|
||||||
|
document.getElementById('editAssignmentModal').classList.add('hidden');
|
||||||
|
_editAssignmentCtx = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _toggleEditOpenEnded() {
|
||||||
|
const open = document.getElementById('editAssignedUntilOpen').checked;
|
||||||
|
const input = document.getElementById('editAssignedUntil');
|
||||||
|
input.disabled = open;
|
||||||
|
if (open) input.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEditAssignment() {
|
||||||
|
if (!_editAssignmentCtx) return;
|
||||||
|
const err = document.getElementById('editAssignmentError');
|
||||||
|
err.classList.add('hidden');
|
||||||
|
|
||||||
|
const assignedAt = document.getElementById('editAssignedAt').value;
|
||||||
|
if (!assignedAt) {
|
||||||
|
err.textContent = 'Assigned-at is required.';
|
||||||
|
err.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const open = document.getElementById('editAssignedUntilOpen').checked;
|
||||||
|
const assignedUntil = open ? null : (document.getElementById('editAssignedUntil').value || null);
|
||||||
|
const notes = document.getElementById('editAssignmentNotes').value;
|
||||||
|
|
||||||
|
const btn = document.getElementById('editAssignmentSaveBtn');
|
||||||
|
btn.disabled = true; btn.textContent = 'Saving…';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `/api/projects/${encodeURIComponent(_editAssignmentCtx.project_id)}/assignments/${encodeURIComponent(_editAssignmentCtx.assignment_id)}`;
|
||||||
|
const r = await fetch(url, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
assigned_at: assignedAt,
|
||||||
|
assigned_until: assignedUntil,
|
||||||
|
notes: notes,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const e = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
||||||
|
throw new Error(e.detail || 'HTTP ' + r.status);
|
||||||
|
}
|
||||||
|
closeEditAssignmentModal();
|
||||||
|
await loadDeploymentTimeline();
|
||||||
|
if (typeof loadUnitEvents === 'function' && currentUnit && currentUnit.device_type === 'seismograph') {
|
||||||
|
loadUnitEvents();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
err.textContent = e.message;
|
||||||
|
err.classList.remove('hidden');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false; btn.textContent = 'Save';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAssignmentFromModal() {
|
||||||
|
if (!_editAssignmentCtx) return;
|
||||||
|
if (!confirm('Hard-delete this deployment record?\n\nThe assignment row and its history audit entry are removed. Use this only for misclicks — to end a real deployment, edit the "assigned until" date instead.')) return;
|
||||||
|
const err = document.getElementById('editAssignmentError');
|
||||||
|
err.classList.add('hidden');
|
||||||
|
try {
|
||||||
|
const url = `/api/projects/${encodeURIComponent(_editAssignmentCtx.project_id)}/assignments/${encodeURIComponent(_editAssignmentCtx.assignment_id)}`;
|
||||||
|
const r = await fetch(url, { method: 'DELETE' });
|
||||||
|
if (!r.ok) {
|
||||||
|
const e = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
||||||
|
throw new Error(e.detail || 'HTTP ' + r.status);
|
||||||
|
}
|
||||||
|
closeEditAssignmentModal();
|
||||||
|
await loadDeploymentTimeline();
|
||||||
|
if (typeof loadUnitEvents === 'function' && currentUnit && currentUnit.device_type === 'seismograph') {
|
||||||
|
loadUnitEvents();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
err.textContent = e.message;
|
||||||
|
err.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add-historical-assignment modal ─────────────────────────────────────────
|
||||||
|
let _addAssignmentProjectsCache = null;
|
||||||
|
|
||||||
|
async function openAddAssignmentModal() {
|
||||||
|
if (!currentUnit) return;
|
||||||
|
document.getElementById('addAssignmentError').classList.add('hidden');
|
||||||
|
document.getElementById('addAssignedAt').value = '';
|
||||||
|
document.getElementById('addAssignedUntil').value = '';
|
||||||
|
document.getElementById('addAssignedUntilOpen').checked = false;
|
||||||
|
document.getElementById('addAssignedUntil').disabled = false;
|
||||||
|
document.getElementById('addAssignmentNotes').value = '';
|
||||||
|
|
||||||
|
const locSel = document.getElementById('addAssignmentLocation');
|
||||||
|
locSel.disabled = true;
|
||||||
|
locSel.innerHTML = '<option value="">Pick a project first</option>';
|
||||||
|
|
||||||
|
const projSel = document.getElementById('addAssignmentProject');
|
||||||
|
projSel.innerHTML = '<option value="">Loading…</option>';
|
||||||
|
|
||||||
|
document.getElementById('addAssignmentModal').classList.remove('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!_addAssignmentProjectsCache) {
|
||||||
|
const r = await fetch('/api/projects/search-json?limit=50');
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
_addAssignmentProjectsCache = await r.json();
|
||||||
|
}
|
||||||
|
projSel.innerHTML = '<option value="">— pick project —</option>'
|
||||||
|
+ _addAssignmentProjectsCache.map(p =>
|
||||||
|
`<option value="${_dtEsc(p.id)}">${_dtEsc(p.name)}${p.project_number ? ' (' + _dtEsc(p.project_number) + ')' : ''}</option>`
|
||||||
|
).join('');
|
||||||
|
} catch (e) {
|
||||||
|
projSel.innerHTML = `<option value="">Load failed: ${_dtEsc(e.message)}</option>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAddAssignmentModal() {
|
||||||
|
document.getElementById('addAssignmentModal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _toggleAddOpenEnded() {
|
||||||
|
const open = document.getElementById('addAssignedUntilOpen').checked;
|
||||||
|
const input = document.getElementById('addAssignedUntil');
|
||||||
|
input.disabled = open;
|
||||||
|
if (open) input.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _addAssignmentProjectChanged() {
|
||||||
|
const projectId = document.getElementById('addAssignmentProject').value;
|
||||||
|
const locSel = document.getElementById('addAssignmentLocation');
|
||||||
|
if (!projectId) {
|
||||||
|
locSel.disabled = true;
|
||||||
|
locSel.innerHTML = '<option value="">Pick a project first</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
locSel.disabled = true;
|
||||||
|
locSel.innerHTML = '<option value="">Loading locations…</option>';
|
||||||
|
// Match the device type to the location_type filter.
|
||||||
|
const wantType = (currentUnit && currentUnit.device_type === 'slm') ? 'sound' : 'vibration';
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/projects/${encodeURIComponent(projectId)}/locations-json?location_type=${wantType}`);
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
const locs = await r.json();
|
||||||
|
if (!locs.length) {
|
||||||
|
locSel.innerHTML = '<option value="">No matching locations in this project</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
locSel.disabled = false;
|
||||||
|
locSel.innerHTML = '<option value="">— pick location —</option>'
|
||||||
|
+ locs.map(l => `<option value="${_dtEsc(l.id)}">${_dtEsc(l.name)}</option>`).join('');
|
||||||
|
} catch (e) {
|
||||||
|
locSel.innerHTML = `<option value="">Load failed: ${_dtEsc(e.message)}</option>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAddAssignment() {
|
||||||
|
if (!currentUnit) return;
|
||||||
|
const err = document.getElementById('addAssignmentError');
|
||||||
|
err.classList.add('hidden');
|
||||||
|
|
||||||
|
const projectId = document.getElementById('addAssignmentProject').value;
|
||||||
|
const locationId = document.getElementById('addAssignmentLocation').value;
|
||||||
|
const assignedAt = document.getElementById('addAssignedAt').value;
|
||||||
|
const open = document.getElementById('addAssignedUntilOpen').checked;
|
||||||
|
const assignedUntil = open ? '' : document.getElementById('addAssignedUntil').value;
|
||||||
|
const notes = document.getElementById('addAssignmentNotes').value;
|
||||||
|
|
||||||
|
if (!projectId) { err.textContent = 'Pick a project.'; err.classList.remove('hidden'); return; }
|
||||||
|
if (!locationId) { err.textContent = 'Pick a location.'; err.classList.remove('hidden'); return; }
|
||||||
|
if (!assignedAt) { err.textContent = 'Assigned-at is required.'; err.classList.remove('hidden'); return; }
|
||||||
|
|
||||||
|
const btn = document.getElementById('addAssignmentSaveBtn');
|
||||||
|
btn.disabled = true; btn.textContent = 'Creating…';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('unit_id', currentUnit.id);
|
||||||
|
fd.append('assigned_at', assignedAt);
|
||||||
|
if (assignedUntil) fd.append('assigned_until', assignedUntil);
|
||||||
|
if (notes) fd.append('notes', notes);
|
||||||
|
|
||||||
|
const url = `/api/projects/${encodeURIComponent(projectId)}/locations/${encodeURIComponent(locationId)}/assign`;
|
||||||
|
const r = await fetch(url, { method: 'POST', body: fd });
|
||||||
|
if (!r.ok) {
|
||||||
|
const e = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
||||||
|
throw new Error(e.detail || 'HTTP ' + r.status);
|
||||||
|
}
|
||||||
|
closeAddAssignmentModal();
|
||||||
|
await loadDeploymentTimeline();
|
||||||
|
if (typeof loadUnitEvents === 'function' && currentUnit.device_type === 'seismograph') {
|
||||||
|
loadUnitEvents();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
err.textContent = e.message;
|
||||||
|
err.classList.remove('hidden');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false; btn.textContent = 'Create';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── SFM Events section ──────────────────────────────────────────────────────
|
// ── SFM Events section ──────────────────────────────────────────────────────
|
||||||
function clearUnitEventFilters() {
|
function clearUnitEventFilters() {
|
||||||
document.getElementById('ue-filter-bucket').value = 'all';
|
document.getElementById('ue-filter-bucket').value = 'all';
|
||||||
|
|||||||
Reference in New Issue
Block a user