Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4fd1c943d |
@@ -109,9 +109,6 @@ app.include_router(watcher_manager.router)
|
|||||||
from backend.routers import admin_modules
|
from backend.routers import admin_modules
|
||||||
app.include_router(admin_modules.router)
|
app.include_router(admin_modules.router)
|
||||||
|
|
||||||
from backend.routers import deployment_history
|
|
||||||
app.include_router(deployment_history.router)
|
|
||||||
|
|
||||||
# Projects system routers
|
# Projects system routers
|
||||||
app.include_router(projects.router)
|
app.include_router(projects.router)
|
||||||
app.include_router(project_locations.router)
|
app.include_router(project_locations.router)
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
"""
|
|
||||||
Fleet-wide deployment-history calendar — Phase 2 of the
|
|
||||||
deployment-history visualisation work (Phase 1 is the per-unit Gantt
|
|
||||||
on /unit/{id}).
|
|
||||||
|
|
||||||
Renders all UnitAssignment windows across all projects on a 12-month
|
|
||||||
calendar grid styled like the Job Planner. Each day cell shows one
|
|
||||||
mini-bar per project that had ≥1 active assignment that day. Click a
|
|
||||||
day → side panel with the (unit, location) pairs active.
|
|
||||||
|
|
||||||
Routes:
|
|
||||||
GET /tools/deployment-history — HTML page
|
|
||||||
GET /api/admin/deployment-history/day — JSON list of deployments
|
|
||||||
on a specific date (used
|
|
||||||
by the day-detail panel)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import date, datetime
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, Request
|
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from backend.database import get_db
|
|
||||||
from backend.services.deployment_history import (
|
|
||||||
get_deployment_history_data,
|
|
||||||
get_deployments_on_day,
|
|
||||||
)
|
|
||||||
from backend.templates_config import templates
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tools/deployment-history", response_class=HTMLResponse)
|
|
||||||
def deployment_history_page(
|
|
||||||
request: Request,
|
|
||||||
year: Optional[int] = Query(None),
|
|
||||||
month: Optional[int] = Query(None),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""Fleet-wide deployment history calendar.
|
|
||||||
|
|
||||||
Defaults to a 12-month window ending in the current month (so the
|
|
||||||
operator sees the recent past, not the future). ?year=&month= can
|
|
||||||
override the START of the window to scroll backward or forward.
|
|
||||||
"""
|
|
||||||
today = date.today()
|
|
||||||
# Default: 12-month window ending this month → start = 11 months back.
|
|
||||||
if year is None or month is None:
|
|
||||||
# 11 months back from current month.
|
|
||||||
m = today.month - 11
|
|
||||||
y = today.year
|
|
||||||
while m < 1:
|
|
||||||
m += 12
|
|
||||||
y -= 1
|
|
||||||
start_year, start_month = y, m
|
|
||||||
else:
|
|
||||||
start_year, start_month = year, month
|
|
||||||
|
|
||||||
calendar = get_deployment_history_data(db, start_year, start_month)
|
|
||||||
|
|
||||||
# Build prev/next navigation values.
|
|
||||||
prev_y, prev_m = (start_year - 1, 12) if start_month == 1 else (start_year, start_month - 1)
|
|
||||||
next_y, next_m = (start_year + 1, 1) if start_month == 12 else (start_year, start_month + 1)
|
|
||||||
|
|
||||||
return templates.TemplateResponse("admin/deployment_history.html", {
|
|
||||||
"request": request,
|
|
||||||
"calendar": calendar,
|
|
||||||
"today": today.isoformat(),
|
|
||||||
"prev_year": prev_y,
|
|
||||||
"prev_month": prev_m,
|
|
||||||
"next_year": next_y,
|
|
||||||
"next_month": next_m,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/admin/deployment-history/day")
|
|
||||||
def deployment_history_day(
|
|
||||||
target_date: str = Query(..., description="YYYY-MM-DD"),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""Return assignments active on a specific calendar day."""
|
|
||||||
try:
|
|
||||||
d = date.fromisoformat(target_date)
|
|
||||||
except ValueError:
|
|
||||||
return JSONResponse(
|
|
||||||
{"error": f"Invalid date: {target_date!r}"},
|
|
||||||
status_code=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
deployments = get_deployments_on_day(db, d)
|
|
||||||
return JSONResponse({
|
|
||||||
"date": target_date,
|
|
||||||
"count": len(deployments),
|
|
||||||
"deployments": deployments,
|
|
||||||
})
|
|
||||||
@@ -1,387 +0,0 @@
|
|||||||
"""
|
|
||||||
Deployment-history calendar service — builds the data structure for the
|
|
||||||
fleet-wide deployment-history grid (`/tools/deployment-history`).
|
|
||||||
|
|
||||||
For each calendar day in a 12-month window, computes which projects had
|
|
||||||
at least one unit assigned to a location on that day. Renders as
|
|
||||||
multi-month grid (job-planner style) with project-colored bars per day.
|
|
||||||
|
|
||||||
Distinct from `services/fleet_calendar_service.py` which renders
|
|
||||||
forward-looking RESERVATIONS for the planner. This one is purely
|
|
||||||
historical / current — it walks `unit_assignments` instead of
|
|
||||||
`job_reservations`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
from datetime import date, datetime, timedelta
|
|
||||||
from calendar import monthrange
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from sqlalchemy import and_, or_
|
|
||||||
|
|
||||||
from backend.models import Project, UnitAssignment
|
|
||||||
|
|
||||||
|
|
||||||
# Color palette for projects without an explicit color attribute. Chosen
|
|
||||||
# to have decent contrast on both light and dark backgrounds; cycles
|
|
||||||
# deterministically by SHA1(project_id).
|
|
||||||
_PROJECT_COLOR_PALETTE = [
|
|
||||||
"#f48b1c", "#142a66", "#7d234d", "#0e7490", "#15803d",
|
|
||||||
"#a16207", "#9333ea", "#dc2626", "#0d9488", "#1d4ed8",
|
|
||||||
"#be185d", "#65a30d", "#0891b2", "#7c3aed", "#b91c1c",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _color_for_project(project_id: str) -> str:
|
|
||||||
"""Deterministic color assignment from a fixed palette."""
|
|
||||||
h = hashlib.sha1(project_id.encode("utf-8")).digest()[0]
|
|
||||||
return _PROJECT_COLOR_PALETTE[h % len(_PROJECT_COLOR_PALETTE)]
|
|
||||||
|
|
||||||
|
|
||||||
def _month_short(m: int) -> str:
|
|
||||||
return ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
|
||||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][m - 1]
|
|
||||||
|
|
||||||
|
|
||||||
def _month_full(m: int) -> str:
|
|
||||||
return ["January", "February", "March", "April", "May", "June",
|
|
||||||
"July", "August", "September", "October", "November", "December"][m - 1]
|
|
||||||
|
|
||||||
|
|
||||||
def get_deployment_history_data(
|
|
||||||
db: Session,
|
|
||||||
start_year: int,
|
|
||||||
start_month: int,
|
|
||||||
) -> dict:
|
|
||||||
"""
|
|
||||||
Build the calendar data structure for a 12-month window starting at
|
|
||||||
(start_year, start_month).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{
|
|
||||||
"months": [
|
|
||||||
{
|
|
||||||
"year": int,
|
|
||||||
"month": int, # 1-12
|
|
||||||
"name": "January",
|
|
||||||
"short_name": "Jan",
|
|
||||||
"year_short": "26",
|
|
||||||
"num_days": int,
|
|
||||||
"first_weekday": int, # 0=Mon..6=Sun (datetime.weekday())
|
|
||||||
"active_days": {
|
|
||||||
day_num: [project_id, project_id, ...] # projects with
|
|
||||||
# ≥1 active assignment
|
|
||||||
# on that day
|
|
||||||
},
|
|
||||||
},
|
|
||||||
... # 12 entries
|
|
||||||
],
|
|
||||||
"projects": [
|
|
||||||
{
|
|
||||||
"id": str,
|
|
||||||
"name": str,
|
|
||||||
"color": str,
|
|
||||||
"status": str,
|
|
||||||
"client_name": str | None,
|
|
||||||
"assignment_count": int, # total assignments contributing to
|
|
||||||
# this 12-month window
|
|
||||||
"first_active": "YYYY-MM-DD" | None,
|
|
||||||
"last_active": "YYYY-MM-DD" | None,
|
|
||||||
},
|
|
||||||
... # only projects with
|
|
||||||
# ≥1 assignment in the
|
|
||||||
# window, sorted by
|
|
||||||
# first_active ASC
|
|
||||||
],
|
|
||||||
"total_assignments": int,
|
|
||||||
"total_active_units": int, # distinct unit_ids across the window
|
|
||||||
"window": {
|
|
||||||
"start_year": int,
|
|
||||||
"start_month": int,
|
|
||||||
"end_year": int,
|
|
||||||
"end_month": int,
|
|
||||||
"first_date": "YYYY-MM-DD",
|
|
||||||
"last_date": "YYYY-MM-DD",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
# Compute window edges.
|
|
||||||
first_date = date(start_year, start_month, 1)
|
|
||||||
# 12 months → end on day-1 of (start + 12)
|
|
||||||
end_year = start_year + ((start_month + 10) // 12)
|
|
||||||
end_month = ((start_month + 10) % 12) + 1
|
|
||||||
last_date = date(end_year, end_month, monthrange(end_year, end_month)[1])
|
|
||||||
|
|
||||||
now = datetime.utcnow()
|
|
||||||
|
|
||||||
# Fetch every assignment that overlaps the window. An assignment
|
|
||||||
# overlaps if assigned_at <= last_date AND (assigned_until is NULL
|
|
||||||
# OR assigned_until >= first_date).
|
|
||||||
assignments = (
|
|
||||||
db.query(UnitAssignment)
|
|
||||||
.filter(UnitAssignment.assigned_at <= datetime.combine(last_date, datetime.max.time()))
|
|
||||||
.filter(
|
|
||||||
or_(
|
|
||||||
UnitAssignment.assigned_until == None, # noqa: E711 — active
|
|
||||||
UnitAssignment.assigned_until >= datetime.combine(first_date, datetime.min.time()),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
# Resolve referenced projects in one query.
|
|
||||||
proj_ids = {a.project_id for a in assignments}
|
|
||||||
proj_map = {
|
|
||||||
p.id: p for p in db.query(Project).filter(Project.id.in_(proj_ids)).all()
|
|
||||||
} if proj_ids else {}
|
|
||||||
|
|
||||||
# Resolve location names in one batch query (used by the Gantt view
|
|
||||||
# for per-bar tooltips).
|
|
||||||
from backend.models import MonitoringLocation
|
|
||||||
loc_ids = {a.location_id for a in assignments}
|
|
||||||
loc_name_map = {
|
|
||||||
l.id: l.name for l in db.query(MonitoringLocation).filter(
|
|
||||||
MonitoringLocation.id.in_(loc_ids)
|
|
||||||
).all()
|
|
||||||
} if loc_ids else {}
|
|
||||||
|
|
||||||
# Compute "active days per project" by walking each assignment and
|
|
||||||
# adding every day in its [start, end] ∩ [first_date, last_date].
|
|
||||||
# O(N_assignments × avg_window_days); for a typical fleet this is
|
|
||||||
# bounded (hundreds of assignments × hundreds of days = manageable).
|
|
||||||
# Also collect raw per-assignment bar data for the Gantt view.
|
|
||||||
project_active_days: dict[str, set[date]] = {}
|
|
||||||
project_first_active: dict[str, date] = {}
|
|
||||||
project_last_active: dict[str, date] = {}
|
|
||||||
project_assignment_count: dict[str, int] = {}
|
|
||||||
project_bars: dict[str, list[dict]] = {}
|
|
||||||
distinct_units: set[str] = set()
|
|
||||||
|
|
||||||
for a in assignments:
|
|
||||||
start = max(a.assigned_at.date() if a.assigned_at else first_date, first_date)
|
|
||||||
end_dt = a.assigned_until or now
|
|
||||||
end = min(end_dt.date(), last_date)
|
|
||||||
if end < start:
|
|
||||||
continue
|
|
||||||
days = project_active_days.setdefault(a.project_id, set())
|
|
||||||
d = start
|
|
||||||
while d <= end:
|
|
||||||
days.add(d)
|
|
||||||
d += timedelta(days=1)
|
|
||||||
project_assignment_count[a.project_id] = project_assignment_count.get(a.project_id, 0) + 1
|
|
||||||
distinct_units.add(a.unit_id)
|
|
||||||
# Track first/last active dates in the window.
|
|
||||||
prev_first = project_first_active.get(a.project_id)
|
|
||||||
if prev_first is None or start < prev_first:
|
|
||||||
project_first_active[a.project_id] = start
|
|
||||||
prev_last = project_last_active.get(a.project_id)
|
|
||||||
if prev_last is None or end > prev_last:
|
|
||||||
project_last_active[a.project_id] = end
|
|
||||||
|
|
||||||
# Per-assignment bar data — used by the Gantt view's renderer.
|
|
||||||
# `is_active` reflects whether the assignment_until was still NULL
|
|
||||||
# at fetch time (open-ended deployment); the clipped `end` here
|
|
||||||
# is just for visual bar drawing.
|
|
||||||
project_bars.setdefault(a.project_id, []).append({
|
|
||||||
"unit_id": a.unit_id,
|
|
||||||
"location_id": a.location_id,
|
|
||||||
"location_name": loc_name_map.get(a.location_id, "(unknown location)"),
|
|
||||||
"start": start.isoformat(),
|
|
||||||
"end": end.isoformat(),
|
|
||||||
"is_active": a.assigned_until is None,
|
|
||||||
"source": a.source,
|
|
||||||
})
|
|
||||||
|
|
||||||
# Build the projects array (sorted by first_active ascending so the
|
|
||||||
# legend reads in deployment-order).
|
|
||||||
projects_data = []
|
|
||||||
for pid, days in project_active_days.items():
|
|
||||||
p = proj_map.get(pid)
|
|
||||||
if not p:
|
|
||||||
# Assignment references a deleted project — surface it anyway
|
|
||||||
# with a placeholder name, since the bars still need a label.
|
|
||||||
projects_data.append({
|
|
||||||
"id": pid,
|
|
||||||
"name": "(deleted project)",
|
|
||||||
"color": _color_for_project(pid),
|
|
||||||
"status": "deleted",
|
|
||||||
"client_name": None,
|
|
||||||
"assignment_count": project_assignment_count.get(pid, 0),
|
|
||||||
"first_active": project_first_active[pid].isoformat() if pid in project_first_active else None,
|
|
||||||
"last_active": project_last_active[pid].isoformat() if pid in project_last_active else None,
|
|
||||||
"bars": project_bars.get(pid, []),
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
projects_data.append({
|
|
||||||
"id": pid,
|
|
||||||
"name": p.name,
|
|
||||||
"color": _color_for_project(pid),
|
|
||||||
"status": p.status or "active",
|
|
||||||
"client_name": p.client_name,
|
|
||||||
"assignment_count": project_assignment_count.get(pid, 0),
|
|
||||||
"first_active": project_first_active[pid].isoformat() if pid in project_first_active else None,
|
|
||||||
"last_active": project_last_active[pid].isoformat() if pid in project_last_active else None,
|
|
||||||
"bars": project_bars.get(pid, []),
|
|
||||||
})
|
|
||||||
|
|
||||||
projects_data.sort(key=lambda p: (p["first_active"] or "9999", p["name"]))
|
|
||||||
|
|
||||||
# ── Per-unit view data (Gantt-by-Unit tab) ────────────────────────
|
|
||||||
# Same source assignments, re-grouped by unit_id. Each bar carries
|
|
||||||
# the project's color + name so the renderer can paint by job
|
|
||||||
# without doing a second lookup.
|
|
||||||
unit_bars: dict[str, list[dict]] = {}
|
|
||||||
project_lookup = {p["id"]: p for p in projects_data}
|
|
||||||
for a in assignments:
|
|
||||||
start = max(a.assigned_at.date() if a.assigned_at else first_date, first_date)
|
|
||||||
end_dt = a.assigned_until or now
|
|
||||||
end = min(end_dt.date(), last_date)
|
|
||||||
if end < start:
|
|
||||||
continue
|
|
||||||
p_info = project_lookup.get(a.project_id, {})
|
|
||||||
unit_bars.setdefault(a.unit_id, []).append({
|
|
||||||
"project_id": a.project_id,
|
|
||||||
"project_name": p_info.get("name", "(deleted project)"),
|
|
||||||
"project_color": p_info.get("color", _color_for_project(a.project_id)),
|
|
||||||
"location_id": a.location_id,
|
|
||||||
"location_name": loc_name_map.get(a.location_id, "(unknown location)"),
|
|
||||||
"start": start.isoformat(),
|
|
||||||
"end": end.isoformat(),
|
|
||||||
"is_active": a.assigned_until is None,
|
|
||||||
"source": a.source,
|
|
||||||
})
|
|
||||||
|
|
||||||
# Sort units by first-active date so the most-recently-deployed
|
|
||||||
# units sit at the top. Reverse if we want oldest-first.
|
|
||||||
units_data = []
|
|
||||||
for uid, bars in unit_bars.items():
|
|
||||||
bars.sort(key=lambda b: b["start"])
|
|
||||||
first_start = bars[0]["start"]
|
|
||||||
# "active now" flag = any bar is still active
|
|
||||||
any_active = any(b["is_active"] for b in bars)
|
|
||||||
units_data.append({
|
|
||||||
"id": uid,
|
|
||||||
"bars": bars,
|
|
||||||
"first_active": first_start,
|
|
||||||
"assignment_count": len(bars),
|
|
||||||
"any_active": any_active,
|
|
||||||
})
|
|
||||||
units_data.sort(key=lambda u: (not u["any_active"], u["first_active"], u["id"]))
|
|
||||||
|
|
||||||
# Now build the months array.
|
|
||||||
months_data = []
|
|
||||||
cur_year, cur_month = start_year, start_month
|
|
||||||
for _ in range(12):
|
|
||||||
num_days = monthrange(cur_year, cur_month)[1]
|
|
||||||
first_weekday = date(cur_year, cur_month, 1).weekday() # 0=Mon..6=Sun
|
|
||||||
active_days: dict[int, list[str]] = {}
|
|
||||||
for day_num in range(1, num_days + 1):
|
|
||||||
d = date(cur_year, cur_month, day_num)
|
|
||||||
day_projects = [
|
|
||||||
pid for pid, days in project_active_days.items()
|
|
||||||
if d in days
|
|
||||||
]
|
|
||||||
if day_projects:
|
|
||||||
# Sort by the project's color-stable order so bars don't
|
|
||||||
# jitter between days.
|
|
||||||
day_projects.sort()
|
|
||||||
active_days[day_num] = day_projects
|
|
||||||
months_data.append({
|
|
||||||
"year": cur_year,
|
|
||||||
"month": cur_month,
|
|
||||||
"name": _month_full(cur_month),
|
|
||||||
"short_name": _month_short(cur_month),
|
|
||||||
"year_short": f"{cur_year % 100:02d}",
|
|
||||||
"num_days": num_days,
|
|
||||||
"first_weekday": first_weekday,
|
|
||||||
"active_days": active_days,
|
|
||||||
})
|
|
||||||
# Advance one month.
|
|
||||||
if cur_month == 12:
|
|
||||||
cur_year += 1
|
|
||||||
cur_month = 1
|
|
||||||
else:
|
|
||||||
cur_month += 1
|
|
||||||
|
|
||||||
return {
|
|
||||||
"months": months_data,
|
|
||||||
"projects": projects_data,
|
|
||||||
"units": units_data,
|
|
||||||
"total_assignments": len(assignments),
|
|
||||||
"total_active_units": len(distinct_units),
|
|
||||||
"window": {
|
|
||||||
"start_year": start_year,
|
|
||||||
"start_month": start_month,
|
|
||||||
"end_year": end_year,
|
|
||||||
"end_month": end_month,
|
|
||||||
"first_date": first_date.isoformat(),
|
|
||||||
"last_date": last_date.isoformat(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_deployments_on_day(
|
|
||||||
db: Session,
|
|
||||||
target_date: date,
|
|
||||||
) -> list[dict]:
|
|
||||||
"""
|
|
||||||
Return the list of (unit, location, project) tuples that were
|
|
||||||
actively assigned on a specific calendar date. Used for the
|
|
||||||
day-detail side panel when an operator clicks a day cell.
|
|
||||||
"""
|
|
||||||
from backend.models import MonitoringLocation, RosterUnit
|
|
||||||
|
|
||||||
day_start = datetime.combine(target_date, datetime.min.time())
|
|
||||||
day_end = datetime.combine(target_date, datetime.max.time())
|
|
||||||
|
|
||||||
rows = (
|
|
||||||
db.query(UnitAssignment)
|
|
||||||
.filter(UnitAssignment.assigned_at <= day_end)
|
|
||||||
.filter(
|
|
||||||
or_(
|
|
||||||
UnitAssignment.assigned_until == None, # noqa: E711
|
|
||||||
UnitAssignment.assigned_until >= day_start,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.order_by(UnitAssignment.project_id, UnitAssignment.unit_id)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
if not rows:
|
|
||||||
return []
|
|
||||||
|
|
||||||
loc_ids = {a.location_id for a in rows}
|
|
||||||
proj_ids = {a.project_id for a in rows}
|
|
||||||
loc_map = {
|
|
||||||
l.id: l for l in db.query(MonitoringLocation).filter(
|
|
||||||
MonitoringLocation.id.in_(loc_ids)
|
|
||||||
).all()
|
|
||||||
}
|
|
||||||
proj_map = {
|
|
||||||
p.id: p for p in db.query(Project).filter(
|
|
||||||
Project.id.in_(proj_ids)
|
|
||||||
).all()
|
|
||||||
}
|
|
||||||
|
|
||||||
results = []
|
|
||||||
for a in rows:
|
|
||||||
loc = loc_map.get(a.location_id)
|
|
||||||
proj = proj_map.get(a.project_id)
|
|
||||||
results.append({
|
|
||||||
"assignment_id": a.id,
|
|
||||||
"unit_id": a.unit_id,
|
|
||||||
"location_id": a.location_id,
|
|
||||||
"location_name": loc.name if loc else "(unknown location)",
|
|
||||||
"project_id": a.project_id,
|
|
||||||
"project_name": proj.name if proj else "(deleted project)",
|
|
||||||
"project_color": _color_for_project(a.project_id),
|
|
||||||
"assigned_at": a.assigned_at.isoformat() if a.assigned_at else None,
|
|
||||||
"assigned_until": a.assigned_until.isoformat() if a.assigned_until else None,
|
|
||||||
"is_active": a.assigned_until is None,
|
|
||||||
"source": a.source,
|
|
||||||
})
|
|
||||||
|
|
||||||
return results
|
|
||||||
@@ -1,711 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Deployment History - Seismo Fleet Manager{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_head %}
|
|
||||||
<style>
|
|
||||||
.dh-calendar-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
@media (max-width: 1280px) { .dh-calendar-grid { grid-template-columns: repeat(3, 1fr); } }
|
|
||||||
@media (max-width: 768px) { .dh-calendar-grid { grid-template-columns: repeat(2, 1fr); } }
|
|
||||||
@media (max-width: 480px) { .dh-calendar-grid { grid-template-columns: 1fr; } }
|
|
||||||
|
|
||||||
.dh-month-card {
|
|
||||||
background: white;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
.dark .dh-month-card { background: rgb(30 41 59); }
|
|
||||||
|
|
||||||
.dh-day-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(7, 1fr);
|
|
||||||
gap: 2px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
.dh-day-header {
|
|
||||||
text-align: center;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #6b7280;
|
|
||||||
padding: 0.25rem 0;
|
|
||||||
}
|
|
||||||
.dark .dh-day-header { color: #9ca3af; }
|
|
||||||
|
|
||||||
.dh-day-cell {
|
|
||||||
aspect-ratio: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
justify-content: flex-start;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
position: relative;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
padding-top: 2px;
|
|
||||||
background-color: #f3f4f6;
|
|
||||||
color: #374151;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.dh-day-cell:hover {
|
|
||||||
transform: scale(1.1);
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
.dh-day-cell.empty {
|
|
||||||
background: transparent;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
.dh-day-cell.empty:hover { transform: none; }
|
|
||||||
.dh-day-cell.today {
|
|
||||||
ring-color: #f48b1c;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #b84a12;
|
|
||||||
}
|
|
||||||
.dark .dh-day-cell {
|
|
||||||
background-color: rgba(55, 65, 81, 0.5);
|
|
||||||
color: #d1d5db;
|
|
||||||
}
|
|
||||||
.dh-day-cell .dh-day-num {
|
|
||||||
text-align: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.dh-day-cell .dh-bars {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1px;
|
|
||||||
margin: 2px 2px 0 2px;
|
|
||||||
}
|
|
||||||
.dh-bar {
|
|
||||||
height: 3px;
|
|
||||||
border-radius: 1px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="mb-6 flex items-center justify-between flex-wrap gap-3">
|
|
||||||
<div>
|
|
||||||
<a href="/tools" class="text-sm text-seismo-orange hover:text-seismo-burgundy">← Back to Tools</a>
|
|
||||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mt-1">Deployment History</h1>
|
|
||||||
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
|
||||||
Where every unit has been — actual assignment windows, color-coded by project.
|
|
||||||
For future / planned deployments use the <a href="/fleet-calendar" class="text-seismo-orange hover:text-seismo-burgundy">Job Planner</a>.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- View tabs: Calendar | Gantt (by project) | By Unit (gantt by unit) -->
|
|
||||||
<div class="flex rounded-lg bg-gray-100 dark:bg-gray-700 p-1 mb-6 w-fit">
|
|
||||||
<button id="dh-tab-calendar" onclick="switchDhView('calendar')"
|
|
||||||
class="px-4 py-2 rounded-md text-sm font-medium transition-colors bg-white dark:bg-slate-600 text-gray-900 dark:text-white shadow">
|
|
||||||
Calendar
|
|
||||||
</button>
|
|
||||||
<button id="dh-tab-gantt" onclick="switchDhView('gantt')"
|
|
||||||
class="px-4 py-2 rounded-md text-sm font-medium transition-colors text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">
|
|
||||||
Gantt by Project
|
|
||||||
</button>
|
|
||||||
<button id="dh-tab-byunit" onclick="switchDhView('byunit')"
|
|
||||||
class="px-4 py-2 rounded-md text-sm font-medium transition-colors text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">
|
|
||||||
Gantt by Unit
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- KPI strip -->
|
|
||||||
<div class="flex flex-wrap items-center gap-4 mb-6">
|
|
||||||
<div class="flex items-center gap-3 text-sm bg-white dark:bg-slate-800 rounded-lg px-4 py-2 shadow">
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">{{ calendar.projects | length }} project{{ '' if calendar.projects | length == 1 else 's' }}</span>
|
|
||||||
<span class="text-gray-300 dark:text-gray-600">|</span>
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">{{ calendar.total_active_units }} unique units</span>
|
|
||||||
<span class="text-gray-300 dark:text-gray-600">|</span>
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">{{ calendar.total_assignments }} assignment{{ '' if calendar.total_assignments == 1 else 's' }} in window</span>
|
|
||||||
</div>
|
|
||||||
{% if calendar.projects %}
|
|
||||||
<details class="bg-white dark:bg-slate-800 rounded-lg shadow px-4 py-2">
|
|
||||||
<summary class="cursor-pointer text-sm font-medium text-gray-700 dark:text-gray-300 select-none">Project legend</summary>
|
|
||||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
{% for p in calendar.projects %}
|
|
||||||
<a href="/projects/{{ p.id }}"
|
|
||||||
class="flex items-center gap-1.5 text-sm text-gray-700 dark:text-gray-300 hover:text-seismo-orange">
|
|
||||||
<span class="w-3 h-1.5 rounded-full flex-shrink-0" style="background-color: {{ p.color }};"></span>
|
|
||||||
{{ p.name }}
|
|
||||||
<span class="text-xs text-gray-400 dark:text-gray-500">·</span>
|
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ p.assignment_count }}</span>
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ─── Calendar view ─── -->
|
|
||||||
<div id="dh-view-calendar">
|
|
||||||
|
|
||||||
<!-- Calendar grid -->
|
|
||||||
{% if calendar.projects %}
|
|
||||||
<div class="dh-calendar-grid mb-6">
|
|
||||||
{% for month_data in calendar.months %}
|
|
||||||
<div class="dh-month-card">
|
|
||||||
<h3 class="font-semibold text-gray-900 dark:text-white mb-2 text-center">
|
|
||||||
{{ month_data.short_name }} '{{ month_data.year_short }}
|
|
||||||
</h3>
|
|
||||||
<div class="dh-day-grid">
|
|
||||||
<div class="dh-day-header">S</div>
|
|
||||||
<div class="dh-day-header">M</div>
|
|
||||||
<div class="dh-day-header">T</div>
|
|
||||||
<div class="dh-day-header">W</div>
|
|
||||||
<div class="dh-day-header">T</div>
|
|
||||||
<div class="dh-day-header">F</div>
|
|
||||||
<div class="dh-day-header">S</div>
|
|
||||||
|
|
||||||
{# Sunday-first alignment: shift Monday=0 → Sunday=0 #}
|
|
||||||
{% set first_offset = (month_data.first_weekday + 1) % 7 %}
|
|
||||||
{% for i in range(first_offset) %}
|
|
||||||
<div class="dh-day-cell empty"></div>
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
{% for day_num in range(1, month_data.num_days + 1) %}
|
|
||||||
{% set date_str = '%04d-%02d-%02d' | format(month_data.year, month_data.month, day_num) %}
|
|
||||||
{% set is_today = date_str == today %}
|
|
||||||
{% set day_proj_ids = month_data.active_days.get(day_num, []) %}
|
|
||||||
<div class="dh-day-cell{% if is_today %} today ring-2 ring-seismo-orange{% endif %}"
|
|
||||||
onclick="openDhDay('{{ date_str }}')"
|
|
||||||
title="{{ date_str }} — {{ day_proj_ids | length }} project{{ '' if day_proj_ids | length == 1 else 's' }}">
|
|
||||||
<span class="dh-day-num">{{ day_num }}</span>
|
|
||||||
{% if day_proj_ids %}
|
|
||||||
<span class="dh-bars">
|
|
||||||
{% for pid in day_proj_ids[:4] %}
|
|
||||||
{% set p = (calendar.projects | selectattr('id', 'equalto', pid) | first) %}
|
|
||||||
{% if p %}
|
|
||||||
<span class="dh-bar" style="background-color: {{ p.color }};"
|
|
||||||
title="{{ p.name }}"></span>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
{% if day_proj_ids | length > 4 %}
|
|
||||||
<span class="text-[8px] text-gray-500 leading-none">+{{ day_proj_ids | length - 4 }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Month navigation -->
|
|
||||||
<div class="flex items-center justify-center gap-3 mb-8">
|
|
||||||
<a href="/tools/deployment-history?year={{ prev_year }}&month={{ prev_month }}"
|
|
||||||
class="px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
|
|
||||||
title="Previous month">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<span class="text-lg font-bold text-gray-900 dark:text-white px-3">
|
|
||||||
{{ calendar.months[0].short_name }} '{{ calendar.months[0].year_short }} – {{ calendar.months[11].short_name }} '{{ calendar.months[11].year_short }}
|
|
||||||
</span>
|
|
||||||
<a href="/tools/deployment-history?year={{ next_year }}&month={{ next_month }}"
|
|
||||||
class="px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
|
|
||||||
title="Next month">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<a href="/tools/deployment-history"
|
|
||||||
class="ml-2 px-4 py-2 rounded-lg bg-seismo-orange text-white hover:bg-orange-600">
|
|
||||||
Recent
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% else %}
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-8 text-center text-gray-500 dark:text-gray-400">
|
|
||||||
<svg class="w-12 h-12 mx-auto mb-3 opacity-40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
|
||||||
</svg>
|
|
||||||
<p class="text-sm">No deployments in this window.</p>
|
|
||||||
<p class="text-xs mt-1">Try the navigation buttons below to look at a different range.</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
</div> {# /#dh-view-calendar #}
|
|
||||||
|
|
||||||
<!-- ─── Gantt view ─── -->
|
|
||||||
<div id="dh-view-gantt" class="hidden">
|
|
||||||
{% if calendar.projects %}
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 mb-6 overflow-x-auto">
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
|
||||||
One row per project. Each bar is one assignment window (unit at a location).
|
|
||||||
Hover for details, click to open the project.
|
|
||||||
Active deployments end at "now" with a small white tip.
|
|
||||||
</p>
|
|
||||||
<svg id="dh-gantt-svg" preserveAspectRatio="none"
|
|
||||||
style="width: 100%; min-width: 800px;"></svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Same nav as calendar view, repeated here so the Gantt view has its own. -->
|
|
||||||
<div class="flex items-center justify-center gap-3 mb-8">
|
|
||||||
<a href="/tools/deployment-history?year={{ prev_year }}&month={{ prev_month }}#gantt"
|
|
||||||
class="px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
|
|
||||||
title="Previous month">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<span class="text-lg font-bold text-gray-900 dark:text-white px-3">
|
|
||||||
{{ calendar.months[0].short_name }} '{{ calendar.months[0].year_short }} – {{ calendar.months[11].short_name }} '{{ calendar.months[11].year_short }}
|
|
||||||
</span>
|
|
||||||
<a href="/tools/deployment-history?year={{ next_year }}&month={{ next_month }}#gantt"
|
|
||||||
class="px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
|
|
||||||
title="Next month">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<a href="/tools/deployment-history#gantt"
|
|
||||||
class="ml-2 px-4 py-2 rounded-lg bg-seismo-orange text-white hover:bg-orange-600">
|
|
||||||
Recent
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-8 text-center text-gray-500 dark:text-gray-400">
|
|
||||||
<p class="text-sm">No deployments in this window.</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div> {# /#dh-view-gantt #}
|
|
||||||
|
|
||||||
<!-- ─── Gantt-by-Unit view ─── -->
|
|
||||||
<div id="dh-view-byunit" class="hidden">
|
|
||||||
{% if calendar.units %}
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 mb-6 overflow-x-auto">
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
|
||||||
One row per seismograph that had ≥1 assignment in this window.
|
|
||||||
Bars are colored by <strong>project</strong>, so an operator can read where each unit travelled
|
|
||||||
across jobs. Active deployments end at "now" with a small white tip.
|
|
||||||
Click a unit ID to open its detail page; click a bar to open the bar's project.
|
|
||||||
</p>
|
|
||||||
<svg id="dh-byunit-svg" preserveAspectRatio="none"
|
|
||||||
style="width: 100%; min-width: 800px;"></svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-center gap-3 mb-8">
|
|
||||||
<a href="/tools/deployment-history?year={{ prev_year }}&month={{ prev_month }}#byunit"
|
|
||||||
class="px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
|
|
||||||
title="Previous month">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<span class="text-lg font-bold text-gray-900 dark:text-white px-3">
|
|
||||||
{{ calendar.months[0].short_name }} '{{ calendar.months[0].year_short }} – {{ calendar.months[11].short_name }} '{{ calendar.months[11].year_short }}
|
|
||||||
</span>
|
|
||||||
<a href="/tools/deployment-history?year={{ next_year }}&month={{ next_month }}#byunit"
|
|
||||||
class="px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
|
|
||||||
title="Next month">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<a href="/tools/deployment-history#byunit"
|
|
||||||
class="ml-2 px-4 py-2 rounded-lg bg-seismo-orange text-white hover:bg-orange-600">
|
|
||||||
Recent
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-8 text-center text-gray-500 dark:text-gray-400">
|
|
||||||
<p class="text-sm">No unit deployments in this window.</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div> {# /#dh-view-byunit #}
|
|
||||||
|
|
||||||
<!-- Day-detail side panel -->
|
|
||||||
<div id="dh-day-panel-backdrop"
|
|
||||||
class="fixed inset-0 bg-black/30 z-40 hidden transition-opacity"
|
|
||||||
onclick="closeDhDayPanel()"></div>
|
|
||||||
<div id="dh-day-panel"
|
|
||||||
class="fixed top-0 right-0 h-screen w-full max-w-md bg-white dark:bg-slate-800 shadow-2xl z-50 hidden transform translate-x-full transition-transform duration-300 overflow-y-auto">
|
|
||||||
<div class="sticky top-0 bg-white dark:bg-slate-800 border-b border-gray-200 dark:border-gray-700 px-5 py-4 flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white" id="dh-day-panel-title">Deployments on…</h2>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400" id="dh-day-panel-subtitle"></p>
|
|
||||||
</div>
|
|
||||||
<button onclick="closeDhDayPanel()" class="text-gray-400 hover:text-gray-700 dark:hover:text-gray-200">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="dh-day-panel-body" class="p-5 space-y-3">
|
|
||||||
<div class="text-center py-8 text-gray-500 dark:text-gray-400 text-sm">
|
|
||||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-seismo-orange mx-auto mb-2"></div>
|
|
||||||
Loading…
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function _dhEsc(s) {
|
|
||||||
if (s == null) return '';
|
|
||||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openDhDay(dateStr) {
|
|
||||||
const panel = document.getElementById('dh-day-panel');
|
|
||||||
const backdrop = document.getElementById('dh-day-panel-backdrop');
|
|
||||||
const title = document.getElementById('dh-day-panel-title');
|
|
||||||
const sub = document.getElementById('dh-day-panel-subtitle');
|
|
||||||
const body = document.getElementById('dh-day-panel-body');
|
|
||||||
|
|
||||||
title.textContent = 'Deployments on ' + dateStr;
|
|
||||||
sub.textContent = '';
|
|
||||||
body.innerHTML = `<div class="text-center py-8 text-gray-500 dark:text-gray-400 text-sm">
|
|
||||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-seismo-orange mx-auto mb-2"></div>
|
|
||||||
Loading…
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
panel.classList.remove('hidden');
|
|
||||||
backdrop.classList.remove('hidden');
|
|
||||||
requestAnimationFrame(() => panel.classList.remove('translate-x-full'));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const r = await fetch(`/api/admin/deployment-history/day?target_date=${encodeURIComponent(dateStr)}`);
|
|
||||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
||||||
const d = await r.json();
|
|
||||||
renderDhDayPanel(d, dateStr);
|
|
||||||
} catch (e) {
|
|
||||||
body.innerHTML = `<p class="text-sm text-red-500">Failed to load: ${_dhEsc(e.message)}</p>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderDhDayPanel(d, dateStr) {
|
|
||||||
const sub = document.getElementById('dh-day-panel-subtitle');
|
|
||||||
const body = document.getElementById('dh-day-panel-body');
|
|
||||||
|
|
||||||
sub.textContent = `${d.count} active deployment${d.count === 1 ? '' : 's'}`;
|
|
||||||
|
|
||||||
if (d.count === 0) {
|
|
||||||
body.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No active deployments on this date.</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group by project for cleaner display.
|
|
||||||
const byProj = {};
|
|
||||||
for (const dep of d.deployments) {
|
|
||||||
const key = dep.project_id || '_none';
|
|
||||||
if (!byProj[key]) byProj[key] = { name: dep.project_name, color: dep.project_color, items: [] };
|
|
||||||
byProj[key].items.push(dep);
|
|
||||||
}
|
|
||||||
|
|
||||||
let html = '';
|
|
||||||
for (const pid of Object.keys(byProj)) {
|
|
||||||
const proj = byProj[pid];
|
|
||||||
html += `<div class="space-y-1">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<a href="/projects/${_dhEsc(pid)}" class="flex items-center gap-2 font-medium text-gray-900 dark:text-white hover:text-seismo-orange">
|
|
||||||
<span class="w-3 h-3 rounded-full" style="background-color: ${proj.color};"></span>
|
|
||||||
${_dhEsc(proj.name)}
|
|
||||||
</a>
|
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-400">${proj.items.length} unit${proj.items.length === 1 ? '' : 's'}</span>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1 ml-5">`;
|
|
||||||
for (const dep of proj.items) {
|
|
||||||
const activeBadge = dep.is_active
|
|
||||||
? '<span class="text-[10px] uppercase tracking-wider px-1 py-0.5 rounded bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">active</span>'
|
|
||||||
: '';
|
|
||||||
const sourceTag = dep.source === 'metadata_backfill'
|
|
||||||
? '<span class="text-[10px] text-blue-600 dark:text-blue-400 italic">auto-backfilled</span>'
|
|
||||||
: '';
|
|
||||||
const windowEnd = dep.is_active ? 'present' : (dep.assigned_until || '').slice(0, 10);
|
|
||||||
const windowStart = (dep.assigned_at || '').slice(0, 10);
|
|
||||||
html += `<div class="flex items-center justify-between gap-2 py-1 text-sm">
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
|
||||||
<a href="/unit/${_dhEsc(dep.unit_id)}" class="font-mono font-medium text-seismo-orange hover:text-seismo-navy">${_dhEsc(dep.unit_id)}</a>
|
|
||||||
${activeBadge}
|
|
||||||
${sourceTag}
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400 truncate">
|
|
||||||
📍 <a href="/projects/${_dhEsc(pid)}/nrl/${_dhEsc(dep.location_id)}" class="hover:text-seismo-orange">${_dhEsc(dep.location_name)}</a>
|
|
||||||
</div>
|
|
||||||
<div class="text-[11px] text-gray-400 dark:text-gray-500 font-mono">${windowStart} → ${windowEnd}</div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
html += `</div></div>`;
|
|
||||||
}
|
|
||||||
body.innerHTML = html;
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeDhDayPanel() {
|
|
||||||
const panel = document.getElementById('dh-day-panel');
|
|
||||||
const backdrop = document.getElementById('dh-day-panel-backdrop');
|
|
||||||
panel.classList.add('translate-x-full');
|
|
||||||
setTimeout(() => {
|
|
||||||
panel.classList.add('hidden');
|
|
||||||
backdrop.classList.add('hidden');
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Escape') closeDhDayPanel();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Tab switcher ────────────────────────────────────────────────────
|
|
||||||
const _DH_TABS = {
|
|
||||||
calendar: { view: 'dh-view-calendar', btn: 'dh-tab-calendar', hash: '', render: null },
|
|
||||||
gantt: { view: 'dh-view-gantt', btn: 'dh-tab-gantt', hash: '#gantt', render: () => renderDhGantt() },
|
|
||||||
byunit: { view: 'dh-view-byunit', btn: 'dh-tab-byunit', hash: '#byunit', render: () => renderDhByUnit() },
|
|
||||||
};
|
|
||||||
|
|
||||||
function switchDhView(which) {
|
|
||||||
const target = _DH_TABS[which] || _DH_TABS.calendar;
|
|
||||||
const activeCls = ['bg-white', 'dark:bg-slate-600', 'text-gray-900', 'dark:text-white', 'shadow'];
|
|
||||||
const dormantCls = ['text-gray-600', 'dark:text-gray-300', 'hover:text-gray-900', 'dark:hover:text-white'];
|
|
||||||
// Hide all views, dormant-style all tabs, then activate the chosen one.
|
|
||||||
for (const key of Object.keys(_DH_TABS)) {
|
|
||||||
const t = _DH_TABS[key];
|
|
||||||
const v = document.getElementById(t.view);
|
|
||||||
const b = document.getElementById(t.btn);
|
|
||||||
if (v) v.classList.add('hidden');
|
|
||||||
if (b) { b.classList.remove(...activeCls); b.classList.add(...dormantCls); }
|
|
||||||
}
|
|
||||||
const v = document.getElementById(target.view);
|
|
||||||
const b = document.getElementById(target.btn);
|
|
||||||
if (v) v.classList.remove('hidden');
|
|
||||||
if (b) { b.classList.add(...activeCls); b.classList.remove(...dormantCls); }
|
|
||||||
// URL hash sync.
|
|
||||||
const wanted = target.hash;
|
|
||||||
const cur = window.location.hash || '';
|
|
||||||
if (cur !== wanted) {
|
|
||||||
history.replaceState(null, '', window.location.pathname + window.location.search + wanted);
|
|
||||||
}
|
|
||||||
if (target.render) target.render();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Land on the requested view if the URL hash matches.
|
|
||||||
const _initialHash = window.location.hash;
|
|
||||||
if (_initialHash === '#gantt' || _initialHash === '#byunit') {
|
|
||||||
document.addEventListener('DOMContentLoaded', () => switchDhView(_initialHash.slice(1)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Gantt renderer ──────────────────────────────────────────────────
|
|
||||||
// Server-rendered data: per-project list with each project's bars
|
|
||||||
// (assignment windows clipped to the visible window).
|
|
||||||
const _dhProjects = {{ calendar.projects | tojson }};
|
|
||||||
const _dhWindowStart = {{ calendar.window.first_date | tojson }};
|
|
||||||
const _dhWindowEnd = {{ calendar.window.last_date | tojson }};
|
|
||||||
|
|
||||||
let _ganttRendered = false;
|
|
||||||
function renderDhGantt() {
|
|
||||||
if (_ganttRendered) return; // build once; data doesn't change after page load
|
|
||||||
_ganttRendered = true;
|
|
||||||
const svg = document.getElementById('dh-gantt-svg');
|
|
||||||
if (!svg) return;
|
|
||||||
if (!_dhProjects || _dhProjects.length === 0) {
|
|
||||||
svg.innerHTML = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDark = document.documentElement.classList.contains('dark');
|
|
||||||
const labelColor = isDark ? '#9ca3af' : '#6b7280';
|
|
||||||
const gridColor = isDark ? '#374151' : '#e5e7eb';
|
|
||||||
const rowAltBg = isDark ? '#1e293b' : '#f9fafb';
|
|
||||||
const todayColor = '#f48b1c';
|
|
||||||
|
|
||||||
// Geometry. The Gantt SVG has a left "labels" gutter and a right
|
|
||||||
// time-axis area. Width is fluid (matches container); height grows
|
|
||||||
// with the number of project rows.
|
|
||||||
const containerW = svg.parentElement.clientWidth || 1000;
|
|
||||||
const width = Math.max(containerW, 800);
|
|
||||||
const labelW = 220;
|
|
||||||
const padTop = 36; // room for month labels above
|
|
||||||
const padBottom = 16;
|
|
||||||
const rowH = 36;
|
|
||||||
const barH = 18;
|
|
||||||
const height = padTop + padBottom + _dhProjects.length * rowH;
|
|
||||||
|
|
||||||
const usableW = width - labelW - 8;
|
|
||||||
|
|
||||||
const tStart = new Date(_dhWindowStart + 'T00:00:00Z').getTime();
|
|
||||||
const tEnd = new Date(_dhWindowEnd + 'T23:59:59Z').getTime();
|
|
||||||
const tRange = tEnd - tStart;
|
|
||||||
const xFor = (d) => {
|
|
||||||
const ms = (typeof d === 'string') ? new Date(d + 'T00:00:00Z').getTime() : d;
|
|
||||||
return labelW + Math.max(0, Math.min(usableW, (ms - tStart) / tRange * usableW));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Month gridlines + month labels along the top.
|
|
||||||
// Walk every 1st-of-month inside the window.
|
|
||||||
const monthsParts = [];
|
|
||||||
const cursor = new Date(_dhWindowStart + 'T00:00:00Z');
|
|
||||||
cursor.setUTCDate(1);
|
|
||||||
while (cursor.getTime() <= tEnd) {
|
|
||||||
const x = xFor(cursor);
|
|
||||||
const label = cursor.toLocaleDateString('en-US', { month: 'short', year: '2-digit', timeZone: 'UTC' });
|
|
||||||
monthsParts.push(`<line x1="${x}" y1="${padTop}" x2="${x}" y2="${height - padBottom}" stroke="${gridColor}" stroke-width="1"/>`);
|
|
||||||
monthsParts.push(`<text x="${x + 2}" y="${padTop - 6}" font-size="10" fill="${labelColor}" font-family="system-ui,sans-serif">${label}</text>`);
|
|
||||||
cursor.setUTCMonth(cursor.getUTCMonth() + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Today marker.
|
|
||||||
const now = Date.now();
|
|
||||||
let todayMarker = '';
|
|
||||||
if (now >= tStart && now <= tEnd) {
|
|
||||||
const x = xFor(now);
|
|
||||||
todayMarker = `<line x1="${x}" y1="${padTop}" x2="${x}" y2="${height - padBottom}" stroke="${todayColor}" stroke-width="2" stroke-dasharray="3 2" opacity="0.85"/>
|
|
||||||
<text x="${x + 3}" y="${padTop - 20}" font-size="9" fill="${todayColor}" font-family="system-ui,sans-serif">today</text>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build rows.
|
|
||||||
const rowParts = [];
|
|
||||||
_dhProjects.forEach((proj, idx) => {
|
|
||||||
const y = padTop + idx * rowH;
|
|
||||||
// Alternating row background.
|
|
||||||
if (idx % 2 === 1) {
|
|
||||||
rowParts.push(`<rect x="0" y="${y}" width="${width}" height="${rowH}" fill="${rowAltBg}"/>`);
|
|
||||||
}
|
|
||||||
// Project label (clipped to gutter, clickable).
|
|
||||||
const labelText = proj.name.length > 28 ? proj.name.slice(0, 26) + '…' : proj.name;
|
|
||||||
rowParts.push(`<a href="/projects/${_dhEsc(proj.id)}" target="_top">
|
|
||||||
<rect x="0" y="${y}" width="${labelW}" height="${rowH}" fill="transparent"/>
|
|
||||||
<circle cx="14" cy="${y + rowH / 2}" r="4" fill="${proj.color}"/>
|
|
||||||
<text x="24" y="${y + rowH / 2 + 4}" font-size="12" fill="${labelColor}"
|
|
||||||
font-family="system-ui,sans-serif"
|
|
||||||
title="${_dhEsc(proj.name)}">
|
|
||||||
${_dhEsc(labelText)}
|
|
||||||
</text>
|
|
||||||
</a>`);
|
|
||||||
// Bars for each assignment in this project.
|
|
||||||
for (const bar of (proj.bars || [])) {
|
|
||||||
const x1 = xFor(bar.start);
|
|
||||||
const x2 = xFor(bar.end);
|
|
||||||
const barW = Math.max(x2 - x1, 2);
|
|
||||||
const by = y + (rowH - barH) / 2;
|
|
||||||
const tip = `${bar.unit_id} @ ${bar.location_name}\n${bar.start} → ${bar.is_active ? 'present' : bar.end}`;
|
|
||||||
rowParts.push(`<g style="cursor: pointer;">
|
|
||||||
<title>${_dhEsc(tip)}</title>
|
|
||||||
<rect x="${x1}" y="${by}" width="${barW}" height="${barH}"
|
|
||||||
rx="3"
|
|
||||||
fill="${proj.color}" opacity="${bar.is_active ? 1.0 : 0.75}"
|
|
||||||
${bar.source === 'metadata_backfill' ? 'stroke="#3b82f6" stroke-width="1.5"' : ''}/>
|
|
||||||
${bar.is_active ? `<rect x="${x2 - 3}" y="${by}" width="3" height="${barH}" fill="#ffffff" opacity="0.8"/>` : ''}
|
|
||||||
</g>`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
|
||||||
svg.setAttribute('height', height);
|
|
||||||
svg.innerHTML = monthsParts.join('') + rowParts.join('') + todayMarker;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Gantt-by-Unit renderer ──────────────────────────────────────────
|
|
||||||
// Inverts the Gantt: rows = seismographs, bars = assignment windows
|
|
||||||
// colored by project (so an operator can read where each unit went
|
|
||||||
// across jobs). Shares the time-axis geometry with renderDhGantt().
|
|
||||||
const _dhUnits = {{ calendar.units | tojson }};
|
|
||||||
let _byunitRendered = false;
|
|
||||||
function renderDhByUnit() {
|
|
||||||
if (_byunitRendered) return;
|
|
||||||
_byunitRendered = true;
|
|
||||||
const svg = document.getElementById('dh-byunit-svg');
|
|
||||||
if (!svg) return;
|
|
||||||
if (!_dhUnits || _dhUnits.length === 0) {
|
|
||||||
svg.innerHTML = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDark = document.documentElement.classList.contains('dark');
|
|
||||||
const labelColor = isDark ? '#9ca3af' : '#6b7280';
|
|
||||||
const labelStrong = isDark ? '#e5e7eb' : '#111827';
|
|
||||||
const gridColor = isDark ? '#374151' : '#e5e7eb';
|
|
||||||
const rowAltBg = isDark ? '#1e293b' : '#f9fafb';
|
|
||||||
const todayColor = '#f48b1c';
|
|
||||||
|
|
||||||
const containerW = svg.parentElement.clientWidth || 1000;
|
|
||||||
const width = Math.max(containerW, 800);
|
|
||||||
const labelW = 160;
|
|
||||||
const padTop = 36;
|
|
||||||
const padBottom = 16;
|
|
||||||
const rowH = 32;
|
|
||||||
const barH = 16;
|
|
||||||
const height = padTop + padBottom + _dhUnits.length * rowH;
|
|
||||||
|
|
||||||
const usableW = width - labelW - 8;
|
|
||||||
const tStart = new Date(_dhWindowStart + 'T00:00:00Z').getTime();
|
|
||||||
const tEnd = new Date(_dhWindowEnd + 'T23:59:59Z').getTime();
|
|
||||||
const tRange = tEnd - tStart;
|
|
||||||
const xFor = (d) => {
|
|
||||||
const ms = (typeof d === 'string') ? new Date(d + 'T00:00:00Z').getTime() : d;
|
|
||||||
return labelW + Math.max(0, Math.min(usableW, (ms - tStart) / tRange * usableW));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Month gridlines + labels (same as renderDhGantt).
|
|
||||||
const monthsParts = [];
|
|
||||||
const cursor = new Date(_dhWindowStart + 'T00:00:00Z');
|
|
||||||
cursor.setUTCDate(1);
|
|
||||||
while (cursor.getTime() <= tEnd) {
|
|
||||||
const x = xFor(cursor);
|
|
||||||
const label = cursor.toLocaleDateString('en-US', { month: 'short', year: '2-digit', timeZone: 'UTC' });
|
|
||||||
monthsParts.push(`<line x1="${x}" y1="${padTop}" x2="${x}" y2="${height - padBottom}" stroke="${gridColor}" stroke-width="1"/>`);
|
|
||||||
monthsParts.push(`<text x="${x + 2}" y="${padTop - 6}" font-size="10" fill="${labelColor}" font-family="system-ui,sans-serif">${label}</text>`);
|
|
||||||
cursor.setUTCMonth(cursor.getUTCMonth() + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
let todayMarker = '';
|
|
||||||
if (now >= tStart && now <= tEnd) {
|
|
||||||
const x = xFor(now);
|
|
||||||
todayMarker = `<line x1="${x}" y1="${padTop}" x2="${x}" y2="${height - padBottom}" stroke="${todayColor}" stroke-width="2" stroke-dasharray="3 2" opacity="0.85"/>
|
|
||||||
<text x="${x + 3}" y="${padTop - 20}" font-size="9" fill="${todayColor}" font-family="system-ui,sans-serif">today</text>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rowParts = [];
|
|
||||||
_dhUnits.forEach((unit, idx) => {
|
|
||||||
const y = padTop + idx * rowH;
|
|
||||||
if (idx % 2 === 1) {
|
|
||||||
rowParts.push(`<rect x="0" y="${y}" width="${width}" height="${rowH}" fill="${rowAltBg}"/>`);
|
|
||||||
}
|
|
||||||
// Unit label: opens /unit/{id} when clicked. Active-now dot.
|
|
||||||
const activeDot = unit.any_active
|
|
||||||
? `<circle cx="12" cy="${y + rowH / 2}" r="3.5" fill="#22c55e"/>`
|
|
||||||
: `<circle cx="12" cy="${y + rowH / 2}" r="3.5" fill="${labelColor}" opacity="0.4"/>`;
|
|
||||||
rowParts.push(`<a href="/unit/${_dhEsc(unit.id)}" target="_top">
|
|
||||||
<rect x="0" y="${y}" width="${labelW}" height="${rowH}" fill="transparent"/>
|
|
||||||
${activeDot}
|
|
||||||
<text x="22" y="${y + rowH / 2 + 4}" font-size="12" fill="${labelStrong}"
|
|
||||||
font-family="system-ui,sans-serif" font-weight="500">
|
|
||||||
${_dhEsc(unit.id)}
|
|
||||||
</text>
|
|
||||||
<text x="${labelW - 8}" y="${y + rowH / 2 + 4}" font-size="10" fill="${labelColor}"
|
|
||||||
text-anchor="end" font-family="system-ui,sans-serif">
|
|
||||||
${unit.assignment_count}
|
|
||||||
</text>
|
|
||||||
</a>`);
|
|
||||||
// Bars — colored by the bar's project (not the row).
|
|
||||||
for (const bar of (unit.bars || [])) {
|
|
||||||
const x1 = xFor(bar.start);
|
|
||||||
const x2 = xFor(bar.end);
|
|
||||||
const barW = Math.max(x2 - x1, 2);
|
|
||||||
const by = y + (rowH - barH) / 2;
|
|
||||||
const tip = `${bar.project_name}\n${bar.location_name}\n${bar.start} → ${bar.is_active ? 'present' : bar.end}`;
|
|
||||||
rowParts.push(`<a href="/projects/${_dhEsc(bar.project_id)}" target="_top">
|
|
||||||
<g style="cursor: pointer;">
|
|
||||||
<title>${_dhEsc(tip)}</title>
|
|
||||||
<rect x="${x1}" y="${by}" width="${barW}" height="${barH}"
|
|
||||||
rx="3"
|
|
||||||
fill="${bar.project_color}" opacity="${bar.is_active ? 1.0 : 0.75}"
|
|
||||||
${bar.source === 'metadata_backfill' ? 'stroke="#3b82f6" stroke-width="1.5"' : ''}/>
|
|
||||||
${bar.is_active ? `<rect x="${x2 - 3}" y="${by}" width="3" height="${barH}" fill="#ffffff" opacity="0.8"/>` : ''}
|
|
||||||
</g>
|
|
||||||
</a>`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
|
||||||
svg.setAttribute('height', height);
|
|
||||||
svg.innerHTML = monthsParts.join('') + rowParts.join('') + todayMarker;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
+5
-6
@@ -210,9 +210,7 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bottom Navigation (Mobile Only) — primary field-work shortcuts.
|
<!-- Bottom Navigation (Mobile Only) -->
|
||||||
Settings + Projects + Job Planner are reachable via the
|
|
||||||
hamburger Menu (slot 1) which opens the full sidebar drawer. -->
|
|
||||||
<nav class="bottom-nav{% if request.query_params.get('embed') == '1' %} hidden{% endif %}">
|
<nav class="bottom-nav{% if request.query_params.get('embed') == '1' %} hidden{% endif %}">
|
||||||
<div class="grid grid-cols-4 h-16">
|
<div class="grid grid-cols-4 h-16">
|
||||||
<button id="hamburgerBtn" class="bottom-nav-btn" onclick="toggleMenu()" aria-label="Menu">
|
<button id="hamburgerBtn" class="bottom-nav-btn" onclick="toggleMenu()" aria-label="Menu">
|
||||||
@@ -233,11 +231,12 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span>Devices</span>
|
<span>Devices</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="bottom-nav-btn" data-href="/sfm" onclick="window.location.href='/sfm'">
|
<button class="bottom-nav-btn" data-href="/settings" onclick="window.location.href='/settings'">
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<span>Events</span>
|
<span>Settings</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -1,159 +0,0 @@
|
|||||||
<!-- Reusable project-location map.
|
|
||||||
|
|
||||||
Renders a Leaflet map with one pin per active monitoring location for
|
|
||||||
the given project. Fetches data from /api/projects/{p}/locations-json
|
|
||||||
on load.
|
|
||||||
|
|
||||||
Required context variable:
|
|
||||||
project_id — UUID string
|
|
||||||
|
|
||||||
Optional context variable:
|
|
||||||
map_height — CSS height (default "320px")
|
|
||||||
location_type — filter the fetched list (default: all types)
|
|
||||||
|
|
||||||
Hover any .location-card on the page with a matching
|
|
||||||
data-location-id → highlights the pin. Click a pin → scrolls
|
|
||||||
the matching card into view + flashes an orange ring.
|
|
||||||
|
|
||||||
isolation:isolate on the container forces a new stacking context so
|
|
||||||
Leaflet's internal z-indexes (200-800) stay contained inside the
|
|
||||||
card instead of leaking out and rendering over modals (z-50).
|
|
||||||
-->
|
|
||||||
<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>
|
|
||||||
<span class="text-xs text-gray-400 dark:text-gray-500" id="loc-map-status-{{ project_id }}"></span>
|
|
||||||
</div>
|
|
||||||
<div id="loc-map-{{ project_id }}"
|
|
||||||
class="w-full rounded-lg border border-gray-200 dark:border-gray-700"
|
|
||||||
style="height: {{ map_height | default('320px') }}; background: rgba(0,0,0,0.05); isolation: isolate;">
|
|
||||||
</div>
|
|
||||||
<div id="loc-map-empty-{{ project_id }}" 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="loc-map-missing-{{ project_id }}" class="hidden text-xs text-gray-500 dark:text-gray-400 mt-2"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
const projectId = {{ project_id | tojson }};
|
|
||||||
const locationType = {{ (location_type | default(none)) | tojson }};
|
|
||||||
const mapEl = document.getElementById('loc-map-' + projectId);
|
|
||||||
const emptyMsg = document.getElementById('loc-map-empty-' + projectId);
|
|
||||||
const missingMsg = document.getElementById('loc-map-missing-' + projectId);
|
|
||||||
const statusEl = document.getElementById('loc-map-status-' + projectId);
|
|
||||||
if (!mapEl) return;
|
|
||||||
|
|
||||||
function parseCoords(s) {
|
|
||||||
if (!s) return null;
|
|
||||||
const parts = String(s).split(',').map(x => parseFloat(x.trim()));
|
|
||||||
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];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function init() {
|
|
||||||
statusEl.textContent = 'loading…';
|
|
||||||
let locs;
|
|
||||||
try {
|
|
||||||
const qs = locationType ? `?location_type=${encodeURIComponent(locationType)}` : '';
|
|
||||||
const r = await fetch(`/api/projects/${projectId}/locations-json${qs}`);
|
|
||||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
||||||
locs = await r.json();
|
|
||||||
} catch (e) {
|
|
||||||
statusEl.textContent = 'failed';
|
|
||||||
mapEl.innerHTML = `<div class="flex items-center justify-center h-full text-sm text-red-500">Map load failed: ${e.message}</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
statusEl.textContent = '';
|
|
||||||
|
|
||||||
const withCoords = [];
|
|
||||||
const withoutCoords = [];
|
|
||||||
for (const loc of locs) {
|
|
||||||
const xy = parseCoords(loc.coordinates);
|
|
||||||
if (xy) withCoords.push({ ...loc, latlon: xy });
|
|
||||||
else withoutCoords.push(loc);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (withCoords.length === 0) {
|
|
||||||
mapEl.classList.add('hidden');
|
|
||||||
emptyMsg.classList.remove('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 markersById = {};
|
|
||||||
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', () => _lmFlashCard(loc.id));
|
|
||||||
markersById[loc.id] = marker;
|
|
||||||
bounds.push(loc.latlon);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (bounds.length === 1) map.setView(bounds[0], 14);
|
|
||||||
else map.fitBounds(bounds, { padding: [20, 20] });
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hover any .location-card on the page → highlight matching pin.
|
|
||||||
let hoverPinId = null;
|
|
||||||
document.addEventListener('mouseover', (e) => {
|
|
||||||
const card = e.target.closest('.location-card');
|
|
||||||
if (!card) return;
|
|
||||||
const locId = card.dataset.locationId;
|
|
||||||
if (!markersById[locId] || locId === hoverPinId) return;
|
|
||||||
if (hoverPinId) _unhighlight(hoverPinId);
|
|
||||||
_highlight(locId);
|
|
||||||
hoverPinId = locId;
|
|
||||||
});
|
|
||||||
document.addEventListener('mouseout', (e) => {
|
|
||||||
const card = e.target.closest('.location-card');
|
|
||||||
if (!card) return;
|
|
||||||
const related = e.relatedTarget && e.relatedTarget.closest('.location-card');
|
|
||||||
if (related === card) return;
|
|
||||||
if (hoverPinId) {
|
|
||||||
_unhighlight(hoverPinId);
|
|
||||||
hoverPinId = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function _highlight(locId) {
|
|
||||||
const m = markersById[locId]; if (!m) return;
|
|
||||||
m.setStyle({ radius: 12, fillColor: '#dc2626', weight: 3 });
|
|
||||||
m.openTooltip();
|
|
||||||
m.bringToFront();
|
|
||||||
}
|
|
||||||
function _unhighlight(locId) {
|
|
||||||
const m = markersById[locId]; if (!m) return;
|
|
||||||
m.setStyle({ radius: 8, fillColor: '#f48b1c', weight: 2 });
|
|
||||||
m.closeTooltip();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function _lmFlashCard(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);
|
|
||||||
}
|
|
||||||
|
|
||||||
init();
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
@@ -78,19 +78,128 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Location map — uses the reusable partial that fetches from
|
<!-- Location Map — replaces the old Upcoming Actions panel for the
|
||||||
/api/projects/{p}/locations-json. Same render is reused on the
|
overview. Operators get a quick visual of where their locations
|
||||||
deeper Vibration tab so both surfaces stay in sync. #}
|
sit relative to each other. Pins clickable → scroll to + flash
|
||||||
{% with project_id=project.id %}
|
the matching card. Locations without coordinates land in a
|
||||||
{% include 'partials/projects/location_map.html' %}
|
"missing coords" hint below the map.
|
||||||
{% endwith %}
|
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 %}
|
||||||
|
<a href="javascript:void(0)" onclick="switchTab('schedules')"
|
||||||
|
class="text-xs text-seismo-orange hover:text-seismo-navy whitespace-nowrap">
|
||||||
|
{{ upcoming_actions | length }} upcoming action{{ '' if upcoming_actions | length == 1 else 's' }} →
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</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>
|
</div>
|
||||||
|
|
||||||
{% if upcoming_actions %}
|
<script>
|
||||||
<div class="mt-3 text-xs text-right text-gray-500 dark:text-gray-400">
|
(function () {
|
||||||
<a href="javascript:void(0)" onclick="switchTab('schedules')"
|
// Build location data from server-side render. Skip removed
|
||||||
class="text-seismo-orange hover:text-seismo-navy">
|
// locations (their pins would clutter the active operations view)
|
||||||
{{ upcoming_actions | length }} upcoming action{{ '' if upcoming_actions | length == 1 else 's' }} →
|
// and skip ones without parseable coordinates.
|
||||||
</a>
|
const locationsRaw = [
|
||||||
</div>
|
{% for loc in locations %}
|
||||||
{% endif %}
|
{% 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 %}
|
||||||
|
];
|
||||||
|
|
||||||
|
function parseCoords(s) {
|
||||||
|
if (!s) return null;
|
||||||
|
const parts = String(s).split(',').map(x => parseFloat(x.trim()));
|
||||||
|
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>
|
||||||
|
|||||||
@@ -101,33 +101,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 lg:col-span-2">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring Locations</h2>
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring Locations</h2>
|
<button onclick="openLocationModal('vibration')"
|
||||||
<button onclick="openLocationModal('vibration')"
|
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||||
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
<svg class="w-5 h-5 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<svg class="w-5 h-5 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
</svg>
|
||||||
</svg>
|
Add Location
|
||||||
Add Location
|
</button>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="vibration-locations"
|
|
||||||
hx-get="/api/projects/{{ project_id }}/locations?location_type=vibration"
|
|
||||||
hx-trigger="load"
|
|
||||||
hx-swap="innerHTML">
|
|
||||||
<div class="text-center py-8 text-gray-500">Loading locations...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div id="vibration-locations"
|
||||||
{# Reusable location map — fetches from /locations-json
|
hx-get="/api/projects/{{ project_id }}/locations?location_type=vibration"
|
||||||
on its own. Hovering any of the location cards on the
|
hx-trigger="load"
|
||||||
left highlights the matching pin on this map. #}
|
hx-swap="innerHTML">
|
||||||
<div>
|
<div class="text-center py-8 text-gray-500">Loading locations...</div>
|
||||||
{% with project_id=project_id, location_type='vibration', map_height='450px' %}
|
|
||||||
{% include 'partials/projects/location_map.html' %}
|
|
||||||
{% endwith %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -68,24 +68,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Deployment History -->
|
|
||||||
<a href="/tools/deployment-history"
|
|
||||||
class="block bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 hover:shadow-xl transition-shadow border border-transparent hover:border-seismo-orange">
|
|
||||||
<div class="flex items-start gap-3">
|
|
||||||
<div class="w-10 h-10 rounded-lg bg-indigo-100 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 flex items-center justify-center shrink-0">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<h3 class="font-semibold text-gray-900 dark:text-white">Deployment History</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
|
||||||
12-month calendar of every unit assignment across every project. Visual bars per project per day; click a day for the full active-deployments list.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- Reports (per-project) -->
|
<!-- Reports (per-project) -->
|
||||||
<a href="/projects"
|
<a href="/projects"
|
||||||
class="block bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 hover:shadow-xl transition-shadow border border-transparent hover:border-seismo-orange">
|
class="block bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5 hover:shadow-xl transition-shadow border border-transparent hover:border-seismo-orange">
|
||||||
|
|||||||
Reference in New Issue
Block a user