Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4fd1c943d | |||
| ba9cdb4347 | |||
| f063383e61 | |||
| 17c988c1ee | |||
| d297412d8a | |||
| 52dd6c3e32 | |||
| 295f9637b3 | |||
| ad55d4ca09 | |||
| ba1f28ee53 | |||
| c48c6e5bca | |||
| ef0008822e | |||
| f13158e7bf | |||
| 3f0ec8f30b | |||
| d5a0163852 | |||
| fd37425f1c | |||
| 32d2a57bc9 |
@@ -5,6 +5,57 @@ 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.11.0] - 2026-05-15
|
||||||
|
|
||||||
|
Operator-facing polish release. All work builds on the v0.10.0 SFM integration foundation — this release is about making the day-to-day workflows (managing locations, cleaning up bad attributions, browsing deployments) faster and less error-prone.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Soft-remove monitoring locations** (`POST /api/projects/{p}/locations/{l}/remove` + `/restore`): mark a location as no longer actively monitored without destroying historical events. Cascade-closes active unit assignments and cancels pending scheduled actions at the location. Restored locations rejoin the active list (assignments are NOT auto-reopened — operator creates new ones if resuming). Project page splits locations into Active and Removed sections; removed cards are greyed out, badged with the removal date + reason, and offer a Restore button.
|
||||||
|
- **Per-unit deployment Gantt chart** above the existing Deployment Timeline list on every seismograph unit detail page. Plain-SVG rendering, color per location, today marker (orange dashed line), reduced-opacity bars for closed assignments, blue outlines on metadata-backfilled assignments, dashed blue underlines marking mergeable groups. Click a bar to scroll the matching list row into view with a flash highlight.
|
||||||
|
- **Merge consecutive same-location assignments** (`POST /api/projects/{p}/assignments/merge`): operators often end up with several rows representing one continuous deployment (after remove/restore, or metadata-backfill adjacent to a manual record). Now auto-detected and surfaceable in the timeline header — one click combines them into a single record. Preserves the earliest record's notes + ingest source, writes an `assignment_merged` audit entry, deletes the others.
|
||||||
|
- **Delete assignment for mis-clicks** (`DELETE /api/projects/{p}/assignments/{a}`): hard-deletes a bogus assignment row that was never a real deployment. Trash icon in each row of the location's Deployment History panel. Refuses the delete if any `MonitoringSession` exists in the assignment's window — those should go through Unassign instead, which preserves audit history. Writes an `assignment_deleted` UnitHistory row.
|
||||||
|
- **Drag-to-reorder location cards**: each active card has a six-dot drag handle on the left. Drag/drop reorders the DOM and persists via `POST /api/projects/{p}/locations/reorder`. Implementation uses native HTML5 drag-and-drop (no library). New locations land at the end (`sort_order = max + 1`); removed locations stay sorted by removal date.
|
||||||
|
- **Three-dot kebab menu on location cards**: replaces the four inline pill buttons (Unassign / Edit / Remove / Delete) with a single ⋮ menu. Click ⋮ to open; click outside or Escape to close; only one menu open at a time.
|
||||||
|
- **Event count on vibration location cards**: vibration cards now show "{N} events" sourced from SFM via concurrent fan-out, instead of "Sessions: 0" (sessions don't exist under the watcher-forward pipeline). Sound locations still show session counts.
|
||||||
|
- **Project overview location map**: right column of every project's overview replaces the lightly-used Upcoming Actions panel with a Leaflet map. One pin per active monitoring location (parsed from the `coordinates` field). Click pin → scrolls + flashes the matching card. Tooltip on hover. Locations without coordinates surface as an inline hint below the map. If the project has pending scheduled actions, a small "{N} upcoming actions →" link appears in the card header that switches to the Schedules tab.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Backfill location fuzzy matcher is now stricter**: `rapidfuzz.WRatio` was over-confident on location names because their shared boilerplate vocabulary ("Area", "Loc", numbers) inflated scores. Example false positive that prompted the change: `"Area 2 - Brookville Dam - Loc 2 East"` vs `"Area 1 - Loc 1 - 87 Jenks"` scored 86% via WRatio. Now uses `token_set_ratio` as the base scorer plus a 0.30 penalty when the two strings have disjoint multi-digit numeric tokens. Catches the "same project, different address number" case (`"68 Jenks"` vs `"87 Jenks"`) that pure token-set scoring still rated above 0.90. Project matching keeps WRatio (where its leniency is desirable for typos like `1-80` vs `I-80`).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Three separate JSON.stringify quote-collision bugs**: any inline `onclick="...({...} | tojson)"` or `onclick="...${JSON.stringify(x)}..."` where `x` contained any character that JSON quotes (essentially every real-world string) broke the HTML attribute and silently un-bound the click handler. Surfaced in three places this release; all fixed by switching to `data-*` attributes plus a trampoline function reading from `this.dataset`:
|
||||||
|
- **Location Remove button** on the project page
|
||||||
|
- **Metadata-backfill typeahead dropdown** (existing project + location pickers)
|
||||||
|
- **Project-merge typeahead dropdown** (in the per-project header)
|
||||||
|
- **Project-merge modal too short to show typeahead options without scrolling**: modal body's `flex-1 overflow-y-auto` collapsed tight; added `min-height: 480px` to the modal container + `min-h-[320px]` to the body so the dropdown always has room.
|
||||||
|
- **Project location map covered modals**: Leaflet's internal panes carry z-indexes 200–800 by default and the map container didn't establish a stacking context, so those z-indexes leaked into the root and outranked modals' `z-50`. Fixed by adding `isolation: isolate` to the map container.
|
||||||
|
- **`delete_assignment` crashed with `AttributeError`**: the safety check queried `MonitoringSession.start_time` but the actual column is `started_at`. Every DELETE call to `/assignments/{id}` failed with 500 before doing anything.
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
Run on each database before deploying. Both migrations are idempotent and non-destructive.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_location_removed.py
|
||||||
|
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_location_sort_order.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Or sweep all migrations at once (safe — already-applied ones no-op):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for f in backend/migrate_*.py; do
|
||||||
|
docker exec terra-view-terra-view-1 python3 "/app/backend/$(basename $f)"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
New columns added this release:
|
||||||
|
- `monitoring_locations.removed_at` (DATETIME, nullable) — NULL means active
|
||||||
|
- `monitoring_locations.removal_reason` (TEXT, nullable)
|
||||||
|
- `monitoring_locations.sort_order` (INTEGER, default 0) — seeded to alphabetical-index per project on first migration
|
||||||
|
|
||||||
|
**Deploy order matters**: migrations must run BEFORE the new code is up, otherwise the running app will throw 500s on the unrecognized columns. Idempotent migrations make this recoverable but it's better avoided — the v0.11.0 deploy on prod hit this exact window after the v0.10.0 release.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.10.0] - 2026-05-14
|
## [0.10.0] - 2026-05-14
|
||||||
|
|
||||||
This release brings terra-view onto the SFM (Seismograph Field Module) event pipeline. Triggered events forwarded by series3-watcher now land in SFM, and terra-view reads from that store as the authoritative source for vibration data. The watcher heartbeat is preserved as a transparent fallback signal.
|
This release brings terra-view onto the SFM (Seismograph Field Module) event pipeline. Triggered events forwarded by series3-watcher now land in SFM, and terra-view reads from that store as the authoritative source for vibration data. The watcher heartbeat is preserved as a transparent fallback signal.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Terra-View v0.10.0
|
# Terra-View v0.11.0
|
||||||
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
|
||||||
@@ -496,6 +496,19 @@ docker compose down -v
|
|||||||
|
|
||||||
## Release Highlights
|
## Release Highlights
|
||||||
|
|
||||||
|
### v0.11.0 — 2026-05-15
|
||||||
|
- **Soft-Remove Monitoring Locations**: Mark a location as no longer actively monitored without destroying history. Closes active unit assignments and cancels pending scheduled actions; historical events stay attributed. Restore brings it back. Surfaces as a Removed Locations collapsed section on the project page.
|
||||||
|
- **Per-Unit Deployment Gantt**: Visual timeline above the deployment history list on each unit detail page. Color-coded bars per location, today marker, mergeable-group dashed underlines, click a bar to scroll its detail row into view.
|
||||||
|
- **Merge Consecutive Deployments**: Auto-detects runs of same-location assignments within a 7-day gap and offers a one-click "Merge into one" button. Preserves notes, ingest source, and writes an `assignment_merged` audit entry.
|
||||||
|
- **Delete Assignment for Mis-Clicks**: Trash icon on each row of the location's Deployment History panel. Hard-deletes the assignment with a safety check that refuses if real MonitoringSessions sit inside the window (those should go through Unassign instead).
|
||||||
|
- **Drag-to-Reorder Location Cards**: Six-dot drag handle on each card; drop order persists via a new `/locations/reorder` endpoint. Removed locations stay sorted by removal date (their order is historical).
|
||||||
|
- **Three-Dot Kebab Menu**: Replaces the inline Unassign / Edit / Remove / Delete pill row with a single ⋮ menu. Much cleaner card layout, especially for projects with many locations.
|
||||||
|
- **Event Count on Vibration Cards**: Vibration locations now show "{N} events" instead of "Sessions: 0" (sessions don't exist under the watcher-forward pipeline). Sound locations are unchanged.
|
||||||
|
- **Project Location Map**: Right column of the project overview is now a Leaflet map with a pin per location. Click pin → scrolls + flashes the matching card. Replaces the lightly-used Upcoming Actions panel (still discoverable via a link to the Schedules tab when actions exist).
|
||||||
|
- **Stricter Location Fuzzy Matching**: Metadata-backfill no longer suggests obviously-wrong matches. WRatio was over-confident on location names ("Area 2 - Brookville Dam - Loc 2" vs "Area 1 - Loc 1 - 87 Jenks" used to score 86%); now uses `token_set_ratio` + a multi-digit penalty so disjoint address numbers correctly demote the score.
|
||||||
|
- **Fixed: Multiple typeahead dropdowns weren't clickable**: Same JSON.stringify quote-collision bug surfaced in three places (location Remove button, backfill typeahead, project-merge dropdown). All three fixed by switching to `data-*` attributes + trampoline functions.
|
||||||
|
- **Fixed: Merge-project modal had to be scrolled to see options**: Modal body's `flex-1 overflow-y-auto` collapsed too tight; added `min-height` so the dropdown has room to render below the input.
|
||||||
|
|
||||||
### v0.10.0 — 2026-05-14
|
### v0.10.0 — 2026-05-14
|
||||||
- **SFM Integration**: terra-view now consumes events from the SFM (Seismograph Field Module) backend in real time, with a fleet-wide events page at `/sfm`, per-unit attribution against project assignment windows, and a project-level vibration roll-up that uses SFM data as the single source of truth.
|
- **SFM Integration**: terra-view now consumes events from the SFM (Seismograph Field Module) backend in real time, with a fleet-wide events page at `/sfm`, per-unit attribution against project assignment windows, and a project-level vibration roll-up that uses SFM data as the single source of truth.
|
||||||
- **SFM-Primary Seismograph Status**: Deployed seismograph status (OK/Pending/Missing) now flows from SFM event forwards first; the watcher heartbeat stays as a transparent backup. Each unit's active table row shows a small `SFM` or `HB` badge so operators can see at a glance which signal is currently driving the status.
|
- **SFM-Primary Seismograph Status**: Deployed seismograph status (OK/Pending/Missing) now flows from SFM event forwards first; the watcher heartbeat stays as a transparent backup. Each unit's active table row shows a small `SFM` or `HB` badge so operators can see at a glance which signal is currently driving the status.
|
||||||
@@ -612,9 +625,11 @@ MIT
|
|||||||
|
|
||||||
## Version
|
## Version
|
||||||
|
|
||||||
**Current: 0.10.0** — SFM integration, SFM-primary seismograph status, dashboard rework, sortable events tables, event detail modal, /admin/sfm + /admin/slmm diagnostic pages, Tools workflow hub (2026-05-14)
|
**Current: 0.11.0** — Soft-remove locations, per-unit Gantt, merge/delete assignments, drag-to-reorder, three-dot kebab menu, event count on vibration cards, project location map, stricter backfill fuzzy match, modal/typeahead bug fixes (2026-05-15)
|
||||||
|
|
||||||
Previous: 0.9.4 — Modular project types, deleted project management, swap modal search, roster auto-refresh fix (2026-04-06)
|
Previous: 0.10.0 — SFM integration, SFM-primary seismograph status, dashboard rework, sortable events tables, event detail modal, /admin/sfm + /admin/slmm diagnostic pages, Tools workflow hub (2026-05-14)
|
||||||
|
|
||||||
|
0.9.4 — Modular project types, deleted project management, swap modal search, roster auto-refresh fix (2026-04-06)
|
||||||
|
|
||||||
0.9.3 — Monitoring session detail page, configurable period windows, vibration project redesign, modem assignment on locations (2026-03-28)
|
0.9.3 — Monitoring session detail page, configurable period windows, vibration project redesign, modem assignment on locations (2026-03-28)
|
||||||
|
|
||||||
|
|||||||
+1
-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.10.0"
|
VERSION = "0.11.0"
|
||||||
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":
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""
|
||||||
|
Migration: add `removed_at` + `removal_reason` columns to `monitoring_locations`.
|
||||||
|
|
||||||
|
Lets operators mark a location as no longer actively monitored without
|
||||||
|
deleting it (so historical events stay attributed correctly). Mirrors
|
||||||
|
the timestamp-based "closed state" pattern already used by
|
||||||
|
`unit_assignments.assigned_until`.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- `removed_at IS NULL` → location is active (default for all existing
|
||||||
|
rows after this migration)
|
||||||
|
- `removed_at` set → location is removed; historical events still
|
||||||
|
attribute to it but it's hidden from active
|
||||||
|
surfaces (assign dropdowns, calendar, etc.)
|
||||||
|
- `removal_reason` → optional operator note (e.g. "client dropped
|
||||||
|
from scope")
|
||||||
|
|
||||||
|
Idempotent — safe to re-run. Non-destructive — adds only.
|
||||||
|
|
||||||
|
Run with:
|
||||||
|
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_location_removed.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
DB_PATH = "./data/seismo_fleet.db"
|
||||||
|
|
||||||
|
|
||||||
|
def _has_column(cur: sqlite3.Cursor, table: str, column: str) -> bool:
|
||||||
|
cur.execute(f"PRAGMA table_info({table})")
|
||||||
|
return any(row[1] == column for row in cur.fetchall())
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_database() -> None:
|
||||||
|
if not os.path.exists(DB_PATH):
|
||||||
|
print(f"Database not found at {DB_PATH}")
|
||||||
|
return
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
added = []
|
||||||
|
if not _has_column(cur, "monitoring_locations", "removed_at"):
|
||||||
|
cur.execute("ALTER TABLE monitoring_locations ADD COLUMN removed_at DATETIME")
|
||||||
|
added.append("removed_at")
|
||||||
|
if not _has_column(cur, "monitoring_locations", "removal_reason"):
|
||||||
|
cur.execute("ALTER TABLE monitoring_locations ADD COLUMN removal_reason TEXT")
|
||||||
|
added.append("removal_reason")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if added:
|
||||||
|
print(f" Added columns to monitoring_locations: {', '.join(added)}")
|
||||||
|
else:
|
||||||
|
print(" monitoring_locations already has removed_at + removal_reason — nothing to do.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("Running migration: add removed_at + removal_reason to monitoring_locations")
|
||||||
|
migrate_database()
|
||||||
|
print("Done.")
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""
|
||||||
|
Migration: add `sort_order` column to `monitoring_locations` and seed
|
||||||
|
existing rows.
|
||||||
|
|
||||||
|
Lets operators reorder location cards via drag-and-drop on the project
|
||||||
|
detail page. Lower sort_order renders first; ties fall back to name.
|
||||||
|
|
||||||
|
Seed strategy: for each existing project, assign sort_order = 0, 1, 2, …
|
||||||
|
to its locations in their current alphabetical-by-name order. After
|
||||||
|
this migration, the visible card order on every existing project will
|
||||||
|
be unchanged.
|
||||||
|
|
||||||
|
Idempotent — safe to re-run. Non-destructive — adds only.
|
||||||
|
|
||||||
|
Run with:
|
||||||
|
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_location_sort_order.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
DB_PATH = "./data/seismo_fleet.db"
|
||||||
|
|
||||||
|
|
||||||
|
def _has_column(cur: sqlite3.Cursor, table: str, column: str) -> bool:
|
||||||
|
cur.execute(f"PRAGMA table_info({table})")
|
||||||
|
return any(row[1] == column for row in cur.fetchall())
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_database() -> None:
|
||||||
|
if not os.path.exists(DB_PATH):
|
||||||
|
print(f"Database not found at {DB_PATH}")
|
||||||
|
return
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
added_column = False
|
||||||
|
if not _has_column(cur, "monitoring_locations", "sort_order"):
|
||||||
|
cur.execute("ALTER TABLE monitoring_locations ADD COLUMN sort_order INTEGER DEFAULT 0")
|
||||||
|
added_column = True
|
||||||
|
print(" Added column: monitoring_locations.sort_order")
|
||||||
|
|
||||||
|
# Seed: for each project, set sort_order to its alphabetical index.
|
||||||
|
# Re-runs are harmless — operator-edited orderings can be re-seeded by
|
||||||
|
# passing FORCE_RESEED=1, but the default behavior leaves existing
|
||||||
|
# nonzero sort_order values alone so we don't clobber user choices.
|
||||||
|
force_reseed = os.environ.get("FORCE_RESEED") == "1"
|
||||||
|
if added_column or force_reseed:
|
||||||
|
cur.execute("SELECT DISTINCT project_id FROM monitoring_locations")
|
||||||
|
projects = [r[0] for r in cur.fetchall()]
|
||||||
|
seeded = 0
|
||||||
|
for project_id in projects:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id FROM monitoring_locations WHERE project_id = ? ORDER BY name",
|
||||||
|
(project_id,),
|
||||||
|
)
|
||||||
|
for idx, (loc_id,) in enumerate(cur.fetchall()):
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE monitoring_locations SET sort_order = ? WHERE id = ?",
|
||||||
|
(idx, loc_id),
|
||||||
|
)
|
||||||
|
seeded += 1
|
||||||
|
print(f" Seeded sort_order for {seeded} location(s) across {len(projects)} project(s).")
|
||||||
|
else:
|
||||||
|
print(" monitoring_locations.sort_order already present — leaving existing values alone.")
|
||||||
|
print(" (Set FORCE_RESEED=1 to re-seed by alphabetical order.)")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("Running migration: add sort_order to monitoring_locations")
|
||||||
|
migrate_database()
|
||||||
|
print("Done.")
|
||||||
@@ -235,6 +235,20 @@ class MonitoringLocation(Base):
|
|||||||
# For vibration: {"ground_type": "bedrock", "depth": "10m"}
|
# For vibration: {"ground_type": "bedrock", "depth": "10m"}
|
||||||
location_metadata = Column(Text, nullable=True)
|
location_metadata = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Soft-removal: NULL means active. When set, the location is hidden from
|
||||||
|
# active surfaces (assign dropdowns, calendar, scheduler, dashboard
|
||||||
|
# vibration summary) but historical events generated before this time
|
||||||
|
# still attribute to it. Mirrors the closed-state pattern used by
|
||||||
|
# UnitAssignment.assigned_until.
|
||||||
|
removed_at = Column(DateTime, nullable=True)
|
||||||
|
removal_reason = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Display order within the project's location list. Operators can
|
||||||
|
# drag-and-drop to reorder cards on the project detail page. Lower
|
||||||
|
# values render first; ties fall back to name (alphabetical). Seeded
|
||||||
|
# to alphabetical-index on migration; new locations get max+1.
|
||||||
|
sort_order = Column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|||||||
@@ -361,6 +361,9 @@ def locations_search(
|
|||||||
db.query(MonitoringLocation)
|
db.query(MonitoringLocation)
|
||||||
.filter(MonitoringLocation.project_id == project_id)
|
.filter(MonitoringLocation.project_id == project_id)
|
||||||
.filter(MonitoringLocation.location_type == "vibration")
|
.filter(MonitoringLocation.location_type == "vibration")
|
||||||
|
# Don't propose creating assignments at removed locations — they
|
||||||
|
# were intentionally decommissioned and shouldn't be backfill targets.
|
||||||
|
.filter(MonitoringLocation.removed_at == None) # noqa: E711
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -373,7 +376,11 @@ def locations_search(
|
|||||||
if q_norm in l_norm:
|
if q_norm in l_norm:
|
||||||
scored.append((l, 1.0))
|
scored.append((l, 1.0))
|
||||||
continue
|
continue
|
||||||
score = svc.similarity(q_norm, l_norm)
|
# Use the location-specific scorer (token_set_ratio + multi-digit
|
||||||
|
# penalty) instead of WRatio — same reason as the cluster-match
|
||||||
|
# path: location names share too much boilerplate vocabulary for
|
||||||
|
# WRatio to discriminate reliably.
|
||||||
|
score = svc.location_similarity(q_norm, l_norm)
|
||||||
if score >= 0.50:
|
if score >= 0.50:
|
||||||
scored.append((l, score))
|
scored.append((l, score))
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from backend.models import (
|
|||||||
MonitoringSession,
|
MonitoringSession,
|
||||||
DataFile,
|
DataFile,
|
||||||
UnitHistory,
|
UnitHistory,
|
||||||
|
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
|
||||||
@@ -138,7 +139,7 @@ async def get_project_locations(
|
|||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get all monitoring locations for a project.
|
Get all monitoring locations for a project.
|
||||||
Returns HTML partial with location list.
|
Returns HTML partial with location list, split into active + removed.
|
||||||
"""
|
"""
|
||||||
project = db.query(Project).filter_by(id=project_id).first()
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
if not project:
|
if not project:
|
||||||
@@ -150,12 +151,35 @@ async def get_project_locations(
|
|||||||
if location_type:
|
if location_type:
|
||||||
query = query.filter_by(location_type=location_type)
|
query = query.filter_by(location_type=location_type)
|
||||||
|
|
||||||
locations = query.order_by(MonitoringLocation.name).all()
|
# Order by operator-set sort_order, then name as a stable tie-breaker.
|
||||||
|
locations = query.order_by(MonitoringLocation.sort_order, MonitoringLocation.name).all()
|
||||||
|
|
||||||
# Enrich with assignment info
|
# For vibration locations, fan out event counts via SFM concurrently
|
||||||
locations_data = []
|
# so the card layout can show "{N} events" instead of "Sessions: 0"
|
||||||
|
# (sessions don't really exist for the watcher-forward pipeline).
|
||||||
|
# Sound locations skip this and keep showing session counts.
|
||||||
|
event_counts: dict[str, int] = {}
|
||||||
|
vibration_locations = [l for l in locations if l.location_type == "vibration"]
|
||||||
|
if vibration_locations:
|
||||||
|
import asyncio
|
||||||
|
from backend.services.sfm_events import events_for_location
|
||||||
|
results = await asyncio.gather(
|
||||||
|
*(events_for_location(db, l.id, limit=1) for l in vibration_locations),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
for loc, res in zip(vibration_locations, results):
|
||||||
|
if isinstance(res, Exception):
|
||||||
|
continue # leave event_counts[loc.id] unset → template falls back
|
||||||
|
event_counts[loc.id] = (res.get("stats") or {}).get("event_count", 0) or 0
|
||||||
|
|
||||||
|
# Enrich with assignment info, splitting active vs removed.
|
||||||
|
active_data: list = []
|
||||||
|
removed_data: list = []
|
||||||
for location in locations:
|
for location in locations:
|
||||||
# Get active assignment (active = assigned_until IS NULL)
|
# Get active assignment (active = assigned_until IS NULL). For
|
||||||
|
# removed locations this will normally be None because the
|
||||||
|
# /remove cascade closes them, but check anyway for resilience
|
||||||
|
# against legacy data.
|
||||||
assignment = db.query(UnitAssignment).filter(
|
assignment = db.query(UnitAssignment).filter(
|
||||||
and_(
|
and_(
|
||||||
UnitAssignment.location_id == location.id,
|
UnitAssignment.location_id == location.id,
|
||||||
@@ -172,17 +196,25 @@ async def get_project_locations(
|
|||||||
location_id=location.id
|
location_id=location.id
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
locations_data.append({
|
item = {
|
||||||
"location": location,
|
"location": location,
|
||||||
"assignment": assignment,
|
"assignment": assignment,
|
||||||
"assigned_unit": assigned_unit,
|
"assigned_unit": assigned_unit,
|
||||||
"session_count": session_count,
|
"session_count": session_count,
|
||||||
})
|
}
|
||||||
|
if location.id in event_counts:
|
||||||
|
item["event_count"] = event_counts[location.id]
|
||||||
|
if location.removed_at is None:
|
||||||
|
active_data.append(item)
|
||||||
|
else:
|
||||||
|
removed_data.append(item)
|
||||||
|
|
||||||
return templates.TemplateResponse("partials/projects/location_list.html", {
|
return templates.TemplateResponse("partials/projects/location_list.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"project": project,
|
"project": project,
|
||||||
"locations": locations_data,
|
"locations": active_data, # back-compat alias
|
||||||
|
"active_locations": active_data,
|
||||||
|
"removed_locations": removed_data,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -191,10 +223,15 @@ async def get_project_locations_json(
|
|||||||
project_id: str,
|
project_id: str,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
location_type: Optional[str] = Query(None),
|
location_type: Optional[str] = Query(None),
|
||||||
|
include_removed: bool = Query(False),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get all monitoring locations for a project as JSON.
|
Get all monitoring locations for a project as JSON.
|
||||||
Used by the schedule modal to populate location dropdown.
|
Used by the schedule modal to populate location dropdown.
|
||||||
|
|
||||||
|
Removed locations are filtered out by default (you can't schedule
|
||||||
|
a new action at a removed location). Pass `include_removed=true`
|
||||||
|
to get them too — useful for historical / reporting views.
|
||||||
"""
|
"""
|
||||||
project = db.query(Project).filter_by(id=project_id).first()
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
if not project:
|
if not project:
|
||||||
@@ -205,7 +242,10 @@ async def get_project_locations_json(
|
|||||||
if location_type:
|
if location_type:
|
||||||
query = query.filter_by(location_type=location_type)
|
query = query.filter_by(location_type=location_type)
|
||||||
|
|
||||||
locations = query.order_by(MonitoringLocation.name).all()
|
if not include_removed:
|
||||||
|
query = query.filter(MonitoringLocation.removed_at == None) # noqa: E711
|
||||||
|
|
||||||
|
locations = query.order_by(MonitoringLocation.sort_order, MonitoringLocation.name).all()
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -215,6 +255,8 @@ async def get_project_locations_json(
|
|||||||
"description": loc.description,
|
"description": loc.description,
|
||||||
"address": loc.address,
|
"address": loc.address,
|
||||||
"coordinates": loc.coordinates,
|
"coordinates": loc.coordinates,
|
||||||
|
"removed_at": loc.removed_at.isoformat() if loc.removed_at else None,
|
||||||
|
"removal_reason": loc.removal_reason,
|
||||||
}
|
}
|
||||||
for loc in locations
|
for loc in locations
|
||||||
]
|
]
|
||||||
@@ -235,6 +277,13 @@ async def create_location(
|
|||||||
|
|
||||||
form_data = await request.form()
|
form_data = await request.form()
|
||||||
|
|
||||||
|
# Compute next sort_order so new locations land at the END of the
|
||||||
|
# project's list rather than getting interleaved alphabetically.
|
||||||
|
from sqlalchemy import func
|
||||||
|
max_sort = db.query(func.max(MonitoringLocation.sort_order))\
|
||||||
|
.filter_by(project_id=project_id).scalar()
|
||||||
|
next_sort_order = (max_sort or 0) + 1 if max_sort is not None else 0
|
||||||
|
|
||||||
location = MonitoringLocation(
|
location = MonitoringLocation(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
@@ -244,6 +293,7 @@ async def create_location(
|
|||||||
coordinates=form_data.get("coordinates"),
|
coordinates=form_data.get("coordinates"),
|
||||||
address=form_data.get("address"),
|
address=form_data.get("address"),
|
||||||
location_metadata=form_data.get("location_metadata"), # JSON string
|
location_metadata=form_data.get("location_metadata"), # JSON string
|
||||||
|
sort_order=next_sort_order,
|
||||||
)
|
)
|
||||||
|
|
||||||
db.add(location)
|
db.add(location)
|
||||||
@@ -335,6 +385,216 @@ async def delete_location(
|
|||||||
return {"success": True, "message": "Location deleted successfully"}
|
return {"success": True, "message": "Location deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/locations/reorder")
|
||||||
|
async def reorder_locations(
|
||||||
|
project_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Persist a new sort order for a project's monitoring locations.
|
||||||
|
|
||||||
|
Body JSON: { "location_ids": [uuid, uuid, ...] }
|
||||||
|
The list MUST contain location ids in the desired display order.
|
||||||
|
Locations not included in the list keep their current sort_order
|
||||||
|
(useful for the "active locations only — leave removed alone"
|
||||||
|
drag-and-drop UX).
|
||||||
|
|
||||||
|
Updates `sort_order` to the index of each id in the list. Ties
|
||||||
|
between included and excluded locations fall back to the existing
|
||||||
|
sort_order.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
||||||
|
|
||||||
|
ids = payload.get("location_ids") or []
|
||||||
|
if not isinstance(ids, list) or len(ids) == 0:
|
||||||
|
raise HTTPException(status_code=400, detail="location_ids must be a non-empty list")
|
||||||
|
|
||||||
|
# Fetch only the locations being reordered and validate ownership.
|
||||||
|
locations = db.query(MonitoringLocation).filter(
|
||||||
|
MonitoringLocation.project_id == project_id,
|
||||||
|
MonitoringLocation.id.in_(ids),
|
||||||
|
).all()
|
||||||
|
|
||||||
|
found_ids = {l.id for l in locations}
|
||||||
|
missing = [i for i in ids if i not in found_ids]
|
||||||
|
if missing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Some locations not found in this project: {missing[:3]}…",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply 0-indexed sort_order matching the operator's chosen order.
|
||||||
|
by_id = {l.id: l for l in locations}
|
||||||
|
for idx, loc_id in enumerate(ids):
|
||||||
|
by_id[loc_id].sort_order = idx
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"success": True, "reordered": len(ids)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/locations/{location_id}/remove")
|
||||||
|
async def remove_location(
|
||||||
|
project_id: str,
|
||||||
|
location_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Soft-remove a monitoring location — mark it as no longer actively
|
||||||
|
monitored without destroying it.
|
||||||
|
|
||||||
|
Use case: a client drops a location from scope mid-project, but the
|
||||||
|
historical events recorded there should remain attributed. Deleting
|
||||||
|
would orphan those events; this preserves them.
|
||||||
|
|
||||||
|
Cascading side-effects:
|
||||||
|
1. All active UnitAssignment rows at this location are closed
|
||||||
|
(assigned_until = effective_date, status = "completed").
|
||||||
|
Units become available for other deployments.
|
||||||
|
2. All pending ScheduledAction rows at this location are cancelled
|
||||||
|
(execution_status = "cancelled").
|
||||||
|
3. Historical events stay attributed (attribution is window-based;
|
||||||
|
events with timestamp < effective_date still match the
|
||||||
|
now-closed assignment windows).
|
||||||
|
|
||||||
|
Accepts JSON body:
|
||||||
|
- effective_date: ISO datetime (optional, defaults to now)
|
||||||
|
- reason: operator note (optional)
|
||||||
|
"""
|
||||||
|
location = db.query(MonitoringLocation).filter_by(
|
||||||
|
id=location_id,
|
||||||
|
project_id=project_id,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not location:
|
||||||
|
raise HTTPException(status_code=404, detail="Location not found")
|
||||||
|
|
||||||
|
if location.removed_at is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Location is already removed (as of {location.removed_at.isoformat()}).",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Body is optional — POST with no body is fine and means "remove now,
|
||||||
|
# no reason given."
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
except Exception:
|
||||||
|
payload = {}
|
||||||
|
|
||||||
|
# Effective date: accept "YYYY-MM-DDTHH:MM" from datetime-local inputs or
|
||||||
|
# full ISO. Defaults to now if absent/empty.
|
||||||
|
raw_eff = payload.get("effective_date")
|
||||||
|
if raw_eff:
|
||||||
|
try:
|
||||||
|
effective_date = datetime.fromisoformat(raw_eff)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid effective_date: {raw_eff!r}",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
effective_date = datetime.utcnow()
|
||||||
|
|
||||||
|
reason = (payload.get("reason") or "").strip() or None
|
||||||
|
|
||||||
|
# 1. Close active assignments at this location.
|
||||||
|
active_assignments = db.query(UnitAssignment).filter(
|
||||||
|
and_(
|
||||||
|
UnitAssignment.location_id == location_id,
|
||||||
|
UnitAssignment.assigned_until == None, # noqa: E711 — SQL NULL
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for a in active_assignments:
|
||||||
|
a.status = "completed"
|
||||||
|
a.assigned_until = effective_date
|
||||||
|
_record_assignment_history(
|
||||||
|
db,
|
||||||
|
unit_id=a.unit_id,
|
||||||
|
change_type="assignment_ended",
|
||||||
|
old_value=location.name,
|
||||||
|
new_value="location removed",
|
||||||
|
notes=f"Location '{location.name}' marked as removed"
|
||||||
|
+ (f" — {reason}" if reason else ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Cancel pending scheduled actions at this location.
|
||||||
|
pending_actions = db.query(ScheduledAction).filter(
|
||||||
|
and_(
|
||||||
|
ScheduledAction.location_id == location_id,
|
||||||
|
ScheduledAction.execution_status == "pending",
|
||||||
|
ScheduledAction.scheduled_time >= effective_date,
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for sa in pending_actions:
|
||||||
|
sa.execution_status = "cancelled"
|
||||||
|
sa.error_message = (
|
||||||
|
f"Cancelled: location '{location.name}' marked as removed"
|
||||||
|
+ (f" — {reason}" if reason else "")
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Mark the location itself as removed.
|
||||||
|
location.removed_at = effective_date
|
||||||
|
location.removal_reason = reason
|
||||||
|
location.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Location '{location.name}' marked as removed",
|
||||||
|
"effective_date": effective_date.isoformat(),
|
||||||
|
"assignments_closed": len(active_assignments),
|
||||||
|
"actions_cancelled": len(pending_actions),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/locations/{location_id}/restore")
|
||||||
|
async def restore_location(
|
||||||
|
project_id: str,
|
||||||
|
location_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Restore a previously-removed monitoring location to active.
|
||||||
|
|
||||||
|
Clears `removed_at` and `removal_reason`. Does NOT automatically
|
||||||
|
re-open the assignments or scheduled actions that were closed when
|
||||||
|
the location was removed — those stay closed and the operator can
|
||||||
|
create new ones if they want to resume monitoring.
|
||||||
|
"""
|
||||||
|
location = db.query(MonitoringLocation).filter_by(
|
||||||
|
id=location_id,
|
||||||
|
project_id=project_id,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not location:
|
||||||
|
raise HTTPException(status_code=404, detail="Location not found")
|
||||||
|
|
||||||
|
if location.removed_at is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Location is already active.",
|
||||||
|
)
|
||||||
|
|
||||||
|
location.removed_at = None
|
||||||
|
location.removal_reason = None
|
||||||
|
location.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Location '{location.name}' restored to active",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Unit Assignments
|
# Unit Assignments
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -650,6 +910,228 @@ async def update_assignment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/assignments/{assignment_id}")
|
||||||
|
async def delete_assignment(
|
||||||
|
project_id: str,
|
||||||
|
assignment_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Hard-delete an assignment record.
|
||||||
|
|
||||||
|
Use case: operator clicked Assign by mistake (or 8 times in a row) and
|
||||||
|
wants the bogus records gone — not just closed with an `assigned_until`
|
||||||
|
timestamp. The standard close-via-unassign path is for legitimate
|
||||||
|
deployments that ended; this is for mis-clicks that never actually
|
||||||
|
happened.
|
||||||
|
|
||||||
|
Safety:
|
||||||
|
- Refuses if any MonitoringSession exists for the same (unit, location)
|
||||||
|
within this assignment's window — that suggests the deployment was
|
||||||
|
real, and the operator should use unassign instead.
|
||||||
|
- Refuses if the assignment is the ONLY active assignment for a unit
|
||||||
|
currently shown as deployed AND a recording session is in progress.
|
||||||
|
|
||||||
|
Audit:
|
||||||
|
- Records UnitHistory `assignment_deleted` so the unit's deployment
|
||||||
|
timeline shows the deletion happened (even though the row itself
|
||||||
|
is gone).
|
||||||
|
"""
|
||||||
|
assignment = db.query(UnitAssignment).filter_by(
|
||||||
|
id=assignment_id,
|
||||||
|
project_id=project_id,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not assignment:
|
||||||
|
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||||
|
|
||||||
|
# Safety: is there a real recording history for this (unit, location)
|
||||||
|
# within the assignment's time window? If so, this isn't a mis-click —
|
||||||
|
# the operator should close it via unassign, not delete it.
|
||||||
|
window_start = assignment.assigned_at
|
||||||
|
window_end = assignment.assigned_until or datetime.utcnow()
|
||||||
|
real_sessions = db.query(MonitoringSession).filter(
|
||||||
|
and_(
|
||||||
|
MonitoringSession.location_id == assignment.location_id,
|
||||||
|
MonitoringSession.unit_id == assignment.unit_id,
|
||||||
|
MonitoringSession.started_at >= window_start,
|
||||||
|
MonitoringSession.started_at <= window_end,
|
||||||
|
)
|
||||||
|
).count()
|
||||||
|
|
||||||
|
if real_sessions > 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=(
|
||||||
|
f"Cannot delete this assignment — {real_sessions} monitoring "
|
||||||
|
f"session(s) were recorded under it. Use Unassign to close "
|
||||||
|
f"the window instead, which preserves the audit trail."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolve location name for audit log before deletion.
|
||||||
|
location = db.query(MonitoringLocation).filter_by(
|
||||||
|
id=assignment.location_id
|
||||||
|
).first()
|
||||||
|
location_label = location.name if location else assignment.location_id
|
||||||
|
|
||||||
|
_record_assignment_history(
|
||||||
|
db,
|
||||||
|
unit_id=assignment.unit_id,
|
||||||
|
change_type="assignment_deleted",
|
||||||
|
old_value=f"{location_label} ({assignment.assigned_at:%Y-%m-%d} → "
|
||||||
|
f"{assignment.assigned_until and assignment.assigned_until.strftime('%Y-%m-%d') or 'active'})",
|
||||||
|
new_value="deleted",
|
||||||
|
notes=(
|
||||||
|
"Assignment row removed — created in error or accidental duplicate."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
db.delete(assignment)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Assignment deleted.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/assignments/merge")
|
||||||
|
async def merge_assignments(
|
||||||
|
project_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Merge multiple consecutive UnitAssignment rows for the same (unit, location)
|
||||||
|
into a single record spanning their combined window.
|
||||||
|
|
||||||
|
Use case: a unit's deployment timeline shows 3 stacked rows for the
|
||||||
|
same location because the assignment was closed-and-reopened (e.g. via
|
||||||
|
location remove + restore) or because metadata-backfill auto-created
|
||||||
|
a retroactive window adjacent to a manual one. Operator sees three
|
||||||
|
rows but they represent one continuous deployment.
|
||||||
|
|
||||||
|
Body JSON:
|
||||||
|
{ "assignment_ids": ["<uuid>", "<uuid>", ...] }
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- All assignments must belong to project_id
|
||||||
|
- All must share the same unit_id AND location_id
|
||||||
|
- At least 2 ids must be provided
|
||||||
|
|
||||||
|
Merge rules:
|
||||||
|
- Keeps the EARLIEST-starting assignment as the surviving row
|
||||||
|
- assigned_at = min(assigned_at across all)
|
||||||
|
- assigned_until = max(assigned_until), or NULL if any input was active
|
||||||
|
- status = "active" if any input was active, else "completed"
|
||||||
|
- source = source of the earliest record (preserves original ingest provenance)
|
||||||
|
- notes = earliest's notes + "Merged N records (<sources>)"
|
||||||
|
- Other records are DELETED
|
||||||
|
- One UnitHistory `assignment_merged` row is written for audit
|
||||||
|
|
||||||
|
No tolerance check — the operator is asking for the merge, so we trust
|
||||||
|
the intent. The UI can pre-filter to only offer "consecutive" merges
|
||||||
|
if it wants.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
||||||
|
|
||||||
|
ids = payload.get("assignment_ids") or []
|
||||||
|
if not isinstance(ids, list) or len(ids) < 2:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Need at least 2 assignment_ids to merge.",
|
||||||
|
)
|
||||||
|
|
||||||
|
assignments = (
|
||||||
|
db.query(UnitAssignment)
|
||||||
|
.filter(UnitAssignment.project_id == project_id)
|
||||||
|
.filter(UnitAssignment.id.in_(ids))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(assignments) != len(set(ids)):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Some assignments not found (got {len(assignments)} of {len(set(ids))}).",
|
||||||
|
)
|
||||||
|
|
||||||
|
unit_ids = {a.unit_id for a in assignments}
|
||||||
|
loc_ids = {a.location_id for a in assignments}
|
||||||
|
if len(unit_ids) > 1 or len(loc_ids) > 1:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Can only merge assignments that share the same unit and location.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Order chronologically.
|
||||||
|
assignments.sort(key=lambda a: a.assigned_at)
|
||||||
|
earliest = assignments[0]
|
||||||
|
others = assignments[1:]
|
||||||
|
|
||||||
|
# Compute merged window.
|
||||||
|
any_active = any(a.assigned_until is None for a in assignments)
|
||||||
|
if any_active:
|
||||||
|
merged_until = None
|
||||||
|
else:
|
||||||
|
merged_until = max(a.assigned_until for a in assignments)
|
||||||
|
|
||||||
|
# Build a brief audit-style note describing what got merged.
|
||||||
|
bits = []
|
||||||
|
for a in assignments:
|
||||||
|
win = (
|
||||||
|
f"{a.assigned_at:%Y-%m-%d}"
|
||||||
|
f"→{(a.assigned_until and a.assigned_until.strftime('%Y-%m-%d')) or 'active'}"
|
||||||
|
)
|
||||||
|
bits.append(f"{win} [{a.source}]")
|
||||||
|
merge_note_suffix = f"Merged {len(assignments)} records: " + " + ".join(bits)
|
||||||
|
new_notes = (earliest.notes + " • " + merge_note_suffix) if earliest.notes else merge_note_suffix
|
||||||
|
|
||||||
|
# Resolve names for the audit log before mutating.
|
||||||
|
location = db.query(MonitoringLocation).filter_by(id=earliest.location_id).first()
|
||||||
|
location_label = location.name if location else earliest.location_id
|
||||||
|
|
||||||
|
# Mutate the survivor.
|
||||||
|
earliest.assigned_at = min(a.assigned_at for a in assignments)
|
||||||
|
earliest.assigned_until = merged_until
|
||||||
|
earliest.status = "active" if any_active else "completed"
|
||||||
|
earliest.notes = new_notes
|
||||||
|
|
||||||
|
# Delete the rest.
|
||||||
|
deleted_ids = [a.id for a in others]
|
||||||
|
for a in others:
|
||||||
|
db.delete(a)
|
||||||
|
|
||||||
|
_record_assignment_history(
|
||||||
|
db,
|
||||||
|
unit_id=earliest.unit_id,
|
||||||
|
change_type="assignment_merged",
|
||||||
|
old_value=f"{len(assignments)} rows at {location_label}",
|
||||||
|
new_value=(
|
||||||
|
f"1 row {earliest.assigned_at:%Y-%m-%d}"
|
||||||
|
f"→{(merged_until and merged_until.strftime('%Y-%m-%d')) or 'active'}"
|
||||||
|
),
|
||||||
|
notes=merge_note_suffix,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(earliest)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Merged {len(assignments)} assignments into one.",
|
||||||
|
"kept_id": earliest.id,
|
||||||
|
"deleted_ids": deleted_ids,
|
||||||
|
"merged_window": {
|
||||||
|
"assigned_at": earliest.assigned_at.isoformat(),
|
||||||
|
"assigned_until": earliest.assigned_until.isoformat() if earliest.assigned_until else None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/locations/{location_id}/swap")
|
@router.post("/locations/{location_id}/swap")
|
||||||
async def swap_unit_on_location(
|
async def swap_unit_on_location(
|
||||||
project_id: str,
|
project_id: str,
|
||||||
|
|||||||
@@ -46,6 +46,13 @@ log = logging.getLogger("backend.services.deployment_timeline")
|
|||||||
# 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
|
||||||
|
|
||||||
|
# When detecting "mergeable" groups of consecutive same-location assignments,
|
||||||
|
# treat assignments separated by no more than this many seconds as adjacent.
|
||||||
|
# Generous enough to catch overnight handoffs and weekend gaps where the
|
||||||
|
# operator forgot to log, but tight enough that genuinely separate
|
||||||
|
# deployments months apart don't get suggested for merging.
|
||||||
|
_MERGE_GAP_TOLERANCE_SECONDS = 7 * 24 * 3600 # 7 days
|
||||||
|
|
||||||
# Per-call timeout when querying SFM for the event overlay.
|
# Per-call timeout when querying SFM for the event overlay.
|
||||||
_SFM_TIMEOUT = 10.0
|
_SFM_TIMEOUT = 10.0
|
||||||
_SFM_FETCH_CEILING = 5000
|
_SFM_FETCH_CEILING = 5000
|
||||||
@@ -245,7 +252,32 @@ async def deployment_timeline_for_unit(
|
|||||||
"history_notes": h.notes,
|
"history_notes": h.notes,
|
||||||
})
|
})
|
||||||
|
|
||||||
# 6. Sort newest first. Active assignments (no end) sort by start time,
|
# 6. Detect mergeable groups — runs of consecutive assignments to the
|
||||||
|
# same location with small gaps between them. Each group becomes a
|
||||||
|
# list of assignment_ids; the UI offers a "Merge into one" action
|
||||||
|
# on any group >= 2.
|
||||||
|
merge_groups: list[list[str]] = []
|
||||||
|
if len(assignments) >= 2:
|
||||||
|
# Sort ascending for the linear scan.
|
||||||
|
sorted_assignments = sorted(assignments, key=lambda a: a.assigned_at)
|
||||||
|
cur_group: list[UnitAssignment] = [sorted_assignments[0]]
|
||||||
|
for a in sorted_assignments[1:]:
|
||||||
|
prev = cur_group[-1]
|
||||||
|
same_location = a.location_id == prev.location_id
|
||||||
|
prev_end = prev.assigned_until or now
|
||||||
|
gap_seconds = (a.assigned_at - prev_end).total_seconds() if a.assigned_at else 0
|
||||||
|
# Within tolerance and same location → extend the current group.
|
||||||
|
# Negative gaps (overlap) also count as adjacent.
|
||||||
|
if same_location and gap_seconds <= _MERGE_GAP_TOLERANCE_SECONDS:
|
||||||
|
cur_group.append(a)
|
||||||
|
else:
|
||||||
|
if len(cur_group) >= 2:
|
||||||
|
merge_groups.append([x.id for x in cur_group])
|
||||||
|
cur_group = [a]
|
||||||
|
if len(cur_group) >= 2:
|
||||||
|
merge_groups.append([x.id for x in cur_group])
|
||||||
|
|
||||||
|
# 7. Sort newest first. Active assignments (no end) sort by start time,
|
||||||
# same as everything else.
|
# same as everything else.
|
||||||
entries.sort(key=lambda e: e.get("starts_at") or "", reverse=True)
|
entries.sort(key=lambda e: e.get("starts_at") or "", reverse=True)
|
||||||
|
|
||||||
@@ -253,4 +285,8 @@ async def deployment_timeline_for_unit(
|
|||||||
"unit_id": unit.id,
|
"unit_id": unit.id,
|
||||||
"device_type": unit.device_type,
|
"device_type": unit.device_type,
|
||||||
"entries": entries,
|
"entries": entries,
|
||||||
|
# List of assignment_id lists; each inner list is a mergeable group.
|
||||||
|
# Empty if nothing is mergeable. UI shows a "Merge" button on any
|
||||||
|
# row whose assignment_id appears in a group.
|
||||||
|
"merge_groups": merge_groups,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,6 +162,11 @@ def similarity(a: str, b: str) -> float:
|
|||||||
too short to fuzzy-match safely (see _MIN_FUZZY_LEN comment) AND the
|
too short to fuzzy-match safely (see _MIN_FUZZY_LEN comment) AND the
|
||||||
strings don't exact-match. This guardrails the 'one common word
|
strings don't exact-match. This guardrails the 'one common word
|
||||||
inside a longer phrase' false positive.
|
inside a longer phrase' false positive.
|
||||||
|
|
||||||
|
USE FOR: project names (where typos like '1-80' vs 'I-80' should
|
||||||
|
still match). For location names use `location_similarity()` —
|
||||||
|
WRatio is too lenient on the shared boilerplate vocabulary in
|
||||||
|
location strings ('Area', 'Loc', 'Bridge', 'Dam', etc.).
|
||||||
"""
|
"""
|
||||||
if not a or not b:
|
if not a or not b:
|
||||||
return 0.0
|
return 0.0
|
||||||
@@ -172,6 +177,50 @@ def similarity(a: str, b: str) -> float:
|
|||||||
return rapidfuzz.fuzz.WRatio(a, b) / 100.0
|
return rapidfuzz.fuzz.WRatio(a, b) / 100.0
|
||||||
|
|
||||||
|
|
||||||
|
# Multi-digit penalty applied when two location names have completely
|
||||||
|
# disjoint multi-digit numeric tokens (e.g. "87 Jenks" vs "68 Jenks").
|
||||||
|
# Single-digit numbers ("Loc 1", "Area 2") are often shared coincidentally,
|
||||||
|
# but address-style multi-digit numbers are strong identifiers — if they
|
||||||
|
# differ, the locations are usually different physical places.
|
||||||
|
_LOCATION_DIGIT_MISMATCH_PENALTY = 0.30
|
||||||
|
|
||||||
|
|
||||||
|
def location_similarity(a: str, b: str) -> float:
|
||||||
|
"""Stricter similarity score for location-name matching.
|
||||||
|
|
||||||
|
Location names share so much boilerplate vocabulary ('Area', 'Loc',
|
||||||
|
'Bridge', 'Dam') that rapidfuzz.WRatio inflates obvious mismatches.
|
||||||
|
Example: 'Area 2 - Brookville Dam - Loc 2 East' vs 'Area 1 - Loc 1 -
|
||||||
|
87 Jenks' scores 85.5 via WRatio despite being unrelated locations.
|
||||||
|
|
||||||
|
This scorer uses `token_set_ratio` as the base (sensitive to actual
|
||||||
|
word overlap, not just substring containment). It then applies a
|
||||||
|
multi-digit penalty: if both strings contain 2+-digit numbers and
|
||||||
|
none overlap, subtract 0.30. Catches the "same project, different
|
||||||
|
address-style identifier" case ('87 Jenks' vs '68 Jenks') that pure
|
||||||
|
token-set scoring still rates above 0.90.
|
||||||
|
|
||||||
|
Single-digit numbers ('Loc 1', 'Area 2') are excluded from the
|
||||||
|
penalty because they're often shared boilerplate ("Loc 1" in every
|
||||||
|
project) rather than discriminating identifiers.
|
||||||
|
"""
|
||||||
|
if not a or not b:
|
||||||
|
return 0.0
|
||||||
|
if a == b:
|
||||||
|
return 1.0
|
||||||
|
if min(len(a), len(b)) < _MIN_FUZZY_LEN:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
base = rapidfuzz.fuzz.token_set_ratio(a, b) / 100.0
|
||||||
|
|
||||||
|
multidigits_a = set(re.findall(r"\d{2,}", a))
|
||||||
|
multidigits_b = set(re.findall(r"\d{2,}", b))
|
||||||
|
if multidigits_a and multidigits_b and not (multidigits_a & multidigits_b):
|
||||||
|
base = max(0.0, base - _LOCATION_DIGIT_MISMATCH_PENALTY)
|
||||||
|
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
# ── Cluster + Suggestion dataclasses ───────────────────────────────────────────
|
# ── Cluster + Suggestion dataclasses ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -572,15 +621,24 @@ async def _scan_clusters(
|
|||||||
def _find_best_match(
|
def _find_best_match(
|
||||||
candidate_norm: str,
|
candidate_norm: str,
|
||||||
candidates: list[tuple[str, str]], # (id, normalised_name)
|
candidates: list[tuple[str, str]], # (id, normalised_name)
|
||||||
|
*,
|
||||||
|
kind: str = "project", # "project" | "location"
|
||||||
) -> tuple[Optional[str], Optional[float], str]:
|
) -> tuple[Optional[str], Optional[float], str]:
|
||||||
"""Return (best_id, best_score, classification).
|
"""Return (best_id, best_score, classification).
|
||||||
|
|
||||||
classification ∈ {"exact", "fuzzy", "ambiguous", "no_match"}
|
classification ∈ {"exact", "fuzzy", "ambiguous", "no_match"}
|
||||||
|
|
||||||
|
The `kind` parameter selects the scorer. Project matching uses
|
||||||
|
rapidfuzz.WRatio (lenient — catches typos like '1-80' vs 'I-80').
|
||||||
|
Location matching uses `location_similarity` (stricter — catches
|
||||||
|
boilerplate-shared-but-actually-different strings like 'Loc 2 - 68
|
||||||
|
Jenks' vs 'Loc 1 - 87 Jenks').
|
||||||
"""
|
"""
|
||||||
if not candidate_norm or not candidates:
|
if not candidate_norm or not candidates:
|
||||||
return None, None, "no_match"
|
return None, None, "no_match"
|
||||||
|
|
||||||
scored = [(cid, similarity(candidate_norm, cnorm)) for cid, cnorm in candidates]
|
scorer = location_similarity if kind == "location" else similarity
|
||||||
|
scored = [(cid, scorer(candidate_norm, cnorm)) for cid, cnorm in candidates]
|
||||||
scored.sort(key=lambda x: x[1], reverse=True)
|
scored.sort(key=lambda x: x[1], reverse=True)
|
||||||
best_id, best_score = scored[0]
|
best_id, best_score = scored[0]
|
||||||
|
|
||||||
@@ -725,7 +783,7 @@ def _build_suggestion(db: Session, cluster: Cluster) -> Suggestion:
|
|||||||
)
|
)
|
||||||
location_candidates = [(l.id, _normalise(l.name)) for l in location_candidates_objs]
|
location_candidates = [(l.id, _normalise(l.name)) for l in location_candidates_objs]
|
||||||
if cluster.location_norm:
|
if cluster.location_norm:
|
||||||
loc_id, loc_score, loc_match = _find_best_match(cluster.location_norm, location_candidates)
|
loc_id, loc_score, loc_match = _find_best_match(cluster.location_norm, location_candidates, kind="location")
|
||||||
else:
|
else:
|
||||||
loc_id, loc_score, loc_match = None, None, "create_new"
|
loc_id, loc_score, loc_match = None, None, "create_new"
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -321,6 +321,11 @@ async def events_for_unit(
|
|||||||
"assignment_id": a.id,
|
"assignment_id": a.id,
|
||||||
"location_id": a.location_id,
|
"location_id": a.location_id,
|
||||||
"location_name": loc.name if loc else None,
|
"location_name": loc.name if loc else None,
|
||||||
|
# Soft-removal indicator so the UI can render a "(removed)"
|
||||||
|
# badge next to historical attributions whose location is no
|
||||||
|
# longer actively monitored.
|
||||||
|
"location_removed_at": (loc.removed_at.isoformat()
|
||||||
|
if loc and loc.removed_at else None),
|
||||||
"project_id": a.project_id,
|
"project_id": a.project_id,
|
||||||
"project_name": proj.name if proj else None,
|
"project_name": proj.name if proj else None,
|
||||||
"assigned_at": _iso_utc(a.assigned_at),
|
"assigned_at": _iso_utc(a.assigned_at),
|
||||||
@@ -515,6 +520,10 @@ async def vibration_summary_for_project(
|
|||||||
"event_count": ec,
|
"event_count": ec,
|
||||||
"peak_pvs": ev_peak,
|
"peak_pvs": ev_peak,
|
||||||
"last_event": ev_last,
|
"last_event": ev_last,
|
||||||
|
# Soft-removal state — UI can show a "(removed)" badge in the
|
||||||
|
# per-location list so operators see at a glance that a row's
|
||||||
|
# numbers are historical-only.
|
||||||
|
"removed_at": loc.removed_at.isoformat() if loc.removed_at else None,
|
||||||
})
|
})
|
||||||
|
|
||||||
per_location.sort(key=lambda r: r["event_count"], reverse=True)
|
per_location.sort(key=lambda r: r["event_count"], reverse=True)
|
||||||
|
|||||||
@@ -275,7 +275,13 @@ async function _fetchTypeahead(input, fieldKind) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Args go in data-* attributes (not inline onclick) to avoid the quote
|
||||||
|
// collision when location names contain characters JSON.stringify quotes
|
||||||
|
// (e.g. anything with spaces/punctuation — basically every real name).
|
||||||
|
// _esc() escapes for HTML attribute context (entities for <>&"), then
|
||||||
|
// the browser decodes them when reading the dataset value.
|
||||||
dropdown.innerHTML = items.map((it, idx) => {
|
dropdown.innerHTML = items.map((it, idx) => {
|
||||||
|
const cid = _esc(input.dataset.clusterId);
|
||||||
if (it.kind === 'match') {
|
if (it.kind === 'match') {
|
||||||
const m = it.payload;
|
const m = it.payload;
|
||||||
const scoreBadge = m.score >= 0.99
|
const scoreBadge = m.score >= 0.99
|
||||||
@@ -291,16 +297,24 @@ async function _fetchTypeahead(input, fieldKind) {
|
|||||||
}
|
}
|
||||||
const metaLine = meta.length ? `<div class="text-xs text-gray-500 dark:text-gray-400">${meta.join(' · ')}</div>` : '';
|
const metaLine = meta.length ? `<div class="text-xs text-gray-500 dark:text-gray-400">${meta.join(' · ')}</div>` : '';
|
||||||
return `<button type="button"
|
return `<button type="button"
|
||||||
|
data-cid="${cid}"
|
||||||
|
data-field-kind="${fieldKind}"
|
||||||
|
data-entity-id="${_esc(m.id)}"
|
||||||
|
data-entity-name="${_esc(m.name)}"
|
||||||
onmousedown="event.preventDefault()"
|
onmousedown="event.preventDefault()"
|
||||||
onclick="onTypeaheadPick(event, '${_esc(input.dataset.clusterId)}', '${fieldKind}', '${_esc(m.id)}', ${JSON.stringify(m.name)})"
|
onclick="_typeaheadPickFromButton(this)"
|
||||||
class="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-slate-700 border-b border-gray-100 dark:border-gray-700 last:border-b-0">
|
class="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-slate-700 border-b border-gray-100 dark:border-gray-700 last:border-b-0">
|
||||||
<div class="text-sm font-medium text-gray-900 dark:text-white">${_esc(m.name)}${scoreBadge}</div>
|
<div class="text-sm font-medium text-gray-900 dark:text-white">${_esc(m.name)}${scoreBadge}</div>
|
||||||
${metaLine}
|
${metaLine}
|
||||||
</button>`;
|
</button>`;
|
||||||
}
|
}
|
||||||
return `<button type="button"
|
return `<button type="button"
|
||||||
|
data-cid="${cid}"
|
||||||
|
data-field-kind="${fieldKind}"
|
||||||
|
data-entity-id=""
|
||||||
|
data-entity-name="${_esc(it.name)}"
|
||||||
onmousedown="event.preventDefault()"
|
onmousedown="event.preventDefault()"
|
||||||
onclick="onTypeaheadPick(event, '${_esc(input.dataset.clusterId)}', '${fieldKind}', '', ${JSON.stringify(it.name)})"
|
onclick="_typeaheadPickFromButton(this)"
|
||||||
class="w-full text-left px-3 py-2 hover:bg-orange-50 dark:hover:bg-orange-900/20 border-t border-gray-200 dark:border-gray-700 text-seismo-orange font-medium text-sm">
|
class="w-full text-left px-3 py-2 hover:bg-orange-50 dark:hover:bg-orange-900/20 border-t border-gray-200 dark:border-gray-700 text-seismo-orange font-medium text-sm">
|
||||||
+ ${_esc(it.label)}
|
+ ${_esc(it.label)}
|
||||||
</button>`;
|
</button>`;
|
||||||
@@ -308,6 +322,19 @@ async function _fetchTypeahead(input, fieldKind) {
|
|||||||
dropdown.classList.remove('hidden');
|
dropdown.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trampoline — reads the click target's data-* attributes and forwards
|
||||||
|
// to onTypeaheadPick. Keeps the inline onclick attribute free of any
|
||||||
|
// string interpolation that could collide with HTML quoting.
|
||||||
|
function _typeaheadPickFromButton(btn) {
|
||||||
|
onTypeaheadPick(
|
||||||
|
null,
|
||||||
|
btn.dataset.cid,
|
||||||
|
btn.dataset.fieldKind,
|
||||||
|
btn.dataset.entityId || '',
|
||||||
|
btn.dataset.entityName || ''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function onTypeaheadPick(event, clusterId, fieldKind, entityId, name) {
|
function onTypeaheadPick(event, clusterId, fieldKind, entityId, name) {
|
||||||
// entityId is empty string for "create new", or a UUID for matched existing.
|
// entityId is empty string for "create new", or a UUID for matched existing.
|
||||||
const inputs = document.querySelectorAll(`input[data-cluster-id="${clusterId}"]`);
|
const inputs = document.querySelectorAll(`input[data-cluster-id="${clusterId}"]`);
|
||||||
|
|||||||
@@ -1,16 +1,58 @@
|
|||||||
<!-- Project Locations List -->
|
<!-- Project Locations List — Active + Removed sections.
|
||||||
{% if locations %}
|
|
||||||
<div class="space-y-3">
|
Card layout:
|
||||||
{% for item in locations %}
|
[drag handle] [location info] [unit pill] [⋮ menu]
|
||||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:border-seismo-orange transition-colors">
|
(name link, description, address, sessions/events, coords)
|
||||||
|
|
||||||
|
Active cards are draggable to reorder. Drop reorders the DOM
|
||||||
|
immediately and posts the new order to /api/projects/{p}/locations/reorder.
|
||||||
|
|
||||||
|
Removed cards are NOT reorderable (their order is historical) but
|
||||||
|
show a Restore button.
|
||||||
|
|
||||||
|
The three-dot menu replaces the inline Unassign/Edit/Remove/Delete
|
||||||
|
pill buttons. Click ⋮ to open; click outside closes.
|
||||||
|
-->
|
||||||
|
|
||||||
|
{% if not active_locations and not removed_locations %}
|
||||||
|
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
|
<svg class="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||||
|
</svg>
|
||||||
|
<p>No locations added yet</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
{# ─── Active locations (draggable) ─── #}
|
||||||
|
{% if active_locations %}
|
||||||
|
<div class="space-y-3" id="active-locations-list" data-project-id="{{ project.id }}">
|
||||||
|
{% for item in active_locations %}
|
||||||
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:border-seismo-orange transition-colors location-card"
|
||||||
|
draggable="true"
|
||||||
|
data-location-id="{{ item.location.id }}"
|
||||||
|
data-location-type="{{ item.location.location_type or 'sound' }}"
|
||||||
|
data-location-name="{{ item.location.name | e }}"
|
||||||
|
data-coordinates="{{ item.location.coordinates or '' }}"
|
||||||
|
ondragstart="onLocationDragStart(event)"
|
||||||
|
ondragover="onLocationDragOver(event)"
|
||||||
|
ondragleave="onLocationDragLeave(event)"
|
||||||
|
ondrop="onLocationDrop(event)"
|
||||||
|
ondragend="onLocationDragEnd(event)">
|
||||||
|
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<!-- Drag handle + info -->
|
||||||
|
<div class="flex items-start gap-3 min-w-0 flex-1">
|
||||||
|
<div class="shrink-0 pt-0.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 cursor-grab active:cursor-grabbing select-none"
|
||||||
|
title="Drag to reorder">
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M7 4a1 1 0 110 2 1 1 0 010-2zm6 0a1 1 0 110 2 1 1 0 010-2zM7 9a1 1 0 110 2 1 1 0 010-2zm6 0a1 1 0 110 2 1 1 0 010-2zM7 14a1 1 0 110 2 1 1 0 010-2zm6 0a1 1 0 110 2 1 1 0 010-2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<a href="/projects/{{ project.id }}/nrl/{{ item.location.id }}"
|
<a href="/projects/{{ project.id }}/nrl/{{ item.location.id }}"
|
||||||
class="font-semibold text-gray-900 dark:text-white hover:text-seismo-orange truncate">
|
class="font-semibold text-gray-900 dark:text-white hover:text-seismo-orange truncate">
|
||||||
{{ item.location.name }}
|
{{ item.location.name }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
|
||||||
{% if item.location.description %}
|
{% if item.location.description %}
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.description }}</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.description }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -20,45 +62,241 @@
|
|||||||
{% if item.location.coordinates %}
|
{% if item.location.coordinates %}
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.coordinates }}</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.coordinates }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mt-3 text-xs text-gray-500 dark:text-gray-400 flex flex-wrap gap-4">
|
||||||
|
{% if item.event_count is defined and item.location.location_type == 'vibration' %}
|
||||||
|
<span><strong class="text-gray-700 dark:text-gray-300">{{ "{:,}".format(item.event_count) }}</strong> event{{ '' if item.event_count == 1 else 's' }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span>Sessions: {{ item.session_count }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.assignment and item.assigned_unit %}
|
||||||
|
<span>Assigned: <a href="/unit/{{ item.assigned_unit.id }}" class="text-seismo-orange hover:text-seismo-navy font-mono">{{ item.assigned_unit.id }}</a></span>
|
||||||
|
{% else %}
|
||||||
|
<span class="italic text-gray-400 dark:text-gray-500">No active assignment</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<!-- Right column: small assign/unassign pill + 3-dot menu -->
|
||||||
{% if item.assignment %}
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
<button onclick="unassignUnit('{{ item.assignment.id }}')" class="text-xs px-3 py-1 rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
|
{% if not item.assignment %}
|
||||||
Unassign
|
<!-- Primary action: visible because the unassigned card
|
||||||
</button>
|
is most likely getting clicked on right after creation -->
|
||||||
{% else %}
|
<button onclick="openAssignModal('{{ item.location.id }}', '{{ item.location.location_type or 'sound' }}')"
|
||||||
<button onclick="openAssignModal('{{ item.location.id }}', '{{ item.location.location_type or 'sound' }}')" class="text-xs px-3 py-1 rounded-full bg-seismo-orange text-white hover:bg-seismo-navy">
|
class="text-xs px-3 py-1 rounded-full bg-seismo-orange text-white hover:bg-seismo-navy">
|
||||||
Assign
|
Assign
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Three-dot kebab menu -->
|
||||||
|
<div class="relative inline-block location-menu-wrapper">
|
||||||
|
<button onclick="toggleLocationMenu(event, this)"
|
||||||
|
class="p-1.5 rounded-full text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
title="More actions">
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zm0 6a2 2 0 110-4 2 2 0 010 4zm0 6a2 2 0 110-4 2 2 0 010 4z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="location-menu hidden absolute right-0 mt-1 w-40 z-30 bg-white dark:bg-slate-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg py-1">
|
||||||
|
{% if item.assignment %}
|
||||||
|
<button onclick="unassignUnit('{{ item.assignment.id }}'); closeAllLocationMenus()"
|
||||||
|
class="w-full text-left px-3 py-1.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
Unassign
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
<button data-location='{{ {"id": item.location.id, "name": item.location.name, "description": item.location.description, "address": item.location.address, "coordinates": item.location.coordinates, "location_type": item.location.location_type} | tojson }}'
|
<button data-location='{{ {"id": item.location.id, "name": item.location.name, "description": item.location.description, "address": item.location.address, "coordinates": item.location.coordinates, "location_type": item.location.location_type} | tojson }}'
|
||||||
onclick="openEditLocationModal(this)"
|
onclick="openEditLocationModal(this); closeAllLocationMenus()"
|
||||||
class="text-xs px-3 py-1 rounded-full bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
class="w-full text-left px-3 py-1.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button onclick="deleteLocation('{{ item.location.id }}')" class="text-xs px-3 py-1 rounded-full bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
<button data-loc-id="{{ item.location.id }}"
|
||||||
|
data-loc-name="{{ item.location.name | e }}"
|
||||||
|
onclick="openRemoveLocationModal(this.dataset.locId, this.dataset.locName); closeAllLocationMenus()"
|
||||||
|
class="w-full text-left px-3 py-1.5 text-sm text-amber-700 dark:text-amber-300 hover:bg-amber-50 dark:hover:bg-amber-900/20"
|
||||||
|
title="Mark as no longer monitored — preserves events">
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
<div class="border-t border-gray-100 dark:border-gray-700 my-1"></div>
|
||||||
|
<button onclick="deleteLocation('{{ item.location.id }}'); closeAllLocationMenus()"
|
||||||
|
class="w-full text-left px-3 py-1.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||||
|
title="Permanently delete — only allowed if no history">
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ─── Removed locations (collapsed by default) ─── #}
|
||||||
|
{% if removed_locations %}
|
||||||
|
<details class="mt-6 group" {% if not active_locations %}open{% endif %}>
|
||||||
|
<summary class="cursor-pointer text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 select-none list-none">
|
||||||
|
<span class="inline-flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
Removed locations
|
||||||
|
<span class="text-xs px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">{{ removed_locations | length }}</span>
|
||||||
|
</span>
|
||||||
|
<p class="ml-6 mt-1 text-xs text-gray-400 dark:text-gray-500">Historical only — events stay attributed, but no new assignments or schedules can be created here.</p>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div class="space-y-3 mt-3">
|
||||||
|
{% for item in removed_locations %}
|
||||||
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-gray-50 dark:bg-slate-900/30 opacity-75">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<a href="/projects/{{ project.id }}/nrl/{{ item.location.id }}"
|
||||||
|
class="font-semibold text-gray-700 dark:text-gray-300 hover:text-seismo-orange truncate">
|
||||||
|
{{ item.location.name }}
|
||||||
|
</a>
|
||||||
|
<span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 font-semibold">
|
||||||
|
Removed
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ item.location.removed_at.strftime('%Y-%m-%d') if item.location.removed_at else '—' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% if item.location.removal_reason %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 italic">"{{ item.location.removal_reason }}"</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.location.description %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.location.address %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.address }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button data-loc-id="{{ item.location.id }}"
|
||||||
|
data-loc-name="{{ item.location.name | e }}"
|
||||||
|
onclick="restoreLocation(this.dataset.locId, this.dataset.locName)"
|
||||||
|
class="text-xs px-3 py-1 rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300 hover:bg-green-200"
|
||||||
|
title="Restore to active monitoring">
|
||||||
|
Restore
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-3 text-xs text-gray-500 dark:text-gray-400 flex flex-wrap gap-4">
|
<div class="mt-3 text-xs text-gray-500 dark:text-gray-400 flex flex-wrap gap-4">
|
||||||
<span>Sessions: {{ item.session_count }}</span>
|
{% if item.event_count is defined and item.location.location_type == 'vibration' %}
|
||||||
{% if item.assignment and item.assigned_unit %}
|
<span>{{ "{:,}".format(item.event_count) }} historical event{{ '' if item.event_count == 1 else 's' }}</span>
|
||||||
<span>Assigned: {{ item.assigned_unit.id }}</span>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<span>No active assignment</span>
|
<span>Historical sessions: {{ item.session_count }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
</details>
|
||||||
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
|
||||||
<svg class="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
|
||||||
</svg>
|
|
||||||
<p>No locations added yet</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Drag-and-drop + menu handlers, scoped to this partial (re-defined
|
||||||
|
on every htmx swap, which is harmless — function declarations
|
||||||
|
overwrite). -->
|
||||||
|
<script>
|
||||||
|
let _dragSrcCard = null;
|
||||||
|
|
||||||
|
function onLocationDragStart(e) {
|
||||||
|
_dragSrcCard = e.currentTarget;
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
// Required for Firefox to start the drag.
|
||||||
|
e.dataTransfer.setData('text/plain', _dragSrcCard.dataset.locationId);
|
||||||
|
e.currentTarget.classList.add('opacity-40');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLocationDragOver(e) {
|
||||||
|
if (!_dragSrcCard || e.currentTarget === _dragSrcCard) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
e.currentTarget.classList.add('ring-2', 'ring-seismo-orange');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLocationDragLeave(e) {
|
||||||
|
e.currentTarget.classList.remove('ring-2', 'ring-seismo-orange');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLocationDrop(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.currentTarget.classList.remove('ring-2', 'ring-seismo-orange');
|
||||||
|
if (!_dragSrcCard || e.currentTarget === _dragSrcCard) return;
|
||||||
|
|
||||||
|
const list = document.getElementById('active-locations-list');
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
// Drop AFTER the target by default; if mouse is in top half, drop BEFORE.
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const dropBefore = (e.clientY - rect.top) < rect.height / 2;
|
||||||
|
if (dropBefore) {
|
||||||
|
list.insertBefore(_dragSrcCard, e.currentTarget);
|
||||||
|
} else {
|
||||||
|
list.insertBefore(_dragSrcCard, e.currentTarget.nextSibling);
|
||||||
|
}
|
||||||
|
|
||||||
|
_persistLocationOrder(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLocationDragEnd(e) {
|
||||||
|
e.currentTarget.classList.remove('opacity-40');
|
||||||
|
document.querySelectorAll('.location-card').forEach(c => {
|
||||||
|
c.classList.remove('ring-2', 'ring-seismo-orange');
|
||||||
|
});
|
||||||
|
_dragSrcCard = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _persistLocationOrder(list) {
|
||||||
|
const projectId = list.dataset.projectId;
|
||||||
|
const ids = Array.from(list.querySelectorAll('.location-card'))
|
||||||
|
.map(c => c.dataset.locationId);
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/projects/${projectId}/locations/reorder`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ location_ids: ids }),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const err = await r.json().catch(() => ({ detail: 'HTTP ' + r.status }));
|
||||||
|
throw new Error(err.detail || 'reorder failed');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save new order:', err);
|
||||||
|
if (typeof showToast === 'function') showToast('Failed to save new order: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Three-dot menu ─────────────────────────────────────────────────
|
||||||
|
function toggleLocationMenu(e, btn) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const menu = btn.parentElement.querySelector('.location-menu');
|
||||||
|
const wasOpen = !menu.classList.contains('hidden');
|
||||||
|
closeAllLocationMenus();
|
||||||
|
if (!wasOpen) {
|
||||||
|
menu.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAllLocationMenus() {
|
||||||
|
document.querySelectorAll('.location-menu').forEach(m => m.classList.add('hidden'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close menus on outside click (only register once globally).
|
||||||
|
if (!window._locationMenuOutsideClickRegistered) {
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!e.target.closest('.location-menu-wrapper')) closeAllLocationMenus();
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') closeAllLocationMenus();
|
||||||
|
});
|
||||||
|
window._locationMenuOutsideClickRegistered = true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -78,22 +78,128 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
<!-- Location Map — replaces the old Upcoming Actions panel for the
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Upcoming Actions</h3>
|
overview. Operators get a quick visual of where their locations
|
||||||
|
sit relative to each other. Pins clickable → scroll to + flash
|
||||||
|
the matching card. Locations without coordinates land in a
|
||||||
|
"missing coords" hint below the map.
|
||||||
|
For projects with scheduled monitoring activity, the full
|
||||||
|
Upcoming Actions list is still available on the Schedules tab. -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Location Map</h3>
|
||||||
{% if upcoming_actions %}
|
{% if upcoming_actions %}
|
||||||
<div class="space-y-3">
|
<a href="javascript:void(0)" onclick="switchTab('schedules')"
|
||||||
{% for action in upcoming_actions %}
|
class="text-xs text-seismo-orange hover:text-seismo-navy whitespace-nowrap">
|
||||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-3">
|
{{ upcoming_actions | length }} upcoming action{{ '' if upcoming_actions | length == 1 else 's' }} →
|
||||||
<p class="font-medium text-gray-900 dark:text-white">{{ action.action_type }}</p>
|
</a>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ action.scheduled_time|local_datetime }} {{ timezone_abbr() }}</p>
|
|
||||||
{% if action.description %}
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ action.description }}</p>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
<!-- `isolation: isolate` forces a new stacking context so Leaflet's
|
||||||
|
internal z-indexes (panes at 200-700, controls at 800) stay
|
||||||
|
contained inside this div instead of leaking into the root
|
||||||
|
stacking context and rendering over modals (which have z-50). -->
|
||||||
|
<div id="project-location-map" class="w-full rounded-lg border border-gray-200 dark:border-gray-700"
|
||||||
|
style="height: 320px; background: rgba(0,0,0,0.05); isolation: isolate;"></div>
|
||||||
|
<div id="project-location-map-empty" class="hidden text-xs text-gray-500 dark:text-gray-400 mt-2 italic text-center">
|
||||||
|
No location coordinates set. Edit a location and add a <code class="font-mono">lat,lon</code> pair to see it here.
|
||||||
|
</div>
|
||||||
|
<div id="project-location-map-missing" class="hidden text-xs text-gray-500 dark:text-gray-400 mt-2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
// Build location data from server-side render. Skip removed
|
||||||
|
// locations (their pins would clutter the active operations view)
|
||||||
|
// and skip ones without parseable coordinates.
|
||||||
|
const locationsRaw = [
|
||||||
|
{% for loc in locations %}
|
||||||
|
{% if not loc.removed_at %}
|
||||||
|
{
|
||||||
|
id: {{ loc.id | tojson }},
|
||||||
|
name: {{ loc.name | tojson }},
|
||||||
|
coords: {{ loc.coordinates | tojson if loc.coordinates else 'null' }},
|
||||||
|
}{% if not loop.last %},{% endif %}
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
];
|
||||||
{% else %}
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">No scheduled actions.</p>
|
function parseCoords(s) {
|
||||||
{% endif %}
|
if (!s) return null;
|
||||||
</div>
|
const parts = String(s).split(',').map(x => parseFloat(x.trim()));
|
||||||
</div>
|
if (parts.length !== 2 || parts.some(isNaN)) return null;
|
||||||
|
const [lat, lon] = parts;
|
||||||
|
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null;
|
||||||
|
return [lat, lon];
|
||||||
|
}
|
||||||
|
|
||||||
|
const withCoords = [];
|
||||||
|
const withoutCoords = [];
|
||||||
|
for (const loc of locationsRaw) {
|
||||||
|
const xy = parseCoords(loc.coords);
|
||||||
|
if (xy) withCoords.push({ ...loc, latlon: xy });
|
||||||
|
else withoutCoords.push(loc);
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyMsg = document.getElementById('project-location-map-empty');
|
||||||
|
const missingMsg = document.getElementById('project-location-map-missing');
|
||||||
|
const mapEl = document.getElementById('project-location-map');
|
||||||
|
if (!mapEl) return;
|
||||||
|
|
||||||
|
if (withCoords.length === 0) {
|
||||||
|
// Hide the map block and show a hint. Don't init Leaflet at all.
|
||||||
|
mapEl.classList.add('hidden');
|
||||||
|
emptyMsg.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialise Leaflet. `L` is loaded globally by base.html.
|
||||||
|
const map = L.map(mapEl, { scrollWheelZoom: false });
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© OpenStreetMap',
|
||||||
|
maxZoom: 18,
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
const markers = [];
|
||||||
|
const bounds = [];
|
||||||
|
withCoords.forEach(loc => {
|
||||||
|
const marker = L.circleMarker(loc.latlon, {
|
||||||
|
radius: 8,
|
||||||
|
fillColor: '#f48b1c',
|
||||||
|
color: '#fff',
|
||||||
|
weight: 2,
|
||||||
|
opacity: 1,
|
||||||
|
fillOpacity: 0.9,
|
||||||
|
}).addTo(map);
|
||||||
|
marker.bindTooltip(loc.name, { direction: 'top', offset: [0, -6] });
|
||||||
|
marker.on('click', () => _flashLocationCard(loc.id));
|
||||||
|
markers.push(marker);
|
||||||
|
bounds.push(loc.latlon);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (bounds.length === 1) {
|
||||||
|
map.setView(bounds[0], 14);
|
||||||
|
} else {
|
||||||
|
map.fitBounds(bounds, { padding: [20, 20] });
|
||||||
|
}
|
||||||
|
// Without this the map renders into a 0×0 area when the partial
|
||||||
|
// first lands via htmx (container size not yet stable).
|
||||||
|
setTimeout(() => map.invalidateSize(), 100);
|
||||||
|
|
||||||
|
if (withoutCoords.length > 0) {
|
||||||
|
const names = withoutCoords.map(l => l.name).join(', ');
|
||||||
|
missingMsg.textContent = `${withoutCoords.length} location${withoutCoords.length === 1 ? '' : 's'} not shown (no coordinates): ${names}`;
|
||||||
|
missingMsg.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Briefly highlight the matching card to confirm the click.
|
||||||
|
function _flashLocationCard(locId) {
|
||||||
|
const card = document.querySelector(`.location-card[data-location-id="${locId}"]`);
|
||||||
|
if (!card) return;
|
||||||
|
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
card.classList.add('ring-2', 'ring-seismo-orange');
|
||||||
|
setTimeout(() => card.classList.remove('ring-2', 'ring-seismo-orange'), 1500);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -87,9 +87,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Merge Modal -->
|
<!-- Merge Modal —
|
||||||
|
min-h on the body ensures the typeahead dropdown has room to render
|
||||||
|
below the input without forcing the operator to scroll inside the
|
||||||
|
modal. overflow-visible on the body lets the dropdown extend
|
||||||
|
beyond the body's natural height when needed. -->
|
||||||
<div id="merge-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
<div id="merge-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl mx-4 max-h-[90vh] flex flex-col">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl mx-4 max-h-[90vh] flex flex-col"
|
||||||
|
style="min-height: 480px;">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -104,7 +109,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
<div class="px-6 py-4 overflow-y-auto flex-1">
|
<div class="px-6 py-4 overflow-y-auto flex-1 min-h-[320px]">
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Target project
|
Target project
|
||||||
</label>
|
</label>
|
||||||
@@ -202,6 +207,10 @@ async function _mergeFetchTargets() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stash target id + name in data-* attributes (NOT inline JS args)
|
||||||
|
// to avoid the quote-collision that breaks click binding when the
|
||||||
|
// project name contains characters JSON.stringify quotes. Same
|
||||||
|
// pattern as the backfill typeahead dropdown.
|
||||||
dropdown.innerHTML = candidates.map(m => {
|
dropdown.innerHTML = candidates.map(m => {
|
||||||
const scoreBadge = m.score >= 0.99
|
const scoreBadge = m.score >= 0.99
|
||||||
? '<span class="text-xs text-green-600 dark:text-green-400 ml-2">exact</span>'
|
? '<span class="text-xs text-green-600 dark:text-green-400 ml-2">exact</span>'
|
||||||
@@ -212,8 +221,10 @@ async function _mergeFetchTargets() {
|
|||||||
if (m.location_count > 0) meta.push(`${m.location_count} location${m.location_count === 1 ? '' : 's'}`);
|
if (m.location_count > 0) meta.push(`${m.location_count} location${m.location_count === 1 ? '' : 's'}`);
|
||||||
const metaLine = meta.length ? `<div class="text-xs text-gray-500 dark:text-gray-400">${meta.join(' · ')}</div>` : '';
|
const metaLine = meta.length ? `<div class="text-xs text-gray-500 dark:text-gray-400">${meta.join(' · ')}</div>` : '';
|
||||||
return `<button type="button"
|
return `<button type="button"
|
||||||
|
data-target-id="${_mergeEsc(m.id)}"
|
||||||
|
data-target-name="${_mergeEsc(m.name)}"
|
||||||
onmousedown="event.preventDefault()"
|
onmousedown="event.preventDefault()"
|
||||||
onclick="onMergePickTarget('${_mergeEsc(m.id)}', ${JSON.stringify(m.name)})"
|
onclick="_mergePickFromButton(this)"
|
||||||
class="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-slate-700 border-b border-gray-100 dark:border-gray-700 last:border-b-0">
|
class="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-slate-700 border-b border-gray-100 dark:border-gray-700 last:border-b-0">
|
||||||
<div class="text-sm font-medium text-gray-900 dark:text-white">${_mergeEsc(m.name)}${scoreBadge}</div>
|
<div class="text-sm font-medium text-gray-900 dark:text-white">${_mergeEsc(m.name)}${scoreBadge}</div>
|
||||||
${metaLine}
|
${metaLine}
|
||||||
@@ -222,6 +233,13 @@ async function _mergeFetchTargets() {
|
|||||||
dropdown.classList.remove('hidden');
|
dropdown.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trampoline — reads the button's data attributes and forwards. Keeps
|
||||||
|
// the inline onclick free of any string interpolation that could break
|
||||||
|
// HTML quoting (see notes on the same pattern in metadata_backfill.html).
|
||||||
|
function _mergePickFromButton(btn) {
|
||||||
|
onMergePickTarget(btn.dataset.targetId, btn.dataset.targetName);
|
||||||
|
}
|
||||||
|
|
||||||
async function onMergePickTarget(targetId, targetName) {
|
async function onMergePickTarget(targetId, targetName) {
|
||||||
document.getElementById('merge-target-input').value = targetName;
|
document.getElementById('merge-target-input').value = targetName;
|
||||||
document.getElementById('merge-target-id').value = targetId;
|
document.getElementById('merge-target-id').value = targetId;
|
||||||
|
|||||||
@@ -60,6 +60,10 @@
|
|||||||
class="flex items-center justify-between py-1.5 px-3 rounded hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors">
|
class="flex items-center justify-between py-1.5 px-3 rounded hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors">
|
||||||
<span class="text-sm font-medium text-gray-900 dark:text-white truncate">
|
<span class="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||||
📍 {{ loc.location_name }}
|
📍 {{ loc.location_name }}
|
||||||
|
{% if loc.removed_at %}
|
||||||
|
<span class="ml-1 text-[10px] uppercase tracking-wider px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 font-semibold align-middle"
|
||||||
|
title="Location no longer actively monitored — events shown are historical">removed</span>
|
||||||
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
<span class="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap ml-3">
|
<span class="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap ml-3">
|
||||||
<span>{{ "{:,}".format(loc.event_count) }} event{{ '' if loc.event_count == 1 else 's' }}</span>
|
<span>{{ "{:,}".format(loc.event_count) }} event{{ '' if loc.event_count == 1 else 's' }}</span>
|
||||||
|
|||||||
@@ -778,6 +778,61 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Remove Location Confirmation Modal —
|
||||||
|
Soft-removal: preserves historical events, closes active assignments,
|
||||||
|
cancels pending scheduled actions. Distinct from Delete (which is
|
||||||
|
permanent and only allowed when there's no history). -->
|
||||||
|
<div id="remove-location-modal" class="hidden fixed inset-0 bg-black bg-opacity-60 z-50 flex items-center justify-center">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md m-4 p-6">
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<div class="p-2 bg-amber-100 dark:bg-amber-900/40 rounded-lg">
|
||||||
|
<svg class="w-6 h-6 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2a4 4 0 014-4h6m0 0l-3-3m3 3l-3 3M5 7h8a2 2 0 012 2v10a2 2 0 01-2 2H5a2 2 0 01-2-2V9a2 2 0 012-2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Remove location</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
Mark <span id="remove-location-name" class="font-semibold text-gray-900 dark:text-white">…</span> as no longer actively monitored.
|
||||||
|
</p>
|
||||||
|
<ul class="text-xs text-gray-500 dark:text-gray-400 mb-4 space-y-1 ml-4 list-disc">
|
||||||
|
<li>Closes any active unit assignment at this location</li>
|
||||||
|
<li>Cancels pending scheduled actions at this location</li>
|
||||||
|
<li>Historical events stay attributed (visible in reports + event lists)</li>
|
||||||
|
<li>Can be restored later if needed</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<input type="hidden" id="remove-location-id">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Effective date</label>
|
||||||
|
<input type="datetime-local" id="remove-location-effective"
|
||||||
|
class="w-full px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
||||||
|
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-1">Defaults to now. Backdate if the location was physically removed earlier.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Reason (optional)</label>
|
||||||
|
<input type="text" id="remove-location-reason" maxlength="200"
|
||||||
|
placeholder="e.g. client dropped from scope"
|
||||||
|
class="w-full px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="remove-location-error" class="hidden text-sm text-red-600 mb-3"></div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button onclick="closeRemoveLocationModal()"
|
||||||
|
class="px-4 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button onclick="confirmRemoveLocation()"
|
||||||
|
class="px-4 py-1.5 text-sm bg-amber-600 hover:bg-amber-700 text-white rounded-lg font-medium">
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Delete Project Confirmation Modal -->
|
<!-- Delete Project Confirmation Modal -->
|
||||||
<div id="delete-project-modal" class="hidden fixed inset-0 bg-black bg-opacity-60 z-50 flex items-center justify-center">
|
<div id="delete-project-modal" class="hidden fixed inset-0 bg-black bg-opacity-60 z-50 flex items-center justify-center">
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md m-4 p-6">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md m-4 p-6">
|
||||||
@@ -1213,6 +1268,94 @@ async function deleteLocation(locationId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Remove / Restore location ────────────────────────────────────────
|
||||||
|
// Soft-removal: marks a location as no longer actively monitored without
|
||||||
|
// destroying it. Historical events stay attributed; active assignments
|
||||||
|
// are auto-closed and pending scheduled actions are auto-cancelled.
|
||||||
|
|
||||||
|
function openRemoveLocationModal(locationId, locationName) {
|
||||||
|
document.getElementById('remove-location-id').value = locationId;
|
||||||
|
document.getElementById('remove-location-name').textContent = locationName;
|
||||||
|
document.getElementById('remove-location-reason').value = '';
|
||||||
|
// Default effective_date to "now" in local datetime-input format.
|
||||||
|
const now = new Date();
|
||||||
|
const tzOffsetMin = now.getTimezoneOffset();
|
||||||
|
const local = new Date(now.getTime() - tzOffsetMin * 60000);
|
||||||
|
document.getElementById('remove-location-effective').value =
|
||||||
|
local.toISOString().slice(0, 16);
|
||||||
|
document.getElementById('remove-location-error').classList.add('hidden');
|
||||||
|
document.getElementById('remove-location-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeRemoveLocationModal() {
|
||||||
|
document.getElementById('remove-location-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRemoveLocation() {
|
||||||
|
const locationId = document.getElementById('remove-location-id').value;
|
||||||
|
const reason = document.getElementById('remove-location-reason').value.trim();
|
||||||
|
const effective = document.getElementById('remove-location-effective').value;
|
||||||
|
const errBox = document.getElementById('remove-location-error');
|
||||||
|
errBox.classList.add('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/projects/${projectId}/locations/${locationId}/remove`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
reason: reason || null,
|
||||||
|
effective_date: effective || null,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.detail || 'Failed to remove location');
|
||||||
|
}
|
||||||
|
const result = await response.json();
|
||||||
|
closeRemoveLocationModal();
|
||||||
|
refreshLocationLists();
|
||||||
|
refreshProjectDashboard();
|
||||||
|
// Lightweight feedback — the UI refresh already shows the location
|
||||||
|
// moving to the Removed section, but a toast confirms the cascade.
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
const bits = [];
|
||||||
|
if (result.assignments_closed) bits.push(`${result.assignments_closed} assignment(s) closed`);
|
||||||
|
if (result.actions_cancelled) bits.push(`${result.actions_cancelled} action(s) cancelled`);
|
||||||
|
const tail = bits.length ? ` (${bits.join(', ')})` : '';
|
||||||
|
showToast(`Location removed${tail}`, 'success');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errBox.textContent = err.message || 'Failed to remove location.';
|
||||||
|
errBox.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreLocation(locationId, locationName) {
|
||||||
|
if (!confirm(`Restore "${locationName}" to active monitoring?\n\nNote: previously-closed assignments are NOT automatically re-opened — you'll need to re-assign units if you want to resume monitoring.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/projects/${projectId}/locations/${locationId}/restore`,
|
||||||
|
{ method: 'POST' }
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.detail || 'Failed to restore location');
|
||||||
|
}
|
||||||
|
refreshLocationLists();
|
||||||
|
refreshProjectDashboard();
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast(`"${locationName}" restored to active`, 'success');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message || 'Failed to restore location.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Assign modal functions
|
// Assign modal functions
|
||||||
function openAssignModal(locationId, locationType) {
|
function openAssignModal(locationId, locationType) {
|
||||||
const safeType = locationType || 'sound';
|
const safeType = locationType || 'sound';
|
||||||
|
|||||||
+321
-7
@@ -287,6 +287,16 @@
|
|||||||
↻ Refresh
|
↻ Refresh
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Gantt chart — visual timeline of all deployments. Click
|
||||||
|
a bar to jump to its row in the list below. -->
|
||||||
|
<div id="deploymentGantt" class="mb-4 hidden">
|
||||||
|
<div class="bg-gray-50 dark:bg-slate-900/40 rounded-lg p-3">
|
||||||
|
<svg id="deploymentGanttSvg" class="w-full" style="height: 140px;" preserveAspectRatio="none"></svg>
|
||||||
|
<div id="deploymentGanttLegend" class="flex flex-wrap items-center gap-x-4 gap-y-1 mt-2 text-xs text-gray-500 dark:text-gray-400"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="deploymentTimeline" class="space-y-3">
|
<div id="deploymentTimeline" class="space-y-3">
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading timeline…</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">Loading timeline…</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1986,6 +1996,10 @@ loadUnitData().then(() => {
|
|||||||
// Replaces the legacy loadDeploymentHistory() + loadUnitHistory() pair.
|
// Replaces the legacy loadDeploymentHistory() + loadUnitHistory() pair.
|
||||||
// Derives entries from unit_assignments + unit_history + SFM event overlay.
|
// Derives entries from unit_assignments + unit_history + SFM event overlay.
|
||||||
|
|
||||||
|
// Cache the most recent timeline payload so the merge action can look up
|
||||||
|
// which assignment_ids belong together in a mergeable group.
|
||||||
|
let _dtCurrentTimeline = { entries: [], merge_groups: [] };
|
||||||
|
|
||||||
async function loadDeploymentTimeline() {
|
async function loadDeploymentTimeline() {
|
||||||
const container = document.getElementById('deploymentTimeline');
|
const container = document.getElementById('deploymentTimeline');
|
||||||
container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">Loading timeline…</p>';
|
container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">Loading timeline…</p>';
|
||||||
@@ -1994,12 +2008,58 @@ async function loadDeploymentTimeline() {
|
|||||||
const r = await fetch(`/api/units/${currentUnit.id}/deployment_timeline`);
|
const r = await fetch(`/api/units/${currentUnit.id}/deployment_timeline`);
|
||||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
renderDeploymentTimeline(d.entries || [], container);
|
_dtCurrentTimeline = {
|
||||||
|
entries: d.entries || [],
|
||||||
|
merge_groups: d.merge_groups || [],
|
||||||
|
};
|
||||||
|
renderDeploymentTimeline(_dtCurrentTimeline.entries, container, _dtCurrentTimeline.merge_groups);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
container.innerHTML = `<p class="text-sm text-red-500">Failed to load timeline: ${e.message}</p>`;
|
container.innerHTML = `<p class="text-sm text-red-500">Failed to load timeline: ${e.message}</p>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns the merge_group (list of assignment_ids) that this assignment is
|
||||||
|
// part of, or null if it isn't in any mergeable group.
|
||||||
|
function _dtFindMergeGroup(assignmentId) {
|
||||||
|
for (const group of _dtCurrentTimeline.merge_groups || []) {
|
||||||
|
if (group.includes(assignmentId)) return group;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mergeAssignmentGroup(assignmentIds) {
|
||||||
|
if (!Array.isArray(assignmentIds) || assignmentIds.length < 2) return;
|
||||||
|
const msg = `Merge ${assignmentIds.length} consecutive assignment records into one?\n\n`
|
||||||
|
+ `The earliest record is kept and its window extended to span all `
|
||||||
|
+ `of them. The other ${assignmentIds.length - 1} record(s) are deleted.\n\n`
|
||||||
|
+ `Original metadata (notes + ingest source) is preserved. This is `
|
||||||
|
+ `logged to the unit's history as "assignment_merged".`;
|
||||||
|
if (!confirm(msg)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// All assignments share the same project_id (validated server-side).
|
||||||
|
// Pick the first entry's project_id from the cache.
|
||||||
|
const first = (_dtCurrentTimeline.entries || []).find(e =>
|
||||||
|
e.kind === 'assignment' && assignmentIds.includes(e.assignment_id)
|
||||||
|
);
|
||||||
|
const projectId = first ? first.project_id : null;
|
||||||
|
if (!projectId) throw new Error('Could not resolve project id for this group');
|
||||||
|
|
||||||
|
const r = await fetch(`/api/projects/${projectId}/assignments/merge`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ assignment_ids: assignmentIds }),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
||||||
|
throw new Error(err.detail || 'HTTP ' + r.status);
|
||||||
|
}
|
||||||
|
await loadDeploymentTimeline();
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message || 'Failed to merge assignments.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function _dtFmtDate(iso) {
|
function _dtFmtDate(iso) {
|
||||||
if (!iso) return '—';
|
if (!iso) return '—';
|
||||||
return iso.slice(0, 10);
|
return iso.slice(0, 10);
|
||||||
@@ -2044,6 +2104,17 @@ function _dtRenderAssignment(e) {
|
|||||||
? '<span class="px-2 py-0.5 rounded text-xs bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">active</span>'
|
? '<span class="px-2 py-0.5 rounded text-xs bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">active</span>'
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
// If this assignment belongs to a mergeable group, show a small
|
||||||
|
// indicator badge — the group-level "Merge" action lives in the
|
||||||
|
// banner at the top of the section to avoid N redundant buttons.
|
||||||
|
const mergeGroup = _dtFindMergeGroup(e.assignment_id);
|
||||||
|
const mergeableBadge = mergeGroup
|
||||||
|
? `<span class="px-2 py-0.5 rounded text-xs bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
|
||||||
|
title="This row is part of a ${mergeGroup.length}-record consecutive group at the same location — see the Merge banner above to combine them.">
|
||||||
|
mergeable
|
||||||
|
</span>`
|
||||||
|
: '';
|
||||||
|
|
||||||
const overlay = evCount > 0
|
const overlay = evCount > 0
|
||||||
? `<div class="mt-2 flex items-center gap-4 text-xs text-gray-600 dark:text-gray-400">
|
? `<div class="mt-2 flex items-center gap-4 text-xs text-gray-600 dark:text-gray-400">
|
||||||
<span><strong class="text-gray-900 dark:text-white">${evCount.toLocaleString()}</strong> event${evCount === 1 ? '' : 's'}</span>
|
<span><strong class="text-gray-900 dark:text-white">${evCount.toLocaleString()}</strong> event${evCount === 1 ? '' : 's'}</span>
|
||||||
@@ -2056,7 +2127,7 @@ 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>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
return `<div class="flex gap-3">
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -2065,8 +2136,11 @@ function _dtRenderAssignment(e) {
|
|||||||
<div class="text-sm text-gray-700 dark:text-gray-300">
|
<div class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
<strong>${start}</strong> → <strong>${end}</strong>${dur}
|
<strong>${start}</strong> → <strong>${end}</strong>${dur}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
${mergeableBadge}
|
||||||
${activeBadge}
|
${activeBadge}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="mt-1">${locLink}</div>
|
<div class="mt-1">${locLink}</div>
|
||||||
${projLine}
|
${projLine}
|
||||||
${overlay}
|
${overlay}
|
||||||
@@ -2118,18 +2192,253 @@ function _dtRenderStateChange(e) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDeploymentTimeline(entries, container) {
|
// ── Gantt chart ─────────────────────────────────────────────────────────────
|
||||||
if (!entries.length) {
|
// Renders all assignment windows as colored horizontal bars on an SVG
|
||||||
container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No deployment history yet. Assign this unit to a project location to start a deployment record.</p>';
|
// timeline. Click a bar to scroll its detail row into view in the list
|
||||||
|
// below. Color per location, opacity reduced for closed assignments.
|
||||||
|
// "Mergeable" groups get a unifying outline overlay so they're visible at
|
||||||
|
// a glance as one logical deployment.
|
||||||
|
const _ganttColorPalette = [
|
||||||
|
'#f48b1c', '#142a66', '#7d234d', '#0e7490', '#15803d', '#a16207',
|
||||||
|
'#9333ea', '#dc2626', '#0d9488', '#1d4ed8', '#be185d', '#65a30d',
|
||||||
|
];
|
||||||
|
function _ganttColorFor(locId, locColorMap) {
|
||||||
|
if (locColorMap[locId]) return locColorMap[locId];
|
||||||
|
const idx = Object.keys(locColorMap).length % _ganttColorPalette.length;
|
||||||
|
locColorMap[locId] = _ganttColorPalette[idx];
|
||||||
|
return locColorMap[locId];
|
||||||
|
}
|
||||||
|
|
||||||
|
function _ganttParseDate(iso) {
|
||||||
|
if (!iso) return null;
|
||||||
|
const d = new Date(iso.replace(' ', 'T'));
|
||||||
|
return isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _ganttFmtMonth(d) {
|
||||||
|
return d.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDeploymentGantt(entries, mergeGroups) {
|
||||||
|
const wrapper = document.getElementById('deploymentGantt');
|
||||||
|
const svg = document.getElementById('deploymentGanttSvg');
|
||||||
|
const legend = document.getElementById('deploymentGanttLegend');
|
||||||
|
if (!wrapper || !svg) return;
|
||||||
|
|
||||||
|
const assignments = (entries || []).filter(e => e.kind === 'assignment' && e.starts_at);
|
||||||
|
if (assignments.length === 0) {
|
||||||
|
wrapper.classList.add('hidden');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
wrapper.classList.remove('hidden');
|
||||||
|
|
||||||
|
// Compute time domain. Pad the end by a few days when an active
|
||||||
|
// assignment is present so the "active" bar doesn't reach the very
|
||||||
|
// edge of the chart.
|
||||||
|
const now = new Date();
|
||||||
|
let minDate = null, maxDate = null;
|
||||||
|
for (const a of assignments) {
|
||||||
|
const start = _ganttParseDate(a.starts_at);
|
||||||
|
const end = a.is_active ? now : (_ganttParseDate(a.ends_at) || now);
|
||||||
|
if (start && (!minDate || start < minDate)) minDate = start;
|
||||||
|
if (end && (!maxDate || end > maxDate)) maxDate = end;
|
||||||
|
}
|
||||||
|
if (!minDate || !maxDate) { wrapper.classList.add('hidden'); return; }
|
||||||
|
// Tiny padding at both ends (3% of total span).
|
||||||
|
const span = maxDate - minDate;
|
||||||
|
const pad = Math.max(span * 0.03, 24 * 3600 * 1000); // at least 1 day
|
||||||
|
minDate = new Date(minDate.getTime() - pad);
|
||||||
|
maxDate = new Date(maxDate.getTime() + pad);
|
||||||
|
|
||||||
|
// Build a quick "which mergeGroup is this id in?" map.
|
||||||
|
const idToGroup = {};
|
||||||
|
(mergeGroups || []).forEach((g, idx) => g.forEach(id => { idToGroup[id] = idx; }));
|
||||||
|
|
||||||
|
// Compute SVG geometry.
|
||||||
|
const width = Math.max(svg.clientWidth || svg.parentElement.clientWidth || 800, 400);
|
||||||
|
const height = 140;
|
||||||
|
const padLeft = 8;
|
||||||
|
const padRight = 8;
|
||||||
|
const padTop = 32; // room for month labels above the bars
|
||||||
|
const padBottom = 18; // room for assignment-count axis below
|
||||||
|
const usableW = width - padLeft - padRight;
|
||||||
|
const usableH = height - padTop - padBottom;
|
||||||
|
const totalRange = maxDate - minDate;
|
||||||
|
const xFor = (d) => padLeft + (d - minDate) / totalRange * usableW;
|
||||||
|
|
||||||
|
// Choose one-row-per-bar OR stack overlapping bars. Since same-unit
|
||||||
|
// assignments rarely overlap (only via the brief unassign/reassign
|
||||||
|
// race), a single row is usually fine. But just in case, stack with
|
||||||
|
// simple top-down packing.
|
||||||
|
const lanes = []; // each lane = [{x1, x2, ...}, ...]
|
||||||
|
function placeInLane(start, end) {
|
||||||
|
for (let i = 0; i < lanes.length; i++) {
|
||||||
|
const last = lanes[i][lanes[i].length - 1];
|
||||||
|
if (last.x2 + 2 < start) {
|
||||||
|
lanes[i].push({ x1: start, x2: end });
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lanes.push([{ x1: start, x2: end }]);
|
||||||
|
return lanes.length - 1;
|
||||||
|
}
|
||||||
|
const placed = assignments.map(a => {
|
||||||
|
const start = _ganttParseDate(a.starts_at);
|
||||||
|
const end = a.is_active ? now : (_ganttParseDate(a.ends_at) || now);
|
||||||
|
const x1 = xFor(start);
|
||||||
|
const x2 = xFor(end);
|
||||||
|
const lane = placeInLane(x1, x2);
|
||||||
|
return { a, x1, x2, lane };
|
||||||
|
});
|
||||||
|
const laneCount = Math.max(lanes.length, 1);
|
||||||
|
const barH = Math.max(14, Math.min(28, Math.floor(usableH / laneCount) - 4));
|
||||||
|
const laneSpacing = barH + 4;
|
||||||
|
|
||||||
|
// Month gridlines + labels. Tick on the 1st of each month inside the
|
||||||
|
// domain. If span > 24mo, tick every 3 months instead.
|
||||||
|
const months = [];
|
||||||
|
let monthCursor = new Date(minDate.getFullYear(), minDate.getMonth(), 1);
|
||||||
|
const tickEveryMonths = (totalRange > 24 * 30 * 86400 * 1000) ? 3 : 1;
|
||||||
|
while (monthCursor <= maxDate) {
|
||||||
|
if (monthCursor >= minDate) months.push(new Date(monthCursor));
|
||||||
|
monthCursor.setMonth(monthCursor.getMonth() + tickEveryMonths);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the SVG string.
|
||||||
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
|
const gridColor = isDark ? '#374151' : '#e5e7eb';
|
||||||
|
const labelColor = isDark ? '#9ca3af' : '#6b7280';
|
||||||
|
const todayColor = '#f48b1c';
|
||||||
|
|
||||||
|
const locColorMap = {};
|
||||||
|
const usedLocs = {};
|
||||||
|
|
||||||
|
let parts = [];
|
||||||
|
// Month gridlines.
|
||||||
|
months.forEach(m => {
|
||||||
|
const x = xFor(m);
|
||||||
|
parts.push(`<line x1="${x}" y1="${padTop}" x2="${x}" y2="${height - padBottom}" stroke="${gridColor}" stroke-width="1"/>`);
|
||||||
|
parts.push(`<text x="${x + 2}" y="${padTop - 6}" font-size="10" fill="${labelColor}" font-family="system-ui,sans-serif">${_ganttFmtMonth(m)}</text>`);
|
||||||
|
});
|
||||||
|
// Today marker.
|
||||||
|
if (now >= minDate && now <= maxDate) {
|
||||||
|
const x = xFor(now);
|
||||||
|
parts.push(`<line x1="${x}" y1="${padTop}" x2="${x}" y2="${height - padBottom}" stroke="${todayColor}" stroke-width="2" stroke-dasharray="3 2" opacity="0.8"/>`);
|
||||||
|
parts.push(`<text x="${x + 3}" y="${height - padBottom + 12}" font-size="9" fill="${todayColor}" font-family="system-ui,sans-serif">today</text>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bars.
|
||||||
|
placed.forEach(p => {
|
||||||
|
const a = p.a;
|
||||||
|
const color = _ganttColorFor(a.location_id || '_', locColorMap);
|
||||||
|
usedLocs[a.location_name || '(no location)'] = color;
|
||||||
|
const y = padTop + p.lane * laneSpacing;
|
||||||
|
const opacity = a.is_active ? 1.0 : 0.85;
|
||||||
|
const stroke = (a.source === 'metadata_backfill') ? '#3b82f6' : 'none';
|
||||||
|
const strokeWidth = (a.source === 'metadata_backfill') ? 2 : 0;
|
||||||
|
|
||||||
|
const barW = Math.max(p.x2 - p.x1, 3);
|
||||||
|
const tipDates = `${(a.starts_at || '').slice(0,10)} → ${a.is_active ? 'active' : (a.ends_at || '').slice(0,10)}`;
|
||||||
|
const tip = `${(a.location_name || '?').replace(/"/g, '"')} (${tipDates})${a.event_overlay && a.event_overlay.event_count ? ' • ' + a.event_overlay.event_count + ' events' : ''}`;
|
||||||
|
|
||||||
|
parts.push(`<g style="cursor: pointer;" onclick="_ganttScrollTo('${a.assignment_id}')">
|
||||||
|
<title>${tip}</title>
|
||||||
|
<rect x="${p.x1}" y="${y}" width="${barW}" height="${barH}" rx="3"
|
||||||
|
fill="${color}" opacity="${opacity}" stroke="${stroke}" stroke-width="${strokeWidth}"/>
|
||||||
|
${a.is_active ? `<circle cx="${p.x2 - 4}" cy="${y + barH / 2}" r="2.5" fill="#fff" opacity="0.9"/>` : ''}
|
||||||
|
</g>`);
|
||||||
|
|
||||||
|
// Mergeable highlight — thin dashed underline below the bar.
|
||||||
|
if (idToGroup[a.assignment_id] !== undefined) {
|
||||||
|
const uy = y + barH + 1;
|
||||||
|
parts.push(`<line x1="${p.x1}" y1="${uy}" x2="${p.x2}" y2="${uy}" stroke="#3b82f6" stroke-width="1.5" stroke-dasharray="2 2" opacity="0.7"/>`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
svg.innerHTML = parts.join('');
|
||||||
|
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
||||||
|
|
||||||
|
// Build legend (one swatch per distinct location).
|
||||||
|
const legendItems = Object.entries(usedLocs).map(([name, color]) =>
|
||||||
|
`<span class="flex items-center gap-1.5"><span class="inline-block w-3 h-2 rounded" style="background:${color}"></span>${_dtEsc(name)}</span>`
|
||||||
|
);
|
||||||
|
if (mergeGroups && mergeGroups.length > 0) {
|
||||||
|
legendItems.push(`<span class="flex items-center gap-1.5"><span class="inline-block w-3 border-b-2 border-dashed border-blue-500"></span>mergeable group</span>`);
|
||||||
|
}
|
||||||
|
if (placed.some(p => p.a.source === 'metadata_backfill')) {
|
||||||
|
legendItems.push(`<span class="flex items-center gap-1.5"><span class="inline-block w-3 h-2 rounded border-2 border-blue-500"></span>auto-backfilled</span>`);
|
||||||
|
}
|
||||||
|
legend.innerHTML = legendItems.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click-on-bar handler. Just scroll the matching list row into view +
|
||||||
|
// briefly flash it so the eye finds it.
|
||||||
|
function _ganttScrollTo(assignmentId) {
|
||||||
|
const target = document.querySelector(`[data-assignment-row="${assignmentId}"]`);
|
||||||
|
if (!target) return;
|
||||||
|
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
target.classList.add('ring-2', 'ring-seismo-orange');
|
||||||
|
setTimeout(() => target.classList.remove('ring-2', 'ring-seismo-orange'), 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDeploymentTimeline(entries, container, mergeGroups) {
|
||||||
|
if (!entries.length) {
|
||||||
|
container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No deployment history yet. Assign this unit to a project location to start a deployment record.</p>';
|
||||||
|
// Hide the Gantt block too.
|
||||||
|
const g = document.getElementById('deploymentGantt');
|
||||||
|
if (g) g.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the Gantt chart first (above the list).
|
||||||
|
renderDeploymentGantt(entries, mergeGroups);
|
||||||
|
|
||||||
|
// Build the mergeable-groups banner. Each group offers one "Merge into
|
||||||
|
// one" button. Skipped when no groups exist.
|
||||||
|
let bannerHtml = '';
|
||||||
|
if (mergeGroups && mergeGroups.length > 0) {
|
||||||
|
const rows = mergeGroups.map(group => {
|
||||||
|
// Look up the entries to describe what we're merging.
|
||||||
|
const groupEntries = (entries || []).filter(e =>
|
||||||
|
e.kind === 'assignment' && group.includes(e.assignment_id)
|
||||||
|
);
|
||||||
|
if (groupEntries.length === 0) return '';
|
||||||
|
const locName = groupEntries[0].location_name || 'unnamed location';
|
||||||
|
const earliest = groupEntries.map(e => e.starts_at).filter(Boolean).sort()[0] || '';
|
||||||
|
const latest = groupEntries.map(e => e.ends_at).filter(Boolean).sort().reverse()[0] || 'present';
|
||||||
|
const idsJson = JSON.stringify(group).replace(/"/g, '"');
|
||||||
|
return `<div class="flex items-center justify-between gap-3 py-1.5">
|
||||||
|
<div class="text-sm text-blue-900 dark:text-blue-200 min-w-0 flex-1">
|
||||||
|
<strong>${group.length} consecutive records</strong> at <strong>${_dtEsc(locName)}</strong>
|
||||||
|
<span class="text-xs text-blue-700 dark:text-blue-300 ml-2">${_dtFmtDate(earliest)} → ${_dtFmtDate(latest)}</span>
|
||||||
|
</div>
|
||||||
|
<button onclick='mergeAssignmentGroup(${JSON.stringify(group)})'
|
||||||
|
class="px-3 py-1 text-xs rounded-full bg-blue-600 hover:bg-blue-700 text-white font-medium whitespace-nowrap">
|
||||||
|
Merge into one
|
||||||
|
</button>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
bannerHtml = `<div class="mb-4 p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
|
||||||
|
<div class="flex items-start gap-2 mb-2">
|
||||||
|
<svg class="w-4 h-4 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
<div class="text-xs text-blue-800 dark:text-blue-300">
|
||||||
|
Consecutive deployments at the same location detected. Combine them into a single record to clean up the view (notes + ingest sources are preserved).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${rows}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
const html = entries.map(e => {
|
const html = entries.map(e => {
|
||||||
if (e.kind === 'assignment') return _dtRenderAssignment(e);
|
if (e.kind === 'assignment') return _dtRenderAssignment(e);
|
||||||
if (e.kind === 'gap') return _dtRenderGap(e);
|
if (e.kind === 'gap') return _dtRenderGap(e);
|
||||||
if (e.kind === 'state_change') return _dtRenderStateChange(e);
|
if (e.kind === 'state_change') return _dtRenderStateChange(e);
|
||||||
return '';
|
return '';
|
||||||
}).join('');
|
}).join('');
|
||||||
container.innerHTML = html;
|
|
||||||
|
container.innerHTML = bannerHtml + '<div class="space-y-3">' + html + '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── SFM Events section ──────────────────────────────────────────────────────
|
// ── SFM Events section ──────────────────────────────────────────────────────
|
||||||
@@ -2286,12 +2595,17 @@ function _ueAttrCell(ev) {
|
|||||||
if (a) {
|
if (a) {
|
||||||
const projLabel = _ueEsc(a.project_name || '—');
|
const projLabel = _ueEsc(a.project_name || '—');
|
||||||
const locLabel = _ueEsc(a.location_name || '—');
|
const locLabel = _ueEsc(a.location_name || '—');
|
||||||
|
// If the attributed location has since been soft-removed, badge
|
||||||
|
// it so operators see at a glance this is historical attribution.
|
||||||
|
const removedBadge = a.location_removed_at
|
||||||
|
? '<span class="ml-1 text-[10px] uppercase tracking-wider px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 font-semibold" title="Location no longer actively monitored">removed</span>'
|
||||||
|
: '';
|
||||||
return `<a href="/projects/${_ueEsc(a.project_id)}/nrl/${_ueEsc(a.location_id)}"
|
return `<a href="/projects/${_ueEsc(a.project_id)}/nrl/${_ueEsc(a.location_id)}"
|
||||||
onclick="event.stopPropagation()"
|
onclick="event.stopPropagation()"
|
||||||
class="text-seismo-orange hover:text-seismo-navy"
|
class="text-seismo-orange hover:text-seismo-navy"
|
||||||
title="${projLabel} → ${locLabel}">
|
title="${projLabel} → ${locLabel}">
|
||||||
📍 ${locLabel}
|
📍 ${locLabel}
|
||||||
</a>
|
</a>${removedBadge}
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400">${projLabel}</div>`;
|
<div class="text-xs text-gray-500 dark:text-gray-400">${projLabel}</div>`;
|
||||||
}
|
}
|
||||||
const n = ev.nearest_assignment;
|
const n = ev.nearest_assignment;
|
||||||
|
|||||||
@@ -583,6 +583,14 @@ function renderAssignmentsUsed(assignments) {
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
onclick="deleteAssignment('${esc(a.assignment_id)}', '${esc(a.unit_id)}', '${start} → ${end}')"
|
||||||
|
title="Delete this assignment record (for mis-clicks / duplicates)"
|
||||||
|
class="text-gray-400 hover:text-red-600 transition-colors p-1">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M1 7h22M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300 whitespace-nowrap">${(a.events_in_window || 0).toLocaleString()} event${a.events_in_window === 1 ? '' : 's'}</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300 whitespace-nowrap">${(a.events_in_window || 0).toLocaleString()} event${a.events_in_window === 1 ? '' : 's'}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -608,6 +616,33 @@ function openAssignmentEditModal(encodedJson) {
|
|||||||
document.getElementById('assignment-edit-modal').classList.remove('hidden');
|
document.getElementById('assignment-edit-modal').classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteAssignment(assignmentId, unitId, windowLabel) {
|
||||||
|
// For mis-clicks / accidental duplicate assignments. Backend refuses
|
||||||
|
// if there's a real recording session inside the window — those should
|
||||||
|
// go through Edit or Unassign instead.
|
||||||
|
const msg = `Delete this assignment?\n\n`
|
||||||
|
+ `Unit: ${unitId}\n`
|
||||||
|
+ `Window: ${windowLabel}\n\n`
|
||||||
|
+ `This is for assignments created in error. Events that fell `
|
||||||
|
+ `in this window will become unattributed. The unit's deployment `
|
||||||
|
+ `history will log the deletion for audit.\n\n`
|
||||||
|
+ `If the unit actually was deployed here, use Edit or Unassign instead.`;
|
||||||
|
if (!confirm(msg)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/projects/${projectId}/assignments/${assignmentId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
||||||
|
throw new Error(err.detail || 'HTTP ' + r.status);
|
||||||
|
}
|
||||||
|
await loadLocationEvents(); // Refresh stats + table without this assignment.
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message || 'Failed to delete assignment.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function closeAssignmentEditModal() {
|
function closeAssignmentEditModal() {
|
||||||
document.getElementById('assignment-edit-modal').classList.add('hidden');
|
document.getElementById('assignment-edit-modal').classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user