From 2ee7ddb047614eb17dc89e7c39657793f49bd0b1 Mon Sep 17 00:00:00 2001 From: zblauser <54143933+zblauser@users.noreply.github.com> Date: Fri, 3 Jul 2026 02:55:51 -0400 Subject: [PATCH] v0.0.9 postgres as a platform rewrite --- .dockerignore | 8 +- .gitignore | 3 + Dockerfile | 18 +- README.md | 76 +- backend/.env.example | 28 - backend/api/main.py | 35 +- backend/api/routes/htmx.py | 652 ++++ backend/api/routes/simulation.py | 7 +- backend/config.py | 2 +- backend/logic/assignments.py | 7 +- backend/requirements.txt | 22 - backend/simulation/loop.py | 78 +- .../index.css => backend/static/app.css | 2171 ++++------- backend/static/vendor/README.md | 26 + backend/static/vendor/htmx.min.js | 1 + backend/static/vendor/idiomorph-ext.min.js | 1 + .../static/vendor/images/marker-icon-2x.png | Bin 0 -> 2464 bytes backend/static/vendor/images/marker-icon.png | Bin 0 -> 1466 bytes .../static/vendor/images/marker-shadow.png | Bin 0 -> 618 bytes backend/static/vendor/leaflet.css | 661 ++++ backend/static/vendor/leaflet.js | 6 + backend/templates/_dash_bar.html | 27 + backend/templates/_job_table.html | 40 + backend/templates/_override_modal.html | 29 + backend/templates/_sim_bar.html | 39 + backend/templates/_tech_list.html | 33 + backend/templates/_timeline.html | 41 + backend/templates/_win_filter.html | 71 + backend/templates/_win_job_detail.html | 59 + backend/templates/_win_personnel.html | 30 + backend/templates/_win_search.html | 71 + backend/templates/_win_settings.html | 59 + backend/templates/_win_tech_detail.html | 58 + backend/templates/base.html | 16 + backend/templates/dashboard.html | 905 +++++ backend/templates/map_window.html | 68 + db/functions/00_media.sql | 11 + db/functions/01_helpers.sql | 23 + db/functions/02_clock.sql | 48 + db/functions/10_tech_list.sql | 46 + db/functions/11_dash_bar.sql | 59 + db/functions/12_job_table.sql | 85 + db/functions/13_timeline.sql | 97 + db/functions/14_win_personnel.sql | 46 + db/functions/15_win_tech_detail.sql | 91 + db/functions/16_win_job_detail.sql | 100 + db/functions/17_win_filter.sql | 69 + db/functions/18_win_search.sql | 120 + db/functions/19_map_markers.sql | 76 + db/functions/20_assign.sql | 122 + db/functions/21_sim.sql | 136 + db/postgrest/00_bootstrap.sql | 20 + db/schema/schema.sql | 335 ++ deploy/nginx.conf | 41 - frontend/eslint.config.js | 29 - frontend/index.html | 13 - frontend/package-lock.json | 3287 ----------------- frontend/package.json | 34 - frontend/public/vite.svg | 1 - frontend/src.bak/App.css | 4 - frontend/src.bak/App.jsx | 8 - frontend/src.bak/api/client.js | 31 - frontend/src.bak/assets/react.svg | 1 - frontend/src.bak/components/Dashboard.jsx | 211 -- frontend/src.bak/components/JobList.jsx | 86 - frontend/src.bak/components/Map.jsx | 116 - frontend/src.bak/index.css | 20 - frontend/src.bak/main.jsx | 10 - frontend/src/App.jsx | 7 - frontend/src/api/client.js | 76 - frontend/src/assets/react.svg | 1 - frontend/src/components/CalendarPicker.jsx | 75 - frontend/src/components/ContextMenu.jsx | 182 - frontend/src/components/Dashboard.jsx | 813 ---- frontend/src/components/FilterWindow.jsx | 176 - frontend/src/components/FloatingWindow.jsx | 114 - frontend/src/components/JobDetailPanel.jsx | 125 - frontend/src/components/JobGrid.jsx | 274 -- frontend/src/components/JobSearchWindow.jsx | 306 -- frontend/src/components/Map.jsx | 116 - frontend/src/components/MapWindow.jsx | 327 -- frontend/src/components/PersonnelWindow.jsx | 122 - frontend/src/components/SimBar.jsx | 166 - frontend/src/components/TechGrid.jsx | 163 - frontend/src/components/TechTimeline.jsx | 166 - frontend/src/components/Toast.jsx | 24 - frontend/src/hooks/useSimEvents.js | 47 - frontend/src/main.jsx | 10 - frontend/vite.config.js | 21 - requirements.txt | 3 + 90 files changed, 5309 insertions(+), 8729 deletions(-) delete mode 100644 backend/.env.example create mode 100644 backend/api/routes/htmx.py delete mode 100644 backend/requirements.txt rename frontend/src/styles/index.css => backend/static/app.css (57%) create mode 100644 backend/static/vendor/README.md create mode 100644 backend/static/vendor/htmx.min.js create mode 100644 backend/static/vendor/idiomorph-ext.min.js create mode 100644 backend/static/vendor/images/marker-icon-2x.png create mode 100644 backend/static/vendor/images/marker-icon.png create mode 100644 backend/static/vendor/images/marker-shadow.png create mode 100644 backend/static/vendor/leaflet.css create mode 100644 backend/static/vendor/leaflet.js create mode 100644 backend/templates/_dash_bar.html create mode 100644 backend/templates/_job_table.html create mode 100644 backend/templates/_override_modal.html create mode 100644 backend/templates/_sim_bar.html create mode 100644 backend/templates/_tech_list.html create mode 100644 backend/templates/_timeline.html create mode 100644 backend/templates/_win_filter.html create mode 100644 backend/templates/_win_job_detail.html create mode 100644 backend/templates/_win_personnel.html create mode 100644 backend/templates/_win_search.html create mode 100644 backend/templates/_win_settings.html create mode 100644 backend/templates/_win_tech_detail.html create mode 100644 backend/templates/base.html create mode 100644 backend/templates/dashboard.html create mode 100644 backend/templates/map_window.html create mode 100644 db/functions/00_media.sql create mode 100644 db/functions/01_helpers.sql create mode 100644 db/functions/02_clock.sql create mode 100644 db/functions/10_tech_list.sql create mode 100644 db/functions/11_dash_bar.sql create mode 100644 db/functions/12_job_table.sql create mode 100644 db/functions/13_timeline.sql create mode 100644 db/functions/14_win_personnel.sql create mode 100644 db/functions/15_win_tech_detail.sql create mode 100644 db/functions/16_win_job_detail.sql create mode 100644 db/functions/17_win_filter.sql create mode 100644 db/functions/18_win_search.sql create mode 100644 db/functions/19_map_markers.sql create mode 100644 db/functions/20_assign.sql create mode 100644 db/functions/21_sim.sql create mode 100644 db/postgrest/00_bootstrap.sql create mode 100644 db/schema/schema.sql delete mode 100644 deploy/nginx.conf delete mode 100644 frontend/eslint.config.js delete mode 100644 frontend/index.html delete mode 100644 frontend/package-lock.json delete mode 100644 frontend/package.json delete mode 100644 frontend/public/vite.svg delete mode 100644 frontend/src.bak/App.css delete mode 100644 frontend/src.bak/App.jsx delete mode 100644 frontend/src.bak/api/client.js delete mode 100644 frontend/src.bak/assets/react.svg delete mode 100644 frontend/src.bak/components/Dashboard.jsx delete mode 100644 frontend/src.bak/components/JobList.jsx delete mode 100644 frontend/src.bak/components/Map.jsx delete mode 100644 frontend/src.bak/index.css delete mode 100644 frontend/src.bak/main.jsx delete mode 100644 frontend/src/App.jsx delete mode 100644 frontend/src/api/client.js delete mode 100644 frontend/src/assets/react.svg delete mode 100644 frontend/src/components/CalendarPicker.jsx delete mode 100644 frontend/src/components/ContextMenu.jsx delete mode 100644 frontend/src/components/Dashboard.jsx delete mode 100644 frontend/src/components/FilterWindow.jsx delete mode 100644 frontend/src/components/FloatingWindow.jsx delete mode 100644 frontend/src/components/JobDetailPanel.jsx delete mode 100644 frontend/src/components/JobGrid.jsx delete mode 100644 frontend/src/components/JobSearchWindow.jsx delete mode 100644 frontend/src/components/Map.jsx delete mode 100644 frontend/src/components/MapWindow.jsx delete mode 100644 frontend/src/components/PersonnelWindow.jsx delete mode 100644 frontend/src/components/SimBar.jsx delete mode 100644 frontend/src/components/TechGrid.jsx delete mode 100644 frontend/src/components/TechTimeline.jsx delete mode 100644 frontend/src/components/Toast.jsx delete mode 100644 frontend/src/hooks/useSimEvents.js delete mode 100644 frontend/src/main.jsx delete mode 100644 frontend/vite.config.js diff --git a/.dockerignore b/.dockerignore index 3543491..6c32308 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,12 +10,8 @@ __pycache__ .coverage htmlcov -# Frontend build artifacts and deps — rebuilt inside image -frontend/node_modules -frontend/dist -frontend/.vite -frontend/src.bak - +# Archived React frontend — never built +archive node_modules # Editors / OS diff --git a/.gitignore b/.gitignore index 8ed34c2..53a522b 100644 --- a/.gitignore +++ b/.gitignore @@ -81,3 +81,6 @@ analytics/.Rhistory # Backups backup/ +# Archived old React frontend (Path A) — kept on disk for reference, never built +archive/ + diff --git a/Dockerfile b/Dockerfile index 834bf57..a91bf2b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,6 @@ -# Multi-stage build: frontend (node) + backend (python) in one image. -# FastAPI serves the built SPA via StaticFiles. No separate nginx needed. +# Single-stage python runtime. FastAPI serves the HTMX UI via Jinja2 +# templates + StaticFiles (no node, no npm, no SPA bundle). -# ── Stage 1: build frontend ────────────────────────────────────────── -FROM node:20-alpine AS frontend -WORKDIR /app/frontend - -COPY frontend/package*.json ./ -RUN npm ci - -COPY frontend/ ./ -RUN npm run build - -# ── Stage 2: python runtime ────────────────────────────────────────── FROM python:3.11-slim WORKDIR /app @@ -31,9 +20,6 @@ COPY backend/ ./backend/ COPY alembic/ ./alembic/ COPY alembic.ini ./ -# Built SPA from stage 1 -COPY --from=frontend /app/frontend/dist ./frontend/dist - ENV PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 diff --git a/README.md b/README.md index 6946950..376e262 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ +> ⚠️ **WIP — full-Postgres rewrite branch (Path C).** This branch re-implements FieldOpt as *Postgres-as-platform*: PL/pgSQL functions serve the HTMX fragments and JSON via PostgREST, replacing the FastAPI backend. **Ported & verified:** the render surface (all fragments + map JSON), the assign/unassign write path, and the sim clock + controls (19 endpoints, matched against the FastAPI reference). **In progress:** the sim engine (auto-route + dispatch loop), the `/pg/` browser shell, and packaging (Docker + raw flavors). The docs below still describe the current FastAPI (Path B) app; they'll be rewritten when the rewrite lands. + ## Overview -FieldOpt - v0.0.8
+FieldOpt - v0.0.9
An open-source field service management system. FieldOpt is an enterprise-grade dispatch console designed for dispatchers and field service companies to efficiently assign, route, and manage service jobs across a workforce of field technicians. @@ -45,61 +47,30 @@ An open-source field service management system. FieldOpt is an enterprise-grade ### Requirements -- Python 3.11+ -- pip and npm -- Docker or PostgreSQL 15+ +- Docker + Docker Compose ### Run -#### Backend - ```bash -cd fieldopt -pip install -r requirements.txt - -# Start PostgreSQL -docker compose up -d postgres - -# Start the API -python -m uvicorn backend.api.main:app --reload +make up # regular (no demo, sim endpoints 404) +make demo # IS_DEMO=true, simulation engine active +make down # stop containers (DB volume kept) +make clean # stop + wipe DB volume (fresh seed next run) +make logs # tail app logs ``` -#### Frontend - -```bash -cd fieldopt/frontend -npm install -npm run dev -``` +Single-container build (multi-stage `Dockerfile`: node → python). Compose runs `postgres` + `app` on port 8080. #### Access | Service | URL | |---------|-----| -| Frontend | http://localhost:5173 | -| API | http://localhost:8000 | -| Swagger Docs | http://localhost:8000/docs | -| ReDoc | http://localhost:8000/redoc | - -#### Seed & Reset +| Dispatch Console | http://localhost:8080/ | +| API | http://localhost:8080/api/v1 | +| Swagger Docs | http://localhost:8080/docs | +| ReDoc | http://localhost:8080/redoc | -```bash -# Seed the database with sample data -python -m backend.database.seeds.seed_data - -# Reset database (drop all tables + reseed) -python -m backend.database.reset_db - -# Reset database (empty, no seed data) -python -m backend.database.reset_db --empty -``` - -#### Environment - -```bash -cp .env.example .env -# Edit .env — defaults work for development -``` +`IS_DEMO=true` (set by `make demo`) enables the simulation engine and a daily 04:00 UTC reseed. Without it, `/simulation/*` returns 404 and the SimBar stays hidden. ## Routing ### Routing Modes @@ -172,7 +143,22 @@ If any check fails, the dispatcher receives a warning with details but can overr ## Change Log -### 0.0.8 (Latest) +### 0.0.9 (Latest) +HTMX is now the only frontend. React archived. Tighter chrome + slot-aware sim. +- **HTMX at root** — server-rendered Jinja2 dashboard mounted at `/`. All `/htmx/*` paths gone; fragments live under root. Vendored htmx / idiomorph / leaflet (no npm). +- **React archived** — old SPA moved to `archive/frontend/` (gitignored, never built, never served). Dockerfile collapsed to single-stage python. Smaller image, no Vite/npm in build, no t3.micro OOM risk. +- **Pre-route on demo start** — `Start Demo` reseeds and immediately auto-routes every today's job before the dispatch loop kicks off. Demo plays a fully-routed day. +- **Sequential, slot-aware dispatch** — loop dispatches one job per tech per tick, ordered by `time_slot_start`. Tech idles at base (AVAILABLE) until `depart = slot_start − travel`, then EN_ROUTE → ON_JOB → AVAILABLE. Arrivals land inside the customer window; days fill the full shift instead of finishing by 10am. +- **Override-modal flash fixed** — manual fetch handler parses the response and moves `#modal-host` + toasts into place properly (htmx OOB swaps don't fire on raw fetches). +- **Column resize** — vanilla JS injects `` per `.grid`, adds 6px resize handles on each `' + || '' + || '' + || '' + || '' + || '' + || '' + || '' as row_html + from technicians t + cross join lateral ( + select + count(*) filter (where j.status::text in ('ASSIGNED', 'IN_PROGRESS')) as assigned, + count(*) filter (where j.status::text = 'COMPLETED') as completed + from assignments a + join jobs j on j.id = a.job_id + where a.technician_id = t.id + ) cnt + ) rows; +$$; + +grant execute on function api.tech_list() to web_anon; diff --git a/db/functions/11_dash_bar.sql b/db/functions/11_dash_bar.sql new file mode 100644 index 0000000..f626092 --- /dev/null +++ b/db/functions/11_dash_bar.sql @@ -0,0 +1,59 @@ +-- Port of backend/templates/_dash_bar.html — the polled counts fragment. +-- Mirrors FastAPI /counts (_counts + counts_fragment in api/routes/htmx.py): +-- job counts are over TODAY's jobs (scheduled_date within the sim-clock day); +-- tech counts are over active techs by status. +-- DB enum columns are UPPERCASE (status='PENDING'); template labels/filters lowercase. +create or replace function api.dash_bar() +returns "text/html" language sql stable as $$ + with day as ( + select date_trunc('day', api.sim_now()) as start + ), + jc as ( + select + count(*) filter (where j.status = 'PENDING') as pending, + count(*) filter (where j.status = 'ASSIGNED') as assigned, + count(*) filter (where j.status = 'IN_PROGRESS') as in_progress, + count(*) filter (where j.status = 'COMPLETED') as completed, + count(*) filter (where j.status = 'ON_HOLD') as on_hold, + count(*) filter (where j.status = 'CANCELLED') as cancelled + from jobs j, day + where j.scheduled_date >= day.start + and j.scheduled_date < day.start + interval '1 day' + ), + tc as ( + select + count(*) filter (where t.status = 'AVAILABLE') as tech_available, + count(*) filter (where t.status in ('OFF_DUTY', 'ON_BREAK')) as tech_off + from technicians t + where t.is_active + ) + select + '
' + || '
' || jc.pending || '
Unassigned
' + || '
' + || '
' + || '
' || jc.assigned || '
Assigned
' + || '
' + || '
' + || '
' || jc.in_progress || '
In Progress
' + || '
' + || '
' + || '
' || jc.completed || '
Completed
' + || '
' + || '
' + || '
' || jc.on_hold || '
On Hold
' + || '
' + || '
' + || '
' || jc.cancelled || '
Failed
' + || '
' + || '
' + || '
' + || '
' || tc.tech_available || '
Techs Active
' + || '
' + || '
' + || '
' || tc.tech_off || '
Off Duty
' + || '
' + from jc, tc; +$$; + +grant execute on function api.dash_bar() to web_anon; diff --git a/db/functions/12_job_table.sql b/db/functions/12_job_table.sql new file mode 100644 index 0000000..43539cd --- /dev/null +++ b/db/functions/12_job_table.sql @@ -0,0 +1,85 @@ +-- Port of backend/templates/_job_table.html — the polled jobs grid. +-- Mirrors FastAPI /jobs (jobs_fragment + _todays_jobs in api/routes/htmx.py): +-- today's jobs (sim-clock day) with optional status/job_type/tech_id/route filters, +-- ordered by time_slot_start (nulls first), id. One assignment per job (job_id UNIQUE). +-- `overdue` row flag: slot_end passed vs sim now-minutes, unless completed/cancelled. +-- DB enum columns are UPPERCASE; template emits lowercase .value for classes/attrs and +-- upper+space for the status pill text. Jinja autoescape is ON -> html_escape mirrors it. +create or replace function api.job_table( + p_status text default '', + p_job_type text default '', + p_tech_id text default '', + p_route text default '' +) +returns "text/html" language sql stable as $$ + with clk as ( + select api.sim_now() as t + ), + d as ( + select + date_trunc('day', t) as day_start, + extract(hour from (t at time zone 'UTC')) * 60 + + extract(minute from (t at time zone 'UTC')) as now_min + from clk + ), + rows as ( + select + j.time_slot_start as ord_slot, + j.id as ord_id, + '' + || '' + || '' + || '' + || '' + || '' + || '' + || '' + || '' + || '' + || '' as row_html + from jobs j + cross join d + left join assignments a on a.job_id = j.id + left join technicians t on t.id = a.technician_id + where j.scheduled_date >= d.day_start + and j.scheduled_date < d.day_start + interval '1 day' + and (p_status = '' or j.status::text = upper(p_status)) + and (p_job_type = '' or j.job_type::text = upper(p_job_type)) + and (p_route = '' or j.route_criteria = p_route) + and ( + p_tech_id = '' + or (p_tech_id = 'unassigned' and a.id is null) + or (p_tech_id ~ '^[0-9]+$' and a.technician_id = p_tech_id::int) + or (p_tech_id <> 'unassigned' and p_tech_id !~ '^[0-9]+$') + ) + ) + select + '
`, persists widths to localStorage by table key. Re-attaches on every `htmx:afterSwap`. +- **CSS port** — `app.css` is a full lift of the React design tokens + components, adapted to HTMX class names. Mobile `@media (max-width: 768px)` makes floating windows + map fullscreen. +- **SVG icons** — header buttons use the React-source SVGs (filter, personnel, search, settings, timeline, map, refresh, auto-route bolt). Bigger touch targets (34×34 icon, 30px min-height regular). +- **Thicker splitters** — 9px with centered grip bar, accent on hover/drag. Jobs↔Timeline splitter hidden when the timeline pane is collapsed. +- **Grid horizontal scroll** — `.grid { min-width: 720px }` so narrow panes get a horizontal scrollbar instead of column-shrunk-to-nothing. +- **Hidden filter selects** — Jobs pane dropped its inline status/type/tech/route bar; dash-cell clicks drive a single hidden `[name='status']` input, Filter window covers multi-select. +- **Cache-bust** — `app.css?v=` query param so CSS edits land without manual hard-refresh after rebuild. + +### 0.0.8 ML routing + investor-ready demo day - **Single-container Docker** — multi-stage build (node → python). FastAPI serves the SPA via `StaticFiles`; no separate nginx. One command brings up Postgres + app: `make up` (or `make demo` to enable the simulation). Browse `http://localhost:8080`. - **MLStrategy** — default dispatch. Scores `(job, tech)` by `travel_time + what_if_duration(job, tech)` using hidden `speed_factor` / `skill_bonuses` modifiers. Two-pass routing: prefers techs who can arrive within the customer window, falls back to best-available so jobs never sit unrouted. `HeuristicStrategy` kept for A/B. diff --git a/backend/.env.example b/backend/.env.example deleted file mode 100644 index cdaa961..0000000 --- a/backend/.env.example +++ /dev/null @@ -1,28 +0,0 @@ -# FieldOpt Configuration - -# Application -APP_NAME=FieldOpt -VERSION=0.0.2 -DEBUG=True - -# Database -# SQLite (default for development) -DATABASE_URL=sqlite:///./fieldopt.db -# PostgreSQL (uncomment for production) -# DATABASE_URL=postgresql://user:password@localhost/fieldopt -DATABASE_ECHO=False - -# API -API_V1_PREFIX=/api/v1 -CORS_ORIGINS=["http://localhost:5173", "http://localhost:3000"] - -# Routing Configuration -MAX_JOBS_PER_TECH=12 -MAX_DRIVE_TIME_MINUTES=45 -ROUTE_OPTIMIZATION_MODE=standard - -# Time Slot Configuration -DEFAULT_JOB_DURATION_MINUTES=60 -LUNCH_BREAK_MINUTES=30 -SHIFT_START_HOUR=8 -SHIFT_END_HOUR=17 diff --git a/backend/api/main.py b/backend/api/main.py index 242bd45..10bf2b6 100644 --- a/backend/api/main.py +++ b/backend/api/main.py @@ -9,12 +9,11 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from backend.config import get_settings from backend.database.connection import init_db -from backend.api.routes import technicians, jobs, assignments, routing, simulation +from backend.api.routes import technicians, jobs, assignments, routing, simulation, htmx logger = logging.getLogger(__name__) settings = get_settings() @@ -42,8 +41,7 @@ async def _daily_reseed_loop() -> None: except Exception: logger.exception("Daily reseed failed") -FRONTEND_DIST = Path(__file__).resolve().parents[2] / "frontend" / "dist" -SERVE_FRONTEND = FRONTEND_DIST.is_dir() and (FRONTEND_DIST / "index.html").is_file() +# React SPA (Path A) is archived. HTMX UI is the only frontend. @asynccontextmanager @@ -84,17 +82,6 @@ async def lifespan(app: FastAPI): ) -if not SERVE_FRONTEND: - @app.get("/") - async def root(): - return { - "message": f"Welcome to {settings.APP_NAME} API", - "version": settings.APP_VERSION, - "docs": "/docs", - "status": "operational", - } - - @app.get("/health") async def health_check(): return { @@ -134,19 +121,13 @@ async def health_check(): tags=["Simulation"], ) +# HTMX UI — primary frontend, mounted at /. All dashboard fragments + sim +# controls live under root. /api/v1/* routers above win on prefix. +app.include_router(htmx.router, prefix="", tags=["HTMX"], include_in_schema=False) -# SPA fallback — must be registered AFTER all /api/* routers so they win on prefix. -# StaticFiles with html=True serves index.html at "/" and assets by path. -# A catch-all FileResponse handles client-side routes (e.g. /jobs/123 deep-links). -if SERVE_FRONTEND: - app.mount("/assets", StaticFiles(directory=str(FRONTEND_DIST / "assets")), name="assets") - - @app.get("/{full_path:path}", include_in_schema=False) - async def spa_fallback(full_path: str): - candidate = FRONTEND_DIST / full_path - if full_path and candidate.is_file(): - return FileResponse(candidate) - return FileResponse(FRONTEND_DIST / "index.html") +_STATIC_DIR = Path(__file__).resolve().parents[1] / "static" +if _STATIC_DIR.is_dir(): + app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static") if __name__ == "__main__": diff --git a/backend/api/routes/htmx.py b/backend/api/routes/htmx.py new file mode 100644 index 0000000..65c5ea1 --- /dev/null +++ b/backend/api/routes/htmx.py @@ -0,0 +1,652 @@ +""" +HTMX/SSE routes — server-rendered dashboard. Primary frontend. + +Mounted at / (root). /api/v1/* JSON routers win on prefix. +No Node, no npm. Jinja2 + vanilla htmx (vendored single JS file). +""" +from __future__ import annotations + +import asyncio +import json +from datetime import datetime, time, timedelta, timezone +from pathlib import Path + +from fastapi import APIRouter, Depends, Form, HTTPException, Request +from fastapi.responses import HTMLResponse, Response, StreamingResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.config import get_settings +from backend.database.connection import get_db +from backend.database.models import Assignment, Job, JobStatus, Technician +from backend.simulation.broadcaster import manager +from backend.simulation.clock import clock, ClockMode +from backend.simulation.loop import dispatch_loop +from backend.simulation.strategy import MLStrategy + +router = APIRouter() +settings = get_settings() + +TEMPLATES_DIR = Path(__file__).resolve().parents[2] / "templates" +STATIC_DIR = Path(__file__).resolve().parents[2] / "static" +templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) + + +def _static_v() -> str: + """mtime of app.css → cache-bust query string. Edits + rebuilds bump it.""" + try: + return str(int((STATIC_DIR / "app.css").stat().st_mtime)) + except OSError: + return settings.APP_VERSION + + +templates.env.globals["app_version"] = settings.APP_VERSION +templates.env.globals["static_v"] = _static_v() + + +def _sim_state() -> dict: + """Snapshot for the SimBar template.""" + return { + "is_demo": settings.IS_DEMO, + "mode": clock.mode.value, + "speed": clock.speed, + "is_paused": clock.is_paused, + "loop_running": dispatch_loop.is_running, + "running": clock.mode == ClockMode.SIMULATED and not clock.is_paused, + "hms": clock.now().strftime("%H:%M:%S"), + } + + +def _require_demo(): + if not settings.IS_DEMO: + raise HTTPException(status_code=404, detail="Not found") + + +async def _todays_jobs( + db: AsyncSession, + status: str | None = None, + job_type: str | None = None, + tech_id: str | None = None, + route: str | None = None, +) -> list[Job]: + """Today's jobs with optional filters. Tech filter accepts 'unassigned' or a tech id.""" + from backend.database.models import Assignment, JobType + now = clock.now() + day_start = datetime.combine(now.date(), time.min, tzinfo=timezone.utc) + day_end = day_start + timedelta(days=1) + q = select(Job).where(Job.scheduled_date >= day_start, Job.scheduled_date < day_end) + if status: + q = q.where(Job.status == JobStatus(status)) + if job_type: + q = q.where(Job.job_type == JobType(job_type)) + if route: + q = q.where(Job.route_criteria == route) + if tech_id == "unassigned": + q = q.where(~Job.id.in_(select(Assignment.job_id))) + elif tech_id: + try: + tid = int(tech_id) + q = q.where(Job.id.in_(select(Assignment.job_id).where(Assignment.technician_id == tid))) + except ValueError: + pass + q = q.order_by(Job.time_slot_start.nullsfirst(), Job.id) + return list((await db.execute(q)).scalars().all()) + + +async def _counts(db: AsyncSession) -> dict: + from backend.database.models import TechnicianStatus + jobs = await _todays_jobs(db) + techs = (await db.execute(select(Technician).where(Technician.is_active == True))).scalars().all() + c = {s.value: 0 for s in JobStatus} + for j in jobs: + c[j.status.value] = c.get(j.status.value, 0) + 1 + tech_avail = sum(1 for t in techs if t.status == TechnicianStatus.AVAILABLE) + tech_busy = sum(1 for t in techs if t.status in (TechnicianStatus.EN_ROUTE, TechnicianStatus.ON_JOB)) + tech_off = sum(1 for t in techs if t.status in (TechnicianStatus.OFF_DUTY, TechnicianStatus.ON_BREAK)) + c["tech_available"] = tech_avail + c["tech_busy"] = tech_busy + c["tech_off"] = tech_off + return c + + +@router.get("/counts", response_class=HTMLResponse) +async def counts_fragment(request: Request, db: AsyncSession = Depends(get_db)): + return templates.TemplateResponse( + "_dash_bar.html", + {"request": request, "counts": await _counts(db)}, + ) + + +@router.get("/", response_class=HTMLResponse) +async def dashboard(request: Request, db: AsyncSession = Depends(get_db)): + from backend.database.models import JobType + techs = (await db.execute(select(Technician).order_by(Technician.name))).scalars().all() + jobs = await _todays_jobs(db) + # Routes seen on today's jobs + routes = sorted({j.route_criteria for j in jobs if j.route_criteria}) + return templates.TemplateResponse( + "dashboard.html", + { + "request": request, + "techs": techs, + "jobs": jobs, + "sim": _sim_state(), + "now_minutes": clock.now().hour * 60 + clock.now().minute, + "statuses": [s.value for s in JobStatus], + "job_types": [t.value for t in JobType], + "routes": routes, + "selected_status": "", + "counts": await _counts(db), + }, + ) + + +@router.get("/techs", response_class=HTMLResponse) +async def techs_fragment(request: Request, db: AsyncSession = Depends(get_db)): + techs = (await db.execute(select(Technician).order_by(Technician.name))).scalars().all() + return templates.TemplateResponse( + "_tech_list.html", + {"request": request, "techs": techs}, + ) + + +@router.get("/jobs", response_class=HTMLResponse) +async def jobs_fragment( + request: Request, + status: str = "", + job_type: str = "", + tech_id: str = "", + route: str = "", + db: AsyncSession = Depends(get_db), +): + jobs = await _todays_jobs(db, status=status or None, job_type=job_type or None, tech_id=tech_id or None, route=route or None) + return templates.TemplateResponse( + "_job_table.html", + { + "request": request, + "jobs": jobs, + "now_minutes": clock.now().hour * 60 + clock.now().minute, + "selected_status": status, + }, + ) + + +# Timeline window (virtual hours, UTC). Matches sim shift window. +TIMELINE_START_HOUR = 8 +TIMELINE_END_HOUR = 17 + + +async def _timeline_rows(db: AsyncSession) -> list[dict]: + """Build [{tech, blocks: [...]}] for the timeline SVG. + + Blocks are ordered by ETA; positions are computed in the template using + `offset_min` (minutes from TIMELINE_START_HOUR) and `width_min`. + """ + now = clock.now() + day_start = datetime.combine(now.date(), time.min, tzinfo=timezone.utc) + day_end = day_start + timedelta(days=1) + + techs = ( + await db.execute( + select(Technician) + .where(Technician.is_active == True) + .order_by(Technician.name) + ) + ).scalars().all() + + assigns = ( + await db.execute( + select(Assignment) + .join(Job, Assignment.job_id == Job.id) + .where(Job.scheduled_date >= day_start, Job.scheduled_date < day_end) + ) + ).scalars().all() + + by_tech: dict[int, list] = {} + for a in assigns: + eta = a.estimated_arrival or a.job.scheduled_date + if eta is None: + continue + # Convert ETA to minutes from timeline start of THIS virtual day + eta_min = (eta - day_start).total_seconds() / 60.0 + offset = eta_min - TIMELINE_START_HOUR * 60 + duration = a.actual_duration_minutes or a.job.estimated_duration or 60 + by_tech.setdefault(a.technician_id, []).append( + { + "job_id": a.job_id, + "job_number": a.job.job_number or a.job.id, + "customer": a.job.customer_name, + "status": a.job.status.value, + "offset_min": max(0.0, offset), + "width_min": max(15.0, float(duration)), # minimum 15min for visibility + "eta_hms": eta.strftime("%H:%M"), + } + ) + + rows = [] + for t in techs: + blocks = sorted(by_tech.get(t.id, []), key=lambda b: b["offset_min"]) + rows.append({"tech": t, "blocks": blocks}) + return rows + + +# ── Assignment (drag-drop + context menu) ─────────────────────────────────── + +def _candoo_issues(job: Job, tech: Technician) -> list[dict]: + """Return list of {label, pass} for skill + route checks. Mirrors React doAssignWithCheck.""" + issues = [] + missing = [s for s in (job.required_skills or []) if s not in (tech.skills or [])] + issues.append({ + "label": f"Skill{(' (missing: ' + ', '.join(missing) + ')') if missing else ''}", + "pass": len(missing) == 0, + }) + # route check — Technician has skills but route via separate join in real model; + # falling back to permissive pass if no route_criteria on job. + route_ok = not job.route_criteria # if no criteria, always OK + # NOTE: full route check requires tech.assigned_routes which lives in a relation. + # Slice 6 keeps it simple — skill check is the gating one; route shown advisory. + issues.append({"label": f"Route{'' if route_ok else ' (' + job.route_criteria + ')'}", "pass": route_ok}) + return issues + + +def _demo_lock_block() -> HTMLResponse | None: + """Return a toast response if sim is running (demo playback lock).""" + if clock.mode == ClockMode.SIMULATED and not clock.is_paused: + return _toast_response("Stop the demo to assign jobs", "warning") + return None + + +@router.post("/assign", response_class=HTMLResponse) +async def assign( + request: Request, + job_id: int = Form(...), + tech_id: int = Form(...), + override: int = Form(0), + db: AsyncSession = Depends(get_db), +): + """Assign a job to a tech. If CanDo issues exist and !override, return modal HTML.""" + blocked = _demo_lock_block() + if blocked: + return blocked + from backend.logic import assignments as assign_logic + + job = (await db.execute(select(Job).where(Job.id == job_id))).scalar_one_or_none() + tech = (await db.execute(select(Technician).where(Technician.id == tech_id))).scalar_one_or_none() + if not job or not tech: + raise HTTPException(status_code=404, detail="Job or tech not found") + + if not override: + issues = _candoo_issues(job, tech) + if any(not i["pass"] for i in issues): + return templates.TemplateResponse( + "_override_modal.html", + {"request": request, "job": job, "tech": tech, "issues": issues}, + ) + + # Unassign first if reassigning + if job.assignment and job.assignment.technician_id != tech_id: + await assign_logic.unassign_job(db, job_id) + elif job.assignment and job.assignment.technician_id == tech_id: + # already on this tech, no-op + return _toast_response(f"Already on {tech.name}", "warning") + + try: + await assign_logic.create_assignment(db, job_id, tech_id, now=clock.now()) + except ValueError as e: + return _toast_response(str(e), "error") + + return _toast_response(f"Job #{job.job_number or job_id} → {tech.name}", "success") + + +def _toast_response(msg: str, kind: str = "success") -> HTMLResponse: + """Return an out-of-band toast + trigger refresh of dependent panels.""" + html = ( + f'
{msg}
' + ) + resp = HTMLResponse(html) + # Tell htmx to re-fetch panels on the page. + resp.headers["HX-Trigger"] = "refreshAll" + return resp + + +@router.post("/unassign", response_class=HTMLResponse) +async def unassign(job_id: int = Form(...), db: AsyncSession = Depends(get_db)): + blocked = _demo_lock_block() + if blocked: + return blocked + from backend.logic import assignments as assign_logic + ok = await assign_logic.unassign_job(db, job_id) + if not ok: + return _toast_response("Job not assigned", "warning") + return _toast_response(f"Job #{job_id} unassigned", "success") + + +@router.get("/map", response_class=HTMLResponse) +async def map_window(request: Request): + """Standalone map page — opened in a popup window from the main dashboard.""" + return templates.TemplateResponse("map_window.html", {"request": request}) + + +# ── Floating window contents ──────────────────────────────────────────────── + +@router.get("/window/personnel", response_class=HTMLResponse) +async def personnel_window(request: Request, db: AsyncSession = Depends(get_db)): + from backend.database.models import TechnicianStatus + techs = (await db.execute(select(Technician).order_by(Technician.name))).scalars().all() + by_status = {s.value: [t for t in techs if t.status == s] for s in TechnicianStatus} + return templates.TemplateResponse( + "_win_personnel.html", + {"request": request, "techs": techs, "by_status": by_status}, + ) + + +@router.get("/window/search", response_class=HTMLResponse) +async def search_window( + request: Request, + q: str = "", + date_from: str = "", + date_to: str = "", + job_id: str = "", + tech_id: str = "", + customer: str = "", + status: str = "", + job_type: str = "", + route: str = "", + db: AsyncSession = Depends(get_db), +): + """Job search with full criteria. Empty form = no results yet.""" + from sqlalchemy import or_ + from backend.database.models import Assignment, JobType + any_criteria = any([q, date_from, date_to, job_id, tech_id, customer, status, job_type, route]) + jobs = [] + if any_criteria: + query = select(Job) + if q: + query = query.where(or_( + Job.job_number.ilike(f"%{q}%"), + Job.customer_name.ilike(f"%{q}%"), + Job.service_address.ilike(f"%{q}%"), + )) + if date_from: + query = query.where(Job.scheduled_date >= datetime.fromisoformat(date_from).replace(tzinfo=timezone.utc)) + if date_to: + query = query.where(Job.scheduled_date < datetime.fromisoformat(date_to).replace(tzinfo=timezone.utc) + timedelta(days=1)) + if job_id: + try: query = query.where(Job.id == int(job_id)) + except ValueError: pass + if customer: + query = query.where(Job.customer_name.ilike(f"%{customer}%")) + if status: + query = query.where(Job.status == JobStatus(status)) + if job_type: + query = query.where(Job.job_type == JobType(job_type)) + if route: + query = query.where(Job.route_criteria == route) + if tech_id: + try: query = query.where(Job.id.in_(select(Assignment.job_id).where(Assignment.technician_id == int(tech_id)))) + except ValueError: pass + query = query.order_by(Job.scheduled_date.desc().nullslast(), Job.id.desc()).limit(200) + jobs = list((await db.execute(query)).scalars().all()) + + techs = (await db.execute(select(Technician).order_by(Technician.name))).scalars().all() + return templates.TemplateResponse( + "_win_search.html", + { + "request": request, "jobs": jobs, + "q": q, "date_from": date_from, "date_to": date_to, + "job_id": job_id, "tech_id": tech_id, "customer": customer, + "status": status, "job_type": job_type, "route": route, + "techs": techs, + "statuses": [s.value for s in JobStatus], + "job_types": [t.value for t in JobType], + "any_criteria": any_criteria, + }, + ) + + +@router.get("/qualified/{job_id}") +async def qualified_techs(job_id: int, db: AsyncSession = Depends(get_db)): + """Return list of techs qualified for a job, partitioned by skill match. + + Used by the job context menu's Assign-To submenu. + """ + job = (await db.execute(select(Job).where(Job.id == job_id))).scalar_one_or_none() + if not job: + return {"qualified": [], "missing_skills": []} + techs = (await db.execute( + select(Technician).where(Technician.is_active == True).order_by(Technician.name) + )).scalars().all() + required = set(job.required_skills or []) + qualified = [] + unqualified = [] + for t in techs: + if t.status.value == "off_duty": + continue + tech_skills = set(t.skills or []) + missing = required - tech_skills + entry = {"id": t.id, "name": t.name, "status": t.status.value, "missing": list(missing)} + (qualified if not missing else unqualified).append(entry) + return {"qualified": qualified, "unqualified": unqualified, "required": list(required)} + + +@router.get("/window/job/{job_id}", response_class=HTMLResponse) +async def job_detail_window(request: Request, job_id: int, db: AsyncSession = Depends(get_db)): + job = (await db.execute(select(Job).where(Job.id == job_id))).scalar_one_or_none() + if not job: + return HTMLResponse('
Job not found.
', status_code=404) + return templates.TemplateResponse( + "_win_job_detail.html", + {"request": request, "job": job}, + ) + + +@router.get("/window/tech/{tech_id}", response_class=HTMLResponse) +async def tech_detail_window(request: Request, tech_id: int, db: AsyncSession = Depends(get_db)): + tech = (await db.execute(select(Technician).where(Technician.id == tech_id))).scalar_one_or_none() + if not tech: + return HTMLResponse('
Tech not found.
', status_code=404) + # Today's assignments for context + now = clock.now() + day_start = datetime.combine(now.date(), time.min, tzinfo=timezone.utc) + day_end = day_start + timedelta(days=1) + from backend.database.models import Assignment + assignments = list((await db.execute( + select(Assignment).join(Job, Assignment.job_id == Job.id) + .where(Assignment.technician_id == tech_id, Job.scheduled_date >= day_start, Job.scheduled_date < day_end) + .order_by(Assignment.estimated_arrival.nullsfirst()) + )).scalars().all()) + return templates.TemplateResponse( + "_win_tech_detail.html", + {"request": request, "tech": tech, "assignments": assignments}, + ) + + +@router.get("/window/settings", response_class=HTMLResponse) +async def settings_window(request: Request): + return templates.TemplateResponse( + "_win_settings.html", + {"request": request, "sim": _sim_state()}, + ) + + +@router.get("/window/filter", response_class=HTMLResponse) +async def filter_window(request: Request, db: AsyncSession = Depends(get_db)): + from backend.database.models import JobType + techs = (await db.execute(select(Technician).order_by(Technician.name))).scalars().all() + jobs = await _todays_jobs(db) + routes = sorted({j.route_criteria for j in jobs if j.route_criteria}) + return templates.TemplateResponse( + "_win_filter.html", + { + "request": request, + "techs": techs, + "routes": routes, + "statuses": [s.value for s in JobStatus], + "job_types": [t.value for t in JobType], + }, + ) + + +@router.get("/map/markers") +async def map_markers(db: AsyncSession = Depends(get_db)): + """JSON markers + per-tech route polylines for the Leaflet map.""" + from backend.database.models import Assignment + jobs = await _todays_jobs(db) + techs = ( + await db.execute(select(Technician).where(Technician.is_active == True)) + ).scalars().all() + + # Build routes: per tech, [home → job1 → job2 → ...] ordered by ETA + now = clock.now() + day_start = datetime.combine(now.date(), time.min, tzinfo=timezone.utc) + day_end = day_start + timedelta(days=1) + all_assigns = list((await db.execute( + select(Assignment).join(Job, Assignment.job_id == Job.id) + .where(Job.scheduled_date >= day_start, Job.scheduled_date < day_end) + )).scalars().all()) + + by_tech: dict[int, list] = {} + for a in all_assigns: + by_tech.setdefault(a.technician_id, []).append(a) + for tid in by_tech: + by_tech[tid].sort(key=lambda a: (a.estimated_arrival or a.job.scheduled_date or datetime.max.replace(tzinfo=timezone.utc))) + + routes = [] + for t in techs: + assigns = by_tech.get(t.id, []) + if not assigns: + continue + path = [[t.current_latitude or t.home_latitude, t.current_longitude or t.home_longitude]] + path.extend([[a.job.latitude, a.job.longitude] for a in assigns]) + # Count done so the rendered route grays the completed legs + done_count = sum(1 for a in assigns if a.job.status.value in ("completed", "cancelled")) + routes.append({"tech_id": t.id, "tech_name": t.name, "path": path, "done_count": done_count}) + + return { + "jobs": [ + {"id": j.id, "job_number": j.job_number or j.id, "customer": j.customer_name, + "address": j.service_address, "status": j.status.value, + "lat": j.latitude, "lng": j.longitude} + for j in jobs + ], + "techs": [ + {"id": t.id, "name": t.name, "status": t.status.value, + "lat": t.current_latitude or t.home_latitude, + "lng": t.current_longitude or t.home_longitude} + for t in techs + if (t.current_latitude or t.home_latitude) is not None + ], + "routes": routes, + } + + +@router.get("/timeline", response_class=HTMLResponse) +async def timeline_fragment( + request: Request, + selected: str = "", + db: AsyncSession = Depends(get_db), +): + rows = await _timeline_rows(db) + if selected: + try: + sel_ids = {int(x) for x in selected.split(",") if x.strip()} + rows = [r for r in rows if r["tech"].id in sel_ids] + except ValueError: + pass + now = clock.now() + now_offset = (now.hour - TIMELINE_START_HOUR) * 60 + now.minute + return templates.TemplateResponse( + "_timeline.html", + { + "request": request, + "rows": rows, + "now_offset_min": max(0, now_offset), + "start_hour": TIMELINE_START_HOUR, + "end_hour": TIMELINE_END_HOUR, + "total_min": (TIMELINE_END_HOUR - TIMELINE_START_HOUR) * 60, + "hours": list(range(TIMELINE_START_HOUR, TIMELINE_END_HOUR + 1)), + }, + ) + + +# ── Sim bar + controls ────────────────────────────────────────────────────── + +@router.get("/sim/bar", response_class=HTMLResponse) +async def sim_bar(request: Request): + return templates.TemplateResponse("_sim_bar.html", {"request": request, "sim": _sim_state()}) + + +@router.post("/sim/start", response_class=HTMLResponse) +async def sim_start(request: Request, speed: float = Form(500.0)): + _require_demo() + from backend.database.connection import reset_db, AsyncSessionLocal + from backend.database.seeds.seed_data import seed_all + from backend.logic.routing.auto_router import auto_route_jobs + + dispatch_loop.stop() + await reset_db() + await seed_all() + # Pre-route all today's jobs so the demo plays a fully-routed day. + async with AsyncSessionLocal() as db: + await auto_route_jobs(db) + virtual_start = datetime.now(timezone.utc).replace(hour=8, minute=0, second=0, microsecond=0) + clock.start_simulation(virtual_start, speed=speed) + dispatch_loop.start(MLStrategy(), on_events=manager.broadcast_events) + return templates.TemplateResponse("_sim_bar.html", {"request": request, "sim": _sim_state()}) + + +@router.post("/sim/pause", response_class=HTMLResponse) +async def sim_pause(request: Request): + _require_demo() + clock.pause() + return templates.TemplateResponse("_sim_bar.html", {"request": request, "sim": _sim_state()}) + + +@router.post("/sim/resume", response_class=HTMLResponse) +async def sim_resume(request: Request): + _require_demo() + clock.resume() + return templates.TemplateResponse("_sim_bar.html", {"request": request, "sim": _sim_state()}) + + +@router.post("/sim/stop", response_class=HTMLResponse) +async def sim_stop(request: Request): + _require_demo() + dispatch_loop.stop() + clock.use_real_time() + return templates.TemplateResponse("_sim_bar.html", {"request": request, "sim": _sim_state()}) + + +@router.post("/sim/speed", response_class=HTMLResponse) +async def sim_speed(request: Request, speed: float = Form(...)): + _require_demo() + if clock.mode != ClockMode.SIMULATED: + raise HTTPException(status_code=400, detail="Simulation not running") + clock.set_speed(speed) + return templates.TemplateResponse("_sim_bar.html", {"request": request, "sim": _sim_state()}) + + +@router.get("/sim/stream") +async def sim_stream(): + """SSE stream of clock ticks. One event per wall-second.""" + + async def event_gen(): + try: + while True: + payload = { + "hms": clock.now().strftime("%H:%M:%S"), + "speed": clock.speed, + "paused": clock.is_paused, + "mode": clock.mode.value, + } + yield f"event: tick\ndata: {json.dumps(payload)}\n\n" + await asyncio.sleep(1.0) + except asyncio.CancelledError: + return + + return StreamingResponse( + event_gen(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) diff --git a/backend/api/routes/simulation.py b/backend/api/routes/simulation.py index 099e96b..b9f903f 100644 --- a/backend/api/routes/simulation.py +++ b/backend/api/routes/simulation.py @@ -54,11 +54,16 @@ class SpeedRequest(BaseModel): async def sim_start(req: StartRequest): _require_demo() # Always reset + reseed — demo starts from a clean state every time - from backend.database.connection import reset_db + from backend.database.connection import reset_db, AsyncSessionLocal from backend.database.seeds.seed_data import seed_all + from backend.logic.routing.auto_router import auto_route_jobs dispatch_loop.stop() await reset_db() await seed_all() + # Pre-route every today's job so the demo shows a fully-routed day. + # Niche-skill jobs that ML would leave PENDING get assigned upfront here. + async with AsyncSessionLocal() as db: + await auto_route_jobs(db) virtual_start = req.virtual_start or datetime.now(timezone.utc).replace( hour=8, minute=0, second=0, microsecond=0 ) diff --git a/backend/config.py b/backend/config.py index ad31b2d..d11741a 100644 --- a/backend/config.py +++ b/backend/config.py @@ -14,7 +14,7 @@ class Settings(BaseSettings): # Application APP_NAME: str = "FieldOpt" - APP_VERSION: str = "0.0.8" + APP_VERSION: str = "0.0.9" DEBUG: bool = True ENVIRONMENT: str = "development" IS_DEMO: bool = False # Enable simulation engine and /simulation/* endpoints diff --git a/backend/logic/assignments.py b/backend/logic/assignments.py index 0aef92e..1dd4505 100644 --- a/backend/logic/assignments.py +++ b/backend/logic/assignments.py @@ -58,9 +58,10 @@ async def create_assignment( ) job.status = JobStatus.ASSIGNED - # Mark tech busy so the strategy stops picking them on subsequent jobs. - # Step 2 of the sim tick flips them to ON_JOB on arrival; step 3 back to AVAILABLE on complete. - if tech.status == TechnicianStatus.AVAILABLE: + # Only flip to EN_ROUTE for *live* assigns (now=given). Pre-routing assigns + # a full day at once — tech state stays AVAILABLE so the loop can stamp + # departures one job at a time, honoring each time_slot_start. + if now is not None and tech.status == TechnicianStatus.AVAILABLE: tech.status = TechnicianStatus.EN_ROUTE db.add(assignment) diff --git a/backend/requirements.txt b/backend/requirements.txt deleted file mode 100644 index 66f6f60..0000000 --- a/backend/requirements.txt +++ /dev/null @@ -1,22 +0,0 @@ -# FieldOpt v0.0.2 Backend Requirements - -# Core Framework -fastapi==0.104.1 -uvicorn[standard]==0.24.0 -pydantic==2.5.0 -pydantic-settings==2.1.0 - -# Database -sqlalchemy==2.0.23 -alembic==1.13.0 - -# CORS -python-multipart==0.0.6 - -# Development -pytest==7.4.3 -pytest-asyncio==0.21.1 -httpx==0.25.2 - -# Optional: For PostgreSQL support -# psycopg2-binary==2.9.9 diff --git a/backend/simulation/loop.py b/backend/simulation/loop.py index 1165900..e7c25d1 100644 --- a/backend/simulation/loop.py +++ b/backend/simulation/loop.py @@ -106,16 +106,62 @@ async def _run(self) -> None: async def _tick(db: AsyncSession, now: datetime, strategy: DispatchStrategy, sim_start: datetime | None = None) -> list[DispatchEvent]: events: list[DispatchEvent] = [] - # ── 1. Stamp estimated_arrival on newly-assigned jobs ────────────────── - result = await db.execute( - select(Assignment).join(Job, Assignment.job_id == Job.id).where( + # ── 1. Stamp estimated_arrival sequentially — one job per tech per tick ── + # Pre-route assigns a whole day's worth of jobs to each tech. We don't want + # them all firing simultaneously; instead the tech works the queue earliest + # time_slot first. A tech is ready for the *next* job when: + # - status is AVAILABLE or EN_ROUTE, AND + # - none of their ASSIGNED jobs already has an arrival pending. + candidate_techs = (await db.execute( + select(Technician) + .distinct() + .join(Assignment, Assignment.technician_id == Technician.id) + .join(Job, Assignment.job_id == Job.id) + .where( + Technician.status.in_([TechnicianStatus.AVAILABLE, TechnicianStatus.EN_ROUTE]), Job.status == JobStatus.ASSIGNED, Assignment.estimated_arrival.is_(None), ) - ) - for assignment in result.scalars().all(): - travel = assignment.estimated_travel_time or 0 - assignment.estimated_arrival = now + timedelta(minutes=travel) + )).scalars().all() + for tech in candidate_techs: + # Skip if this tech already has an EN_ROUTE assignment ticking down. + pending = (await db.execute( + select(Assignment.id).join(Job, Assignment.job_id == Job.id).where( + Assignment.technician_id == tech.id, + Job.status == JobStatus.ASSIGNED, + Assignment.estimated_arrival.is_not(None), + ).limit(1) + )).scalar_one_or_none() + if pending is not None: + continue + next_a = (await db.execute( + select(Assignment).join(Job, Assignment.job_id == Job.id).where( + Assignment.technician_id == tech.id, + Job.status == JobStatus.ASSIGNED, + Assignment.estimated_arrival.is_(None), + ).order_by(Job.time_slot_start.nullsfirst(), Job.id).limit(1) + )).scalar_one_or_none() + if next_a is None: + continue + travel = next_a.estimated_travel_time or 0 + # Honor the customer time slot: don't arrive before slot_start. Tech idles + # at base (AVAILABLE) until depart_time = slot_start - travel. + slot_str = next_a.job.time_slot_start + if slot_str: + try: + h, m = int(slot_str[:2]), int(slot_str[3:5]) + slot_dt = now.replace(hour=h, minute=m, second=0, microsecond=0) + depart_dt = slot_dt - timedelta(minutes=travel) + if now < depart_dt: + continue # too early — wait + arrival_dt = max(now + timedelta(minutes=travel), slot_dt) + except (ValueError, IndexError): + arrival_dt = now + timedelta(minutes=travel) + else: + arrival_dt = now + timedelta(minutes=travel) + next_a.estimated_arrival = arrival_dt + if tech.status == TechnicianStatus.AVAILABLE: + tech.status = TechnicianStatus.EN_ROUTE await db.commit() # ── 2. ASSIGNED → IN_PROGRESS when arrival passes ────────────────────── @@ -234,7 +280,23 @@ async def _tick(db: AsyncSession, now: datetime, strategy: DispatchStrategy, sim ) ) active_today = count_result.scalar() or 0 - if active_today == 0: + # Day ends when all jobs terminal OR virtual clock passes 18:00 (post-shift). + # Time-based stop prevents the sim from rolling forever when ML intentionally + # leaves niche-skill jobs (T. Monk, Sun Ra, Tim Berne) unassigned. + past_shift = now.hour >= 18 + if active_today == 0 or past_shift: + # Cancel any still-active jobs so they don't sit pending forever. + if past_shift and active_today > 0: + await db.execute( + Job.__table__.update() + .where( + Job.status.in_([JobStatus.PENDING, JobStatus.ASSIGNED]), + Job.scheduled_date >= day_start, + Job.scheduled_date < day_end, + ) + .values(status=JobStatus.CANCELLED) + ) + await db.commit() events.append(DispatchEvent(event_type="day_complete", timestamp=now)) elapsed_min = int((now - sim_start).total_seconds() / 60) if sim_start else 0 diff --git a/frontend/src/styles/index.css b/backend/static/app.css similarity index 57% rename from frontend/src/styles/index.css rename to backend/static/app.css index 041770f..baa3b9d 100644 --- a/frontend/src/styles/index.css +++ b/backend/static/app.css @@ -1,11 +1,9 @@ /* ================================================================ - FieldOpt v0.0.8 — Enterprise Dispatch Console - Design System & Global Styles + FieldOpt — HTMX UI styles. Ported from React index.css for parity. ================================================================ */ -/* --- CSS Custom Properties --- */ :root { - /* Surface colors — neutral, professional grays */ + /* Surface colors */ --surface-app: #1a1d23; --surface-header: #22262e; --surface-panel: #2a2e37; @@ -16,13 +14,13 @@ --surface-input: #1e2128; --surface-overlay: rgba(0, 0, 0, 0.6); - /* Text colors */ + /* Text */ --text-primary: #e0e2e7; --text-secondary: #9a9da4; --text-muted: #6b6f78; --text-inverse: #1a1d23; - /* Accent / Status colors — functional, not decorative */ + /* Accent / status */ --color-accent: #4a9eff; --color-accent-dim: #345e8a; --color-success: #4caf6a; @@ -37,7 +35,7 @@ --color-purple-dim: rgba(155, 126, 216, 0.15); --color-on-hold: #7a7e88; - /* Priority colors */ + /* Priority */ --priority-1: #e05555; --priority-2: #e5a834; --priority-3: #5b9bd5; @@ -62,31 +60,26 @@ --space-2xl: 24px; /* Layout */ - --header-height: 40px; - --dashboard-bar-height: 32px; - --divider-size: 5px; + --header-height: 38px; + --sim-bar-height: 28px; + --dashboard-bar-height: 28px; + --divider-size: 9px; --border-radius: 3px; - - /* Borders */ --border-color: #3a3f4a; --border-light: #2f333b; } -/* --- Reset & Base --- */ -*, -*::before, -*::after { +/* --- Reset --- */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } - -html, body, #root { +html, body { height: 100%; width: 100%; overflow: hidden; } - body { font-family: var(--font-family); font-size: var(--font-size-base); @@ -96,8 +89,18 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } +.mono { font-family: var(--font-mono); } +.muted { color: var(--text-muted); } + +/* --- Scrollbars --- */ +::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar-track { background: var(--surface-panel); } +::-webkit-scrollbar-thumb { background: var(--surface-divider); border-radius: 4px; } +::-webkit-scrollbar-thumb:hover { background: var(--text-muted); } +::-webkit-scrollbar-corner { background: var(--surface-panel); } +html, body { scrollbar-color: var(--surface-divider) var(--surface-panel); scrollbar-width: thin; } -/* --- App Shell --- */ +/* --- App shell --- */ .app-shell { display: flex; flex-direction: column; @@ -105,7 +108,7 @@ body { overflow: hidden; } -/* --- Header Bar --- */ +/* --- Header bar --- */ .header-bar { height: var(--header-height); min-height: var(--header-height); @@ -119,7 +122,6 @@ body { overflow-x: auto; overflow-y: hidden; } - .header-brand { display: flex; align-items: center; @@ -129,7 +131,6 @@ body { color: var(--text-primary); letter-spacing: -0.01em; } - .header-brand-icon { width: 18px; height: 18px; @@ -137,14 +138,12 @@ body { border-radius: 3px; flex-shrink: 0; } - .header-version { font-size: var(--font-size-xs); color: var(--text-muted); font-weight: 400; margin-left: 4px; } - .header-real-clock { font-size: var(--font-size-xs); color: var(--text-muted); @@ -152,18 +151,15 @@ body { margin-left: 8px; letter-spacing: 0.03em; } - .header-divider { width: 1px; height: 20px; background: var(--border-color); } - .header-actions { display: flex; align-items: center; - gap: var(--space-md); - margin-left: auto; + gap: var(--space-sm); flex-shrink: 0; white-space: nowrap; } @@ -172,8 +168,10 @@ body { .btn { display: inline-flex; align-items: center; + justify-content: center; gap: var(--space-sm); - padding: 4px 10px; + padding: 7px 14px; + min-height: 30px; font-family: var(--font-family); font-size: var(--font-size-sm); font-weight: 500; @@ -186,51 +184,189 @@ body { white-space: nowrap; transition: background 0.1s, border-color 0.1s; } - .btn:hover { background: var(--surface-row-hover); border-color: #4a4f5a; } - -.btn:active { - background: var(--surface-divider); -} - +.btn:active { background: var(--surface-divider); } .btn:disabled { opacity: 0.4; cursor: not-allowed; } - .btn--primary { background: var(--color-accent-dim); border-color: var(--color-accent); color: var(--color-accent); } - -.btn--primary:hover { - background: rgba(74, 158, 255, 0.25); -} - +.btn--primary:hover { background: rgba(74, 158, 255, 0.25); } .btn--warning { background: var(--color-warning-dim); border-color: var(--color-warning); color: var(--color-warning); } - -.btn--warning:hover { - background: rgba(229, 168, 52, 0.25); +.btn--warning:hover { background: rgba(229, 168, 52, 0.25); } +.btn--danger { + background: var(--color-danger-dim); + border-color: var(--color-danger); + color: var(--color-danger); } - -.btn--icon { - padding: 4px 6px; +.btn--danger:hover { background: rgba(224, 85, 85, 0.25); } +.btn--success { + background: var(--color-success-dim); + border-color: var(--color-success); + color: var(--color-success); +} +.btn--success:hover { background: rgba(76, 175, 106, 0.25); } +.btn--stop { background: var(--color-danger-dim); border-color: var(--color-danger); color: var(--color-danger); } +.btn--sm { + height: 22px; + padding: 0 8px; + font-size: var(--font-size-xs); } - .btn svg { width: 14px; height: 14px; } +.icon-btn, +.btn.icon-btn, +.btn--icon { + width: 34px; + min-width: 34px; + height: 34px; + min-height: 34px; + padding: 0; + font-size: 16px; +} +.icon-btn svg, +.btn--icon svg { + width: 16px; + height: 16px; + display: block; +} + +/* --- Day picker --- */ +.day-picker { + display: inline-flex; + align-items: center; + gap: 4px; +} +.day-picker .icon-btn { + width: 26px; + min-width: 26px; + height: 26px; + min-height: 26px; + font-size: 12px; + color: var(--text-muted); + background: none; + border: 1px solid transparent; +} +.day-picker .icon-btn:hover { + background: var(--surface-row-hover); + color: var(--text-primary); + border-color: var(--border-color); +} +.day-btn { + padding: 4px 14px; + min-height: 26px; + background: rgba(74, 158, 255, 0.08); + border: 1px solid var(--color-accent-dim); + border-radius: var(--border-radius); + color: var(--color-accent); + cursor: pointer; + font-family: var(--font-family); + font-size: var(--font-size-sm); + font-weight: 600; + min-width: 110px; + text-align: center; + line-height: 1; +} +.day-btn:hover { + background: rgba(74, 158, 255, 0.16); + color: var(--color-accent); +} + +/* --- Calendar picker --- */ +.cal-picker { + position: fixed; + z-index: 9999; + background: var(--surface-panel); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 10px; + box-shadow: 0 8px 24px rgba(0,0,0,0.4); + min-width: 220px; +} +.cal-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} +.cal-month-label { + font-size: var(--font-size-xs); + font-weight: 600; + color: var(--text-primary); +} +.cal-nav { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 2px 6px; + border-radius: 3px; + font-size: 11px; + line-height: 1; +} +.cal-nav:hover { + background: var(--surface-row-hover); + color: var(--text-primary); +} +.cal-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 2px; +} +.cal-dow { + font-size: 10px; + color: var(--text-muted); + text-align: center; + padding: 2px 0 4px; +} +.cal-day { + background: none; + border: none; + border-radius: 3px; + color: var(--text-secondary); + cursor: pointer; + font-size: var(--font-size-xs); + padding: 4px 2px; + text-align: center; + line-height: 1; +} +.cal-day:hover { background: var(--surface-row-hover); color: var(--text-primary); } +.cal-day--today { color: var(--color-accent); font-weight: 600; } +.cal-day--selected { + background: var(--color-accent); + color: #fff; + font-weight: 600; +} +.cal-day--selected:hover { background: var(--color-accent); } +.cal-footer { + margin-top: 8px; + text-align: center; +} +.cal-today-btn { + background: none; + border: 1px solid var(--border-color); + border-radius: 3px; + color: var(--text-secondary); + cursor: pointer; + font-size: 10px; + padding: 3px 10px; +} +.cal-today-btn:hover { border-color: var(--color-accent); color: var(--color-accent); } -/* --- Dashboard Indicator Bar --- */ +/* --- Dashboard bar --- */ .dashboard-bar { height: var(--dashboard-bar-height); min-height: var(--dashboard-bar-height); @@ -244,7 +380,7 @@ body { overflow-x: auto; overflow-y: hidden; } - +.dash-cell, .dash-indicator { display: inline-flex; align-items: center; @@ -259,27 +395,13 @@ body { font-family: var(--font-family); transition: background 0.1s, border-color 0.1s; } - -.dash-indicator:hover { - background: var(--surface-row-hover); -} - +.dash-cell:hover, +.dash-indicator:hover { background: var(--surface-row-hover); } +.dash-cell.active, .dash-indicator--active { background: var(--surface-row-selected); border-color: var(--color-accent); } - -.dash-indicator--clear { - color: var(--text-muted); - font-size: var(--font-size-xs); - gap: 3px; -} - -.dash-indicator--clear:hover { - color: var(--color-danger); - background: var(--color-danger-dim); -} - .dash-count { font-weight: 700; font-size: var(--font-size-sm); @@ -287,19 +409,18 @@ body { min-width: 16px; text-align: center; } - .dash-count--danger { color: var(--color-danger); } .dash-count--warning { color: var(--color-warning); } .dash-count--info { color: var(--color-info); } .dash-count--success { color: var(--color-success); } .dash-count--muted { color: var(--text-muted); } - .dash-label { color: var(--text-muted); font-size: var(--font-size-xs); } -/* --- Split Pane Container --- */ +/* --- Vertical stack body + splitters --- */ +.stack, .split-container { flex: 1; display: flex; @@ -307,14 +428,18 @@ body { overflow: hidden; min-height: 0; } - -/* --- Pane --- */ .pane { display: flex; flex-direction: column; overflow: hidden; min-height: 80px; } +.pane-techs { flex: 0 0 38%; } +.pane-jobs { flex: 1 1 auto; } +.pane-timeline { flex: 1 1 220px; min-height: 100px; border-top: 1px solid var(--border-color); } +.pane-timeline[hidden] { display: none; } +/* Hide the Jobs↔Timeline splitter when timeline is collapsed. */ +.stack:has(.pane-timeline[hidden]) .row-splitter[data-resize="jobs"] { display: none; } .pane-header { height: 28px; @@ -327,7 +452,6 @@ body { gap: var(--space-md); user-select: none; } - .pane-title { font-size: var(--font-size-sm); font-weight: 600; @@ -335,863 +459,244 @@ body { text-transform: uppercase; letter-spacing: 0.04em; } - .pane-count { font-size: var(--font-size-xs); color: var(--text-muted); font-family: var(--font-mono); } - +.pane-sel-count { + font-size: var(--font-size-xs); + color: var(--color-accent); + font-weight: 600; + font-family: var(--font-family); +} .pane-body { flex: 1; - overflow: hidden; + overflow: auto; min-height: 0; } - -.pane-selection { - font-size: var(--font-size-xs); - color: var(--color-accent); - font-weight: 500; +.pane-actions { margin-left: auto; + display: inline-flex; + gap: var(--space-sm); } -/* --- Day Picker --- */ -.day-picker { - display: flex; - align-items: center; - gap: 2px; -} - -.day-picker-btn { +.row-splitter, +.split-divider { + height: var(--divider-size); + min-height: var(--divider-size); + background: var(--surface-divider); + cursor: row-resize; display: flex; align-items: center; justify-content: center; - width: 22px; - height: 22px; - background: none; - border: 1px solid transparent; - border-radius: var(--border-radius); - color: var(--text-muted); - cursor: pointer; - font-size: 11px; - font-family: var(--font-family); + user-select: none; + transition: background 0.15s; + flex-shrink: 0; + position: relative; } - -.day-picker-btn:hover { - background: var(--surface-row-hover); - color: var(--text-primary); - border-color: var(--border-color); +/* Visible grip — three short bars centered */ +.row-splitter::after, +.split-divider::after { + content: ''; + display: block; + width: 40px; + height: 3px; + border-radius: 2px; + background: var(--text-muted); + opacity: 0.5; + transition: opacity 0.15s, background 0.15s; +} +.row-splitter:hover, +.row-splitter.dragging, +.split-divider:hover, +.split-divider--active { background: var(--color-accent-dim); } +.row-splitter:hover::after, +.row-splitter.dragging::after, +.split-divider:hover::after, +.split-divider--active::after { + opacity: 1; + background: var(--color-accent); } -.day-picker-date { - padding: 2px 10px; - background: none; - border: 1px solid var(--border-color); - border-radius: var(--border-radius); +/* --- Grids (tech + job + personnel) --- */ +.grid { + width: 100%; + min-width: 720px; + border-collapse: separate; + border-spacing: 0; + font-size: var(--font-size-sm); + user-select: none; + -webkit-user-select: none; + table-layout: auto; +} +.grid th, .grid td { + padding: 5px var(--space-md); + text-align: left; + border-bottom: 1px solid var(--border-light); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + position: relative; +} +.grid th { color: var(--text-secondary); - cursor: pointer; - font-family: var(--font-family); font-size: var(--font-size-xs); - font-weight: 500; - min-width: 80px; - text-align: center; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + background: var(--surface-panel-alt); + position: sticky; + top: 0; + z-index: 1; + border-bottom: 1px solid var(--border-color); + user-select: none; } - -.day-picker-date:hover { - background: var(--surface-row-hover); - color: var(--text-primary); +.grid th.col-resizable { + resize: horizontal; + overflow: auto; } - -.day-picker-date--today { - color: var(--color-accent); - border-color: var(--color-accent-dim); -} - -.day-picker-date--locked { - cursor: default; - opacity: 0.75; - pointer-events: none; -} - -/* --- Calendar Picker --- */ -.cal-picker { - z-index: 9999; - background: var(--surface-panel); - border: 1px solid var(--border-color); - border-radius: 6px; - padding: 10px; - box-shadow: 0 8px 24px rgba(0,0,0,0.4); - min-width: 220px; -} -.cal-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 8px; -} -.cal-month-label { - font-size: var(--font-size-xs); - font-weight: 600; - color: var(--text-primary); -} -.cal-nav { - background: none; - border: none; - color: var(--text-secondary); - cursor: pointer; - padding: 2px 6px; - border-radius: 3px; - font-size: 11px; - line-height: 1; -} -.cal-nav:hover { background: var(--surface-row-hover); color: var(--text-primary); } -.cal-grid { - display: grid; - grid-template-columns: repeat(7, 1fr); - gap: 2px; -} -.cal-dow { - font-size: 10px; - color: var(--text-muted); - text-align: center; - padding: 2px 0 4px; -} -.cal-day { - background: none; - border: none; - border-radius: 3px; - color: var(--text-secondary); - cursor: pointer; - font-size: var(--font-size-xs); - padding: 4px 2px; - text-align: center; - line-height: 1; -} -.cal-day:hover { background: var(--surface-row-hover); color: var(--text-primary); } -.cal-day--today { color: var(--color-accent); font-weight: 600; } -.cal-day--selected { - background: var(--color-accent); - color: #fff; - font-weight: 600; -} -.cal-day--selected:hover { background: var(--color-accent); } -.cal-footer { - margin-top: 8px; - text-align: center; -} -.cal-today-btn { - background: none; - border: 1px solid var(--border-color); - border-radius: 3px; - color: var(--text-secondary); - cursor: pointer; - font-size: 10px; - padding: 3px 10px; -} -.cal-today-btn:hover { border-color: var(--color-accent); color: var(--color-accent); } - -/* --- Row Selection Enhancement --- */ -.ag-theme-fieldopt .ag-row-selected { - border-left: 2px solid var(--color-accent) !important; -} - -/* --- Tech Timeline --- */ -.timeline-container { - height: 100%; - overflow: auto; - background: var(--surface-panel); -} - -.timeline-empty { - display: flex; - align-items: center; - justify-content: center; - height: 100%; - color: var(--text-muted); - font-size: var(--font-size-sm); - font-style: italic; -} - -.timeline-header { - display: flex; - align-items: center; - height: 24px; - border-bottom: 1px solid var(--border-color); - position: sticky; - top: 0; - z-index: 2; - background: var(--surface-panel-alt); -} - -.timeline-body { - position: relative; -} - -.timeline-label-col { - width: 140px; - min-width: 140px; - padding: 0 var(--space-md); - font-size: var(--font-size-xs); - font-weight: 600; - color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.03em; - display: flex; - align-items: center; -} - -.timeline-tech-name { - color: var(--text-primary); - font-weight: 500; - text-transform: none; - letter-spacing: 0; - font-size: var(--font-size-sm); -} - -.timeline-hour-header { - font-size: 10px; - color: var(--text-muted); - text-align: left; - padding-left: 4px; - border-left: 1px solid var(--border-light); -} - -.timeline-row { - display: flex; - align-items: stretch; - border-bottom: 1px solid var(--border-light); -} - -.timeline-track { - position: relative; - flex: 1; -} - -.timeline-gridline { - position: absolute; - top: 0; - width: 1px; - background: var(--border-light); -} - -.timeline-shift { - position: absolute; - background: rgba(74, 158, 255, 0.06); - border-radius: 2px; -} - -.timeline-job { - position: absolute; - border-radius: 2px; - opacity: 0.85; - display: flex; - align-items: center; - padding: 0 4px; - overflow: hidden; - cursor: default; - transition: opacity 0.1s; -} - -.timeline-job:hover { - opacity: 1; - z-index: 1; -} - -.timeline-job-label { - font-size: 10px; - font-weight: 600; - color: var(--text-inverse); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.timeline-job-slot { - font-size: 9px; - font-weight: 400; - color: rgba(255, 255, 255, 0.7); - white-space: nowrap; - margin-left: auto; - padding-left: 4px; -} - -/* --- Draggable Divider --- */ -.split-divider { - height: var(--divider-size); - min-height: var(--divider-size); - background: var(--surface-divider); - cursor: row-resize; - display: flex; - align-items: center; - justify-content: center; - user-select: none; - transition: background 0.15s; -} - -.split-divider:hover, -.split-divider--active { - background: var(--color-accent-dim); -} - -.split-divider-grip { - width: 32px; - height: 2px; - background: var(--text-muted); - border-radius: 1px; - opacity: 0.5; -} - -.split-divider:hover .split-divider-grip, -.split-divider--active .split-divider-grip { - opacity: 0.9; - background: var(--color-accent); -} - -/* --- AG Grid Theme Overrides --- */ -.ag-theme-fieldopt { - --ag-background-color: var(--surface-panel); - --ag-header-background-color: var(--surface-panel-alt); - --ag-odd-row-background-color: var(--surface-panel); - --ag-row-hover-color: var(--surface-row-hover); - --ag-selected-row-background-color: var(--surface-row-selected); - --ag-header-foreground-color: var(--text-secondary); - --ag-foreground-color: var(--text-primary); - --ag-border-color: var(--border-light); - --ag-secondary-border-color: var(--border-light); - --ag-row-border-color: transparent; - --ag-font-family: var(--font-family); - --ag-font-size: var(--font-size-sm); - --ag-header-height: 28px; - --ag-row-height: 26px; - --ag-cell-horizontal-padding: 8px; - --ag-header-column-separator-display: none; - --ag-icon-size: 12px; - --ag-wrapper-border-radius: 0; - --ag-borders: none; -} - -.ag-theme-fieldopt .ag-header-cell-label { - font-size: var(--font-size-xs); - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.03em; -} - -.ag-theme-fieldopt .ag-cell { - line-height: 26px; - border-right: none; -} - -.ag-theme-fieldopt .ag-row { - border-bottom: 1px solid var(--border-light); -} - -/* --- Status Badges (used in grid cells) --- */ -.status-badge { - display: inline-block; - padding: 1px 6px; - border-radius: 2px; - font-size: var(--font-size-xs); - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.02em; - line-height: 1.6; -} - -.status-badge--pending { - background: var(--color-warning-dim); - color: var(--color-warning); -} - -.status-badge--assigned { - background: var(--color-info-dim); - color: var(--color-info); -} - -.status-badge--in_progress { - background: var(--color-purple-dim); - color: var(--color-purple); -} - -.status-badge--completed { - background: var(--color-success-dim); - color: var(--color-success); -} - -.status-badge--cancelled { - background: var(--color-danger-dim); - color: var(--color-danger); -} - -.status-badge--on_hold { - background: rgba(122, 126, 136, 0.15); - color: var(--color-on-hold); -} - -/* Tech statuses */ -.status-badge--available { - background: var(--color-success-dim); - color: var(--color-success); -} - -.status-badge--on_job { - background: var(--color-purple-dim); - color: var(--color-purple); -} - -.status-badge--en_route { - background: var(--color-info-dim); - color: var(--color-info); -} - -.status-badge--on_break { - background: var(--color-warning-dim); - color: var(--color-warning); -} - -.status-badge--off_duty { - background: rgba(122, 126, 136, 0.15); - color: var(--color-on-hold); -} - -/* --- Priority indicators --- */ -.priority-cell { - font-weight: 700; - font-family: var(--font-mono); - font-size: var(--font-size-xs); -} - -.priority-cell--1 { color: var(--priority-1); } -.priority-cell--2 { color: var(--priority-2); } -.priority-cell--3 { color: var(--priority-3); } -.priority-cell--4 { color: var(--priority-4); } -.priority-cell--5 { color: var(--priority-5); } - -/* --- Skills chips in grid --- */ -.skills-cell { - display: flex; - flex-wrap: nowrap; - gap: 3px; - overflow: hidden; -} - -.skill-chip { - display: inline-block; - padding: 0 5px; - background: rgba(74, 158, 255, 0.1); - color: var(--color-accent); - border-radius: 2px; - font-size: 10px; - font-weight: 500; - line-height: 18px; - white-space: nowrap; -} - -/* --- Floating Map Window --- */ -.map-window { - position: fixed; - z-index: 1000; - background: var(--surface-panel); - border: 1px solid var(--border-color); - border-radius: 4px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); - display: flex; - flex-direction: column; - overflow: hidden; -} - -.map-window-titlebar { - height: 30px; - min-height: 30px; - background: var(--surface-header); - border-bottom: 1px solid var(--border-color); - display: flex; - align-items: center; - padding: 0 var(--space-md) 0 var(--space-lg); - cursor: move; - user-select: none; -} - -.map-window-title { - font-size: var(--font-size-sm); - font-weight: 600; - color: var(--text-secondary); - flex: 1; -} - -.map-window-close { - width: 22px; - height: 22px; - display: flex; - align-items: center; - justify-content: center; - background: none; - border: none; - color: var(--text-muted); - cursor: pointer; - border-radius: 2px; - font-size: 16px; - line-height: 1; -} - -.map-window-close:hover { - background: var(--color-danger-dim); - color: var(--color-danger); -} - -.map-window-body { - flex: 1; - min-height: 0; -} - -/* Leaflet dark adjustments */ -.map-window .leaflet-container { - background: #1a1d23; -} - -/* --- Loading State --- */ -.loading-screen { - display: flex; - align-items: center; - justify-content: center; - height: 100%; - background: var(--surface-app); - color: var(--text-secondary); - font-size: var(--font-size-lg); - gap: var(--space-md); -} - -.loading-spinner { - width: 18px; - height: 18px; - border: 2px solid var(--border-color); - border-top-color: var(--color-accent); - border-radius: 50%; - animation: spin 0.8s linear infinite; -} - -@keyframes spin { - to { transform: rotate(360deg); } -} - -/* --- Context Menu --- */ -.ctx-menu { - position: fixed; - z-index: 2000; - min-width: 200px; - background: var(--surface-header); - border: 1px solid var(--border-color); - border-radius: var(--border-radius); - box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5); - padding: 4px 0; - font-size: var(--font-size-sm); -} - -.ctx-menu-header { - padding: 4px 12px; - font-size: var(--font-size-xs); - font-weight: 600; - color: var(--text-secondary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.ctx-menu-status { - padding: 2px 12px 4px; -} - -.ctx-menu-label { - padding: 4px 12px 2px; - font-size: 10px; - font-weight: 600; - color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.ctx-menu-sep { - height: 1px; - margin: 3px 0; - background: var(--border-light); -} - -.ctx-menu-item { - display: flex; - align-items: center; - width: 100%; - padding: 5px 12px; - border: none; - background: none; - color: var(--text-primary); - font-family: var(--font-family); - font-size: var(--font-size-sm); - cursor: pointer; - text-align: left; - gap: 6px; - white-space: nowrap; - position: relative; -} - -.ctx-menu-item:hover { - background: var(--surface-row-hover); -} - -.ctx-menu-item:disabled { - opacity: 0.35; - cursor: not-allowed; -} - -.ctx-menu-item:disabled:hover { - background: none; -} - -.ctx-menu-item--danger { - color: var(--color-danger); -} - -.ctx-menu-item--danger:hover { - background: var(--color-danger-dim); -} - -.ctx-menu-item--parent { - justify-content: space-between; +/* Column resize handle */ +.grid th .col-resizer { + position: absolute; + top: 0; + right: 0; + width: 6px; + height: 100%; + cursor: col-resize; + z-index: 2; + user-select: none; } - -.ctx-arrow { - color: var(--text-muted); - font-size: 10px; - margin-left: auto; +.grid th .col-resizer:hover { + background: var(--color-accent); + opacity: 0.4; } - -.ctx-dot { - display: inline-block; - width: 7px; - height: 7px; - border-radius: 50%; - flex-shrink: 0; +.grid tbody tr { + height: 26px; } - -.ctx-dot--success { background: var(--color-success); } -.ctx-dot--warning { background: var(--color-warning); } -.ctx-dot--muted { background: var(--text-muted); } -.ctx-dot--purple { background: var(--color-purple); } -.ctx-dot--info { background: var(--color-info); } - -/* --- Context Submenu --- */ -.ctx-submenu { - position: absolute; - left: 100%; - top: -4px; - min-width: 200px; - max-height: 320px; - overflow-y: auto; - background: var(--surface-header); - border: 1px solid var(--border-color); - border-radius: var(--border-radius); - box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5); - padding: 4px 0; +.grid tbody tr:hover { background: var(--surface-row-hover); } +.grid td.num { font-family: var(--font-mono); text-align: right; } +.grid td.addr, .grid td.routes { + color: var(--text-secondary); } - -.ctx-menu-empty { - padding: 8px 12px; +.grid td.empty { + text-align: center; color: var(--text-muted); - font-size: var(--font-size-xs); + padding: 16px; font-style: italic; } -/* --- Drop Target Indicator --- */ -.drop-target-active .ag-row:hover { - outline: 1px solid var(--color-accent); - outline-offset: -1px; - background: var(--surface-row-selected) !important; -} - -/* --- Drag ghost styling --- */ -.drag-ghost { - position: fixed; - z-index: 3000; - padding: 4px 10px; - background: var(--surface-header); - border: 1px solid var(--color-accent); - border-radius: var(--border-radius); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); - font-size: var(--font-size-xs); - color: var(--color-accent); - white-space: nowrap; - pointer-events: none; - font-weight: 500; -} - -/* --- Scrollbar styling --- */ -::-webkit-scrollbar { - width: 8px; - height: 8px; -} - -::-webkit-scrollbar-track { - background: var(--surface-panel); -} - -::-webkit-scrollbar-thumb { - background: var(--surface-divider); - border-radius: 4px; -} - -::-webkit-scrollbar-thumb:hover { - background: var(--text-muted); -} - -/* --- Row Status Tinting --- */ -.ag-theme-fieldopt .ag-row[row-status="pending"] { - background: rgba(229, 168, 52, 0.04); -} - -.ag-theme-fieldopt .ag-row[row-status="in_progress"] { - background: rgba(155, 126, 216, 0.04); -} - -.ag-theme-fieldopt .ag-row[row-status="completed"] { - background: rgba(76, 175, 106, 0.04); -} - -.ag-theme-fieldopt .ag-row[row-status="cancelled"] { - background: rgba(224, 85, 85, 0.04); -} - -/* --- Map Window Resize Handle --- */ -.map-window-resize { - position: absolute; - right: 0; - bottom: 0; - width: 16px; - height: 16px; - cursor: nwse-resize; - z-index: 10; -} - -.map-window-resize::after { - content: ''; - position: absolute; - right: 3px; - bottom: 3px; - width: 8px; - height: 8px; - border-right: 2px solid var(--text-muted); - border-bottom: 2px solid var(--text-muted); - opacity: 0.5; -} - -.map-window-resize:hover::after { - opacity: 1; - border-color: var(--color-accent); -} +.grid-row { cursor: pointer; transition: background 0.1s; } +.grid-row.selected { background: var(--surface-row-selected) !important; } +.grid-row.selected td:first-child { box-shadow: inset 3px 0 0 var(--color-accent); } +.grid-row.related { background: rgba(74, 158, 255, 0.06); } +.grid-row.related td:first-child { box-shadow: inset 2px 0 0 var(--color-info); } -/* --- Toast Notifications --- */ -.toast-container { - position: fixed; - bottom: 16px; - right: 16px; - z-index: 3000; - display: flex; - flex-direction: column-reverse; - gap: 6px; - pointer-events: none; -} +/* Row status tinting */ +.grid-row[data-job-status="pending"] { background: rgba(229, 168, 52, 0.04); } +.grid-row[data-job-status="in_progress"] { background: rgba(155, 126, 216, 0.04); } +.grid-row[data-job-status="completed"] { background: rgba(76, 175, 106, 0.04); } +.grid-row[data-job-status="cancelled"] { background: rgba(224, 85, 85, 0.04); } -.toast { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 14px; - background: var(--surface-header); - border: 1px solid var(--border-color); - border-radius: var(--border-radius); - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); - font-size: var(--font-size-sm); - color: var(--text-primary); - opacity: 0; - transform: translateY(8px); - transition: opacity 0.2s, transform 0.2s; - white-space: nowrap; +.job-row { cursor: grab; } +.job-row.dragging { opacity: 0.4; } +.job-row--overdue { + box-shadow: inset 3px 0 0 var(--color-danger); + background: rgba(224, 85, 85, 0.08) !important; } +.job-row--overdue:hover { background: rgba(224, 85, 85, 0.14) !important; } -.toast--visible { - opacity: 1; - transform: translateY(0); -} +.row--overrun-yellow { background: var(--color-warning-dim) !important; } +.row--overrun-yellow:hover { background: rgba(229, 168, 52, 0.22) !important; } +.row--overrun-red { background: var(--color-danger-dim) !important; } +.row--overrun-red:hover { background: rgba(224, 85, 85, 0.22) !important; } -.toast-icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 18px; - height: 18px; - border-radius: 50%; - font-size: 10px; +/* --- Priority indicators --- */ +.pri, .priority-cell { font-weight: 700; - flex-shrink: 0; -} - -.toast--success .toast-icon { - background: var(--color-success-dim); - color: var(--color-success); -} - -.toast--error .toast-icon { - background: var(--color-danger-dim); - color: var(--color-danger); -} - -.toast--warning .toast-icon { - background: var(--color-warning-dim); - color: var(--color-warning); + font-family: var(--font-mono); + font-size: var(--font-size-xs); } +.pri--1, .priority-cell--1 { color: var(--priority-1); } +.pri--2, .priority-cell--2 { color: var(--priority-2); } +.pri--3, .priority-cell--3 { color: var(--priority-3); } +.pri--4, .priority-cell--4 { color: var(--priority-4); } +.pri--5, .priority-cell--5 { color: var(--priority-5); } -.toast--info .toast-icon { - background: var(--color-info-dim); - color: var(--color-info); +/* --- Status pills --- */ +.pill, .status-badge { + display: inline-block; + padding: 1px 6px; + border-radius: 2px; + font-size: var(--font-size-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.02em; + line-height: 1.6; } - -/* --- Status Bar (footer) --- */ -.status-bar { - height: 22px; - min-height: 22px; - background: var(--surface-panel-alt); - border-top: 1px solid var(--border-color); - display: flex; - align-items: center; - padding: 0 var(--space-lg); - gap: var(--space-xl); +.pill--pending, .status-badge--pending { background: var(--color-warning-dim); color: var(--color-warning); } +.pill--assigned, .status-badge--assigned { background: var(--color-info-dim); color: var(--color-info); } +.pill--in_progress, .status-badge--in_progress { background: var(--color-purple-dim); color: var(--color-purple); } +.pill--completed, .status-badge--completed { background: var(--color-success-dim); color: var(--color-success); } +.pill--cancelled, .status-badge--cancelled { background: var(--color-danger-dim); color: var(--color-danger); } +.pill--on_hold, .status-badge--on_hold { background: rgba(122, 126, 136, 0.15); color: var(--color-on-hold); } +.pill--available, .pill--AVAILABLE, .status-badge--available { background: var(--color-success-dim); color: var(--color-success); } +.pill--on_job, .status-badge--on_job { background: var(--color-purple-dim); color: var(--color-purple); } +.pill--en_route, .status-badge--en_route { background: var(--color-info-dim); color: var(--color-info); } +.pill--on_break, .status-badge--on_break { background: var(--color-warning-dim); color: var(--color-warning); } +.pill--off_duty, .status-badge--off_duty { background: rgba(122, 126, 136, 0.15); color: var(--color-on-hold); } +.pill { transition: background 0.15s ease, color 0.15s ease; } + +/* --- Skill chips --- */ +.skill-chip { + display: inline-block; + padding: 0 5px; + background: rgba(74, 158, 255, 0.1); + color: var(--color-accent); + border-radius: 2px; font-size: 10px; - color: var(--text-muted); - user-select: none; + font-weight: 500; + line-height: 18px; + white-space: nowrap; } - -.status-bar-item { +.skills-cell { display: flex; - align-items: center; - gap: var(--space-sm); + flex-wrap: nowrap; + gap: 3px; + overflow: hidden; } -.status-bar kbd { - display: inline-block; - padding: 0 4px; +/* --- Timeline (SVG) --- */ +.timeline-wrap { + padding: var(--space-md); + height: 100%; + overflow: auto; background: var(--surface-panel); - border: 1px solid var(--border-light); - border-radius: 2px; +} +.timeline { + display: block; font-family: var(--font-mono); - font-size: 9px; - line-height: 1.5; } - -/* ================================================================ - v0.0.7 — Floating Windows, Filter, Personnel, Job Search, Detail - ================================================================ */ - -/* --- Floating Window Base --- */ +.tl-grid { stroke: var(--border-light); } +.tl-rowline { stroke: var(--border-light); } +.tl-hour { fill: var(--text-muted); font-size: 9px; } +.tl-name { fill: var(--text-primary); font-size: var(--font-size-xs); } +.tl-now { stroke: var(--color-danger); stroke-width: 1.5; stroke-dasharray: 3 2; } +.tl-block rect { stroke: rgba(0,0,0,0.5); stroke-width: 0.5; cursor: pointer; } +.tl-block:hover rect { stroke: var(--color-accent); stroke-width: 1; } +.tl-block-label { fill: var(--text-inverse); font: 600 9px var(--font-family); pointer-events: none; } +.tl-block--pending rect { fill: var(--color-warning); } +.tl-block--assigned rect { fill: var(--color-info); } +.tl-block--in_progress rect { fill: var(--color-success); } +.tl-block--completed rect { fill: var(--text-muted); } +.tl-block--cancelled rect { fill: var(--color-danger); } +.tl-block--on_hold rect { fill: var(--color-purple); } + +/* --- Floating windows --- */ +.float-win, .fw-window { position: fixed; + z-index: 700; display: flex; flex-direction: column; background: var(--surface-panel); @@ -1199,8 +704,11 @@ body { border-radius: var(--border-radius); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 2px 8px rgba(0, 0, 0, 0.3); overflow: hidden; + min-width: 320px; + min-height: 200px; + resize: both; } - +.float-win-head, .fw-titlebar { height: 30px; min-height: 30px; @@ -1209,10 +717,11 @@ body { display: flex; align-items: center; padding: 0 var(--space-md) 0 var(--space-lg); + gap: var(--space-md); cursor: move; user-select: none; } - +.float-win-title, .fw-title { font-size: var(--font-size-sm); font-weight: 600; @@ -1222,7 +731,7 @@ body { overflow: hidden; text-overflow: ellipsis; } - +.float-win-close, .fw-close { width: 22px; height: 22px; @@ -1237,12 +746,12 @@ body { font-size: 16px; line-height: 1; } - +.float-win-close:hover, .fw-close:hover { background: var(--color-danger-dim); color: var(--color-danger); } - +.float-win-body, .fw-body { flex: 1; min-height: 0; @@ -1250,8 +759,8 @@ body { flex-direction: column; overflow: hidden; } - -.fw-resize { +.fw-resize, +.float-win-resize { position: absolute; right: 0; bottom: 0; @@ -1260,8 +769,8 @@ body { cursor: nwse-resize; z-index: 10; } - -.fw-resize::after { +.fw-resize::after, +.float-win-resize::after { content: ''; position: absolute; right: 3px; @@ -1272,150 +781,73 @@ body { border-bottom: 2px solid var(--text-muted); opacity: 0.5; } - -.fw-resize:hover::after { +.fw-resize:hover::after, +.float-win-resize:hover::after { opacity: 1; - border-color: var(--color-accent); -} - -/* --- Filter Window --- */ -.filter-grid { - flex: 1; - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 1px; - background: var(--border-light); - min-height: 0; - overflow: hidden; -} - -.filter-col { - display: flex; - flex-direction: column; - background: var(--surface-panel); - min-height: 0; -} - -.filter-col-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 5px 8px; - background: var(--surface-panel-alt); - border-bottom: 1px solid var(--border-light); -} - -.filter-col-label { - font-size: var(--font-size-xs); - font-weight: 600; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.04em; -} - -.filter-col-count { - font-size: 10px; - color: var(--text-muted); - font-family: var(--font-mono); -} - -.filter-col-list { - flex: 1; - overflow-y: auto; - padding: 2px 0; + border-color: var(--color-accent); } -.filter-item { +/* --- Filter window inner --- */ +.filter-section { margin-bottom: 16px; padding: 0 14px; } +.filter-section:first-child { margin-top: 14px; } +.filter-section-head { display: flex; + justify-content: space-between; align-items: center; - width: 100%; - padding: 3px 8px; - border: none; + padding-bottom: 4px; + border-bottom: 1px solid var(--border-light); + margin-bottom: 6px; +} +.filter-section-head > span:first-child { + font-size: var(--font-size-xs); + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.06em; +} +.filter-section-actions { display: inline-flex; gap: 4px; } +.filter-section-actions button { background: none; - color: var(--text-primary); - font-family: var(--font-family); + border: none; + color: var(--text-muted); font-size: var(--font-size-xs); + font-family: var(--font-family); cursor: pointer; - text-align: left; - gap: 5px; + padding: 2px 6px; + border-radius: 2px; } - -.filter-item:hover { +.filter-section-actions button:hover { + color: var(--color-accent); background: var(--surface-row-hover); } - -.filter-item--selected { - background: var(--surface-row-selected); +.filter-checks { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 4px 12px; + max-height: 180px; + overflow-y: auto; + padding: 4px 0; } - -.filter-check { - width: 14px; - height: 14px; - border: 1px solid var(--border-color); - border-radius: 2px; +.filter-checks label { display: flex; align-items: center; - justify-content: center; - font-size: 9px; - color: transparent; - flex-shrink: 0; - background: var(--surface-input); -} - -.filter-check--on { - background: var(--color-accent); - border-color: var(--color-accent); - color: #fff; -} - -.filter-item-label { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - text-transform: capitalize; -} - -.filter-col-actions { - display: flex; - gap: 8px; - padding: 4px 8px; - border-top: 1px solid var(--border-light); -} - -.filter-link { - border: none; - background: none; - color: var(--color-accent); - font-size: 10px; + gap: 6px; + font-size: var(--font-size-sm); + color: var(--text-primary); cursor: pointer; - font-family: var(--font-family); - padding: 0; -} - -.filter-link:hover { - text-decoration: underline; } +.filter-checks input[type="checkbox"] { accent-color: var(--color-accent); } -.filter-actions { +/* --- Window toolbar (personnel) --- */ +.win-toolbar { display: flex; align-items: center; gap: var(--space-md); padding: 6px var(--space-lg); - border-top: 1px solid var(--border-color); background: var(--surface-panel-alt); -} - -/* --- Personnel Window --- */ -.personnel-search { - display: flex; - align-items: center; - gap: var(--space-md); - padding: 6px var(--space-lg); border-bottom: 1px solid var(--border-color); - background: var(--surface-panel-alt); } - -.personnel-input { +.win-input { flex: 1; height: 24px; padding: 0 var(--space-md); @@ -1427,141 +859,47 @@ body { font-size: var(--font-size-sm); outline: none; } - -.personnel-input:focus { - border-color: var(--color-accent); -} - -.personnel-clear { - border: none; - background: none; - color: var(--text-muted); - cursor: pointer; - font-size: 12px; - padding: 0 2px; -} - -.personnel-count { - font-size: 10px; - color: var(--text-muted); - font-family: var(--font-mono); - white-space: nowrap; -} - -.personnel-list { - flex: 1; - overflow-y: auto; -} - -.personnel-header { - display: flex; - align-items: center; - gap: var(--space-md); - padding: 3px var(--space-lg); - border-bottom: 1px solid var(--border-color); - font-size: 10px; - font-weight: 600; - color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.04em; - background: var(--surface-panel); -} - -.personnel-status-col { - width: 80px; - flex-shrink: 0; -} - -.personnel-row { - display: flex; - align-items: center; - gap: var(--space-md); - padding: 4px var(--space-lg); - border-bottom: 1px solid var(--border-light); - font-size: var(--font-size-xs); -} - -.personnel-row:hover { - background: var(--surface-row-hover); -} - -.personnel-id { - font-family: var(--font-mono); - color: var(--text-muted); - width: 50px; - flex-shrink: 0; -} - -.personnel-name { - flex: 1; - color: var(--text-primary); - font-weight: 500; -} - -.personnel-routes { - color: var(--text-muted); - font-family: var(--font-mono); - font-size: 10px; - max-width: 120px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.personnel-skills { - color: var(--text-muted); - font-size: 10px; - white-space: nowrap; -} - -.personnel-locate { - border: none; - background: none; - color: var(--color-accent); - cursor: pointer; - font-size: 14px; - padding: 0 2px; - opacity: 0.5; -} - -.personnel-locate:hover { - opacity: 1; -} - -.personnel-empty { +.win-input:focus { border-color: var(--color-accent); } +.win-empty { padding: 24px; text-align: center; color: var(--text-muted); - font-size: var(--font-size-sm); + font-style: italic; +} +.win-body { + flex: 1; + overflow: auto; + min-height: 0; } -/* --- Job Search Window --- */ -.js-criteria { - padding: 6px var(--space-lg); - border-bottom: 1px solid var(--border-color); +/* --- Search criteria form --- */ +.search-criteria { + padding: 8px var(--space-lg); background: var(--surface-panel-alt); + border-bottom: 1px solid var(--border-color); display: flex; flex-direction: column; gap: 4px; } - -.js-row { +.cr-row { display: flex; - align-items: center; - gap: 6px; + align-items: flex-end; + gap: 8px; } - -.js-label { +.cr-row label { + flex: 1; + display: flex; + flex-direction: column; + gap: 3px; font-size: 10px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; - letter-spacing: 0.04em; - white-space: nowrap; + letter-spacing: 0.05em; + min-width: 0; } - -.js-input, -.js-select { +.cr-row input, +.cr-row select { height: 22px; padding: 0 6px; background: var(--surface-input); @@ -1573,84 +911,68 @@ body { outline: none; min-width: 0; } - -.js-input { width: 100px; } -.js-input--sm { width: 55px; } -.js-input--lg { flex: 1; min-width: 100px; } - -.js-select { - width: 100px; - cursor: pointer; -} - -.js-input:focus, -.js-select:focus { - border-color: var(--color-accent); -} - -.js-placeholder { - flex: 1; +.cr-row input:focus, +.cr-row select:focus { border-color: var(--color-accent); } +.cr-actions { display: flex; align-items: center; - justify-content: center; - color: var(--text-muted); - font-size: var(--font-size-sm); + gap: 8px; + padding-top: 6px; } -.js-result-header { +/* --- Settings --- */ +.settings-group { display: flex; - align-items: center; - justify-content: space-between; - padding: 4px var(--space-lg); - border-bottom: 1px solid var(--border-light); + flex-direction: column; + gap: 6px; + margin-bottom: 14px; + padding: 0 14px; } - -.js-result-count { +.settings-group:first-child { padding-top: 14px; } +.settings-group label { font-size: var(--font-size-xs); font-weight: 600; color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; } - -.js-result-hint { - font-size: 10px; - color: var(--text-muted); -} - -.js-grid { - width: 100%; +.settings-group select, +.settings-group input[type="text"] { + background: var(--surface-input); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + font-size: var(--font-size-sm); + font-family: var(--font-family); + padding: 5px 8px; } -/* --- Job Detail Panel --- */ +/* --- Job/Tech detail panel --- */ .jd-content { padding: var(--space-lg); overflow-y: auto; flex: 1; } - .jd-header-row { display: flex; align-items: center; gap: var(--space-md); margin-bottom: var(--space-lg); + padding-bottom: 10px; + border-bottom: 1px solid var(--border-light); } - .jd-type { text-transform: capitalize; font-size: var(--font-size-sm); color: var(--text-secondary); } - .jd-priority { font-family: var(--font-mono); - font-size: var(--font-size-xs); - color: var(--text-muted); + font-size: var(--font-size-sm); + font-weight: 600; margin-left: auto; } - -.jd-section { - margin-bottom: var(--space-lg); -} - +.jd-section { margin-bottom: var(--space-lg); } .jd-section-title { font-size: 10px; font-weight: 600; @@ -1661,129 +983,172 @@ body { padding-bottom: 2px; border-bottom: 1px solid var(--border-light); } - .jd-field { display: flex; align-items: baseline; gap: var(--space-md); padding: 2px 0; } - .jd-label { font-size: var(--font-size-xs); color: var(--text-muted); - width: 65px; + width: 90px; flex-shrink: 0; } - .jd-value { font-size: var(--font-size-sm); color: var(--text-primary); } - -.jd-mono { - font-family: var(--font-mono); - font-size: var(--font-size-xs); -} - -.jd-muted { - color: var(--text-muted); - font-size: var(--font-size-xs); -} - +.jd-mono { font-family: var(--font-mono); font-size: var(--font-size-xs); } +.jd-muted { color: var(--text-muted); font-size: var(--font-size-xs); } .jd-skills { display: flex; flex-wrap: wrap; gap: 3px; padding: 2px 0; } - .jd-text { font-size: var(--font-size-sm); color: var(--text-secondary); line-height: 1.4; padding: 2px 0; } - -.jd-text--warning { - color: var(--color-warning); -} - +.jd-text--warning { color: var(--color-warning); } .jd-timestamps { opacity: 0.7; + padding-top: 8px; + border-top: 1px dashed var(--border-light); } -/* --- Small button variant --- */ -.btn--sm { - height: 22px; - padding: 0 8px; - font-size: var(--font-size-xs); -} - -/* --- Override Warning Modal --- */ -.override-overlay { +/* --- Context menu --- */ +.ctx-menu { position: fixed; - inset: 0; - background: var(--surface-overlay); - z-index: 2500; - display: flex; - align-items: center; - justify-content: center; -} - -.override-modal { - background: var(--surface-panel); + z-index: 2000; + min-width: 200px; + background: var(--surface-header); border: 1px solid var(--border-color); border-radius: var(--border-radius); - box-shadow: 0 12px 48px rgba(0, 0, 0, 0.6); - padding: var(--space-xl); - max-width: 400px; - width: 90%; + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5); + padding: 4px 0; + font-size: var(--font-size-sm); } - -.override-title { - font-size: var(--font-size-lg); +.ctx-menu-header { + padding: 4px 12px; + font-size: var(--font-size-xs); font-weight: 600; - color: var(--color-warning); - margin-bottom: var(--space-md); -} - -.override-body { - font-size: var(--font-size-sm); color: var(--text-secondary); - line-height: 1.5; - margin-bottom: var(--space-xl); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } - -.override-issue { +.ctx-menu-status { padding: 2px 12px 4px; } +.ctx-menu-label { + padding: 4px 12px 2px; + font-size: 10px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} +.ctx-menu-sep { + height: 1px; + margin: 3px 0; + background: var(--border-light); +} +.ctx-menu-item { display: flex; align-items: center; - gap: var(--space-md); - padding: 3px 0; - font-size: var(--font-size-xs); + width: 100%; + padding: 5px 12px; + border: none; + background: none; + color: var(--text-primary); + font-family: var(--font-family); + font-size: var(--font-size-sm); + cursor: pointer; + text-align: left; + gap: 6px; + white-space: nowrap; + position: relative; } - -.override-x { - color: var(--color-danger); - font-weight: 700; +.ctx-menu-item:hover { background: var(--surface-row-hover); } +.ctx-menu-item:disabled { opacity: 0.35; cursor: not-allowed; } +.ctx-menu-item:disabled:hover { background: none; } +.ctx-menu-item--danger { color: var(--color-danger); } +.ctx-menu-item--danger:hover { background: var(--color-danger-dim); } +.ctx-menu-item--parent { justify-content: space-between; } +.ctx-arrow { + color: var(--text-muted); + font-size: 10px; + margin-left: auto; } - -.override-check { - color: var(--color-success); +.ctx-dot { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; +} +.ctx-dot--success { background: var(--color-success); } +.ctx-dot--warning { background: var(--color-warning); } +.ctx-dot--muted { background: var(--text-muted); } +.ctx-dot--purple { background: var(--color-purple); } +.ctx-dot--info { background: var(--color-info); } +.ctx-dot--danger { background: var(--color-danger); } +.ctx-submenu { + position: absolute; + left: 100%; + top: -4px; + min-width: 200px; + max-height: 320px; + overflow-y: auto; + background: var(--surface-header); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5); + padding: 4px 0; +} +.ctx-menu-empty { + padding: 8px 12px; + color: var(--text-muted); + font-size: var(--font-size-xs); + font-style: italic; } -.override-actions { - display: flex; - justify-content: flex-end; - gap: var(--space-md); +/* --- Drag --- */ +body.dragging-active { user-select: none; cursor: grabbing !important; } +body.dragging-active * { cursor: grabbing !important; } +.drag-ghost { + position: fixed; + z-index: 3000; + padding: 4px 10px; + background: var(--surface-header); + border: 1px solid var(--color-accent); + border-radius: var(--border-radius); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + font-size: var(--font-size-xs); + color: var(--color-accent); + white-space: nowrap; + pointer-events: none; + font-weight: 500; +} +.drop-target-hover, +.drop-target.drag-over { + outline: 1px solid var(--color-accent) !important; + outline-offset: -1px; + background: var(--surface-row-selected) !important; } -/* --- Simulation Bar --- */ -.sim-bar { - height: 32px; - min-height: 32px; +/* --- Sim bar --- */ +.sim-bar-row { + height: var(--sim-bar-height); + min-height: var(--sim-bar-height); background: var(--surface-panel-alt); border-bottom: 1px solid var(--border-color); +} +.sim-row, +.sim-bar { + height: 100%; display: flex; align-items: center; padding: 0 var(--space-lg); @@ -1793,8 +1158,10 @@ body { overflow-y: hidden; scrollbar-width: none; } +.sim-row::-webkit-scrollbar, .sim-bar::-webkit-scrollbar { display: none; } +.demo-badge, .sim-demo-badge { font-size: 9px; font-weight: 800; @@ -1808,6 +1175,7 @@ body { flex-shrink: 0; } +.sim-state, .sim-status { font-size: 10px; font-weight: 700; @@ -1816,18 +1184,25 @@ body { border-radius: 3px; font-family: var(--font-mono); flex-shrink: 0; + border: 1px solid transparent; } +.sim-state--simulated, .sim-status--running { color: var(--color-success); background: var(--color-success-dim); + border-color: var(--color-success); } +.sim-state--paused, .sim-status--paused { color: var(--color-warning); background: var(--color-warning-dim); + border-color: var(--color-warning); } +.sim-state--real, .sim-status--stopped { color: var(--text-muted); background: rgba(122, 126, 136, 0.12); + border-color: var(--border-color); } .sim-clock { @@ -1839,14 +1214,12 @@ body { min-width: 50px; flex-shrink: 0; } - .sim-controls { display: flex; align-items: center; gap: var(--space-sm); flex-shrink: 0; } - .sim-speed { display: flex; align-items: center; @@ -1854,13 +1227,13 @@ body { margin-left: auto; flex-shrink: 0; } - .sim-speed-label { font-size: var(--font-size-xs); color: var(--text-muted); } - -.sim-speed-select { +.sim-speed select, +.sim-speed-select, +.status-filter { background: var(--surface-input); border: 1px solid var(--border-color); border-radius: var(--border-radius); @@ -1870,48 +1243,145 @@ body { padding: 2px 6px; cursor: pointer; } +.sim-speed select:disabled, .sim-speed-select:disabled { opacity: 0.4; cursor: not-allowed; } -/* --- Overrun row colors --- */ -.ag-theme-fieldopt .row--overrun-yellow { - background: var(--color-warning-dim) !important; +/* --- Modals (override warning + autoroute confirm) --- */ +.modal-backdrop, +.override-overlay { + position: fixed; + inset: 0; + background: var(--surface-overlay); + z-index: 2500; + display: flex; + align-items: center; + justify-content: center; +} +.modal, +.override-modal { + background: var(--surface-panel); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + box-shadow: 0 12px 48px rgba(0, 0, 0, 0.6); + padding: var(--space-xl); + max-width: 440px; + width: 90%; +} +.modal h3 { + font-size: var(--font-size-lg); + font-weight: 600; + color: var(--color-warning); + margin-bottom: var(--space-md); +} +.modal p { + font-size: var(--font-size-sm); + color: var(--text-secondary); + line-height: 1.5; + margin-bottom: var(--space-md); +} +.modal .issues { list-style: none; margin: 12px 0; } +.modal .issues li { + padding: 4px 0; + font-family: var(--font-mono); + font-size: var(--font-size-sm); +} +.modal .issues li.ok { color: var(--color-success); } +.modal .issues li.fail { color: var(--color-danger); } +.modal-actions, +.override-actions { + display: flex; + justify-content: flex-end; + gap: var(--space-md); + margin-top: 12px; } -.ag-theme-fieldopt .row--overrun-yellow:hover { - background: rgba(229, 168, 52, 0.22) !important; +.override-title { + font-size: var(--font-size-lg); + font-weight: 600; + color: var(--color-warning); + margin-bottom: var(--space-md); } -.ag-theme-fieldopt .row--overrun-red { - background: var(--color-danger-dim) !important; +.override-body { + font-size: var(--font-size-sm); + color: var(--text-secondary); + line-height: 1.5; + margin-bottom: var(--space-xl); } -.ag-theme-fieldopt .row--overrun-red:hover { - background: rgba(224, 85, 85, 0.22) !important; +.override-issue { + display: flex; + align-items: center; + gap: var(--space-md); + padding: 3px 0; + font-size: var(--font-size-xs); } +.override-x { color: var(--color-danger); font-weight: 700; } +.override-check { color: var(--color-success); } -/* Past-time-frame indicator: job's customer window has closed and the - job isn't completed/cancelled yet. Distinct from overrun (which is - in-progress past estimated_duration). */ -.ag-theme-fieldopt .row--overdue { - box-shadow: inset 3px 0 0 var(--color-danger); - background: rgba(224, 85, 85, 0.08) !important; +/* --- Toasts --- */ +#toast-host { + position: fixed; + bottom: 16px; + right: 16px; + z-index: 3000; + display: flex; + flex-direction: column-reverse; + gap: 6px; + pointer-events: none; +} +.toast { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + background: var(--surface-header); + border: 1px solid var(--border-color); + border-left: 3px solid var(--color-accent); + border-radius: var(--border-radius); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); + font-size: var(--font-size-sm); + color: var(--text-primary); + white-space: nowrap; + animation: toast-in 0.2s ease, toast-out 0.3s ease 3s forwards; +} +.toast--success { border-left-color: var(--color-success); } +.toast--warning { border-left-color: var(--color-warning); } +.toast--error, +.toast--danger { border-left-color: var(--color-danger); } +.toast--info { border-left-color: var(--color-info); } +@keyframes toast-in { + from { transform: translateY(8px); opacity: 0; } } -.ag-theme-fieldopt .row--overdue:hover { - background: rgba(224, 85, 85, 0.14) !important; +@keyframes toast-out { + to { transform: translateY(8px); opacity: 0; } } -/* Demo playback lock — only blocks assignment-changing UI. Tech/job grids, - timeline, map, refresh stay interactive so the dispatcher can watch. - Block list (handled via `disabled` props + handler guards): - - Filter / Personnel / Job Search / Auto-Route buttons - - Drag-drop assign + context-menu assign + override-warning confirm -*/ -.app-shell.demo-locked::after { +/* --- Map popup (legacy in-page; primary map opens in own window) --- */ +.map-dot span { + display: block; + border-radius: 50%; + border: 1.5px solid #0f1115; + box-shadow: 0 0 0 1px rgba(255,255,255,0.15); +} +.leaflet-tooltip { font-size: var(--font-size-xs); } +.map-window .leaflet-container { background: #1a1d23; } + +/* --- Density modes --- */ +body[data-density="compact"] .grid th, +body[data-density="compact"] .grid td { padding: 3px 8px; font-size: var(--font-size-xs); } +body[data-density="comfortable"] .grid th, +body[data-density="comfortable"] .grid td { padding: 9px 14px; font-size: var(--font-size-base); } + +/* --- Demo playback lock --- */ +body.demo-locked .job-row { cursor: not-allowed; } +.app-shell.demo-locked::after, +body.demo-locked::after { content: 'DEMO PLAYING — controls locked'; position: fixed; bottom: 12px; right: 12px; - background: var(--color-warning, #d99a00); + background: var(--color-warning); color: #000; padding: 6px 12px; border-radius: 6px; @@ -1922,45 +1392,37 @@ body { pointer-events: none; } -/* btn--success + btn--danger variants */ -.btn--success { - border-color: var(--color-success); - color: var(--color-success); -} -.btn--success:hover { - background: var(--color-success-dim); +/* --- Loading --- */ +.loading-screen { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + background: var(--surface-app); + color: var(--text-secondary); + font-size: var(--font-size-lg); + gap: var(--space-md); } -.btn--danger { - border-color: var(--color-danger); - color: var(--color-danger); +.loading-spinner { + width: 18px; + height: 18px; + border: 2px solid var(--border-color); + border-top-color: var(--color-accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; } -.btn--danger:hover { - background: var(--color-danger-dim); +@keyframes spin { + to { transform: rotate(360deg); } } /* ================================================================ - Mobile / Touch Responsive + Mobile / Touch (≤768px) ================================================================ */ - @media (max-width: 768px) { - /* Bigger touch targets on buttons */ - .btn { - padding: 8px 12px; - min-height: 40px; - font-size: var(--font-size-sm); - } - - /* Hide text labels — icons only */ - .btn-label { - display: none; - } - - /* Hide version badge */ - .header-version { - display: none; - } + .btn { padding: 8px 12px; min-height: 40px; font-size: var(--font-size-sm); } + .btn-label { display: none; } + .header-version { display: none; } - /* Header: reduce padding, allow wrap */ .header-bar { padding: 4px var(--space-md); gap: var(--space-sm); @@ -1968,14 +1430,8 @@ body { height: auto; min-height: var(--header-height); } + .header-actions { gap: var(--space-sm); flex-wrap: wrap; margin-left: 0; } - .header-actions { - gap: var(--space-sm); - flex-wrap: wrap; - margin-left: 0; - } - - /* Dashboard bar — scrollable row on mobile */ .dashboard-bar { overflow-x: auto; overflow-y: hidden; @@ -1986,54 +1442,27 @@ body { } .dashboard-bar::-webkit-scrollbar { display: none; } - /* Larger status badge touch area in grids */ - .status-badge { - padding: 2px 8px; - font-size: 10px; - } - - /* Day picker buttons bigger */ - .day-picker-btn { - width: 32px; - height: 32px; - font-size: 14px; - } + .status-badge, .pill { padding: 2px 8px; font-size: 10px; } - .day-picker-date { + .day-btn { padding: 6px 10px; font-size: var(--font-size-sm); min-height: 32px; } - /* Pane headers bigger for touch */ - .pane-header { - height: 36px; - min-height: 36px; - padding: 0 var(--space-md); - } + .pane-header { height: 36px; min-height: 36px; padding: 0 var(--space-md); } - /* Divider — taller touch target */ - .split-divider { + .row-splitter, .split-divider { height: 10px; min-height: 10px; } - /* Calendar picker — wider on phone */ - .cal-picker { - min-width: 280px; - } - - .cal-day { - padding: 6px 2px; - font-size: var(--font-size-sm); - } - - .cal-today-btn { - padding: 6px 16px; - font-size: var(--font-size-xs); - } + .cal-picker { min-width: 280px; } + .cal-day { padding: 6px 2px; font-size: var(--font-size-sm); } + .cal-today-btn { padding: 6px 16px; font-size: var(--font-size-xs); } - /* All floating windows — full screen on mobile */ + /* Floating windows + map → full screen */ + .float-win, .fw-window, .map-window { position: fixed !important; @@ -2041,16 +1470,19 @@ body { left: 0 !important; width: 100dvw !important; height: 100dvh !important; + min-width: 0 !important; + min-height: 0 !important; + max-width: none !important; + max-height: none !important; border-radius: 0 !important; + resize: none !important; } - /* Disable resize handle on mobile */ .fw-resize, - .map-window-resize { - display: none; - } + .float-win-resize, + .map-window-resize { display: none; } - /* Bigger close buttons for touch */ + .float-win-close, .fw-close, .map-window-close { width: 36px; @@ -2058,7 +1490,7 @@ body { font-size: 20px; } - /* Taller titlebars */ + .float-win-head, .fw-titlebar, .map-window-titlebar { height: 44px; @@ -2066,15 +1498,16 @@ body { padding: 0 var(--space-md); } - /* Override modal — full width */ - .override-modal { - width: 95%; - max-width: none; - } + .modal, + .override-modal { width: 95%; max-width: none; } + + /* Search criteria: stack rows on narrow */ + .cr-row { flex-wrap: wrap; } + .cr-row label { flex: 1 1 45%; min-width: 0; } - /* Autoroute confirm modal */ - .autoroute-confirm { - width: 95%; - max-width: none; + /* Filter checks: single column */ + .filter-checks { + grid-template-columns: 1fr; + max-height: none; } } diff --git a/backend/static/vendor/README.md b/backend/static/vendor/README.md new file mode 100644 index 0000000..f4198d9 --- /dev/null +++ b/backend/static/vendor/README.md @@ -0,0 +1,26 @@ +# Vendored static deps + +These files are vendored on purpose — no npm, no CDN at runtime. +Pin a known-good version + SHA. Verify before committing. + +## htmx.min.js + +Path: `backend/static/vendor/htmx.min.js` + +Download (verify the URL on https://htmx.org before running): + +```bash +curl -fsSL https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js -o backend/static/vendor/htmx.min.js +``` + +Verify SHA-256 against https://htmx.org/docs/#installing or the npm registry's reported integrity for that exact version: + +```bash +shasum -a 256 backend/static/vendor/htmx.min.js +``` + +Commit the file. Future upgrades = bump version + re-verify SHA. No build step ever touches `node_modules`. + +## leaflet.js / leaflet.css + +Add when the map view is ported (later slice). Same pattern: download + SHA verify + commit. diff --git a/backend/static/vendor/htmx.min.js b/backend/static/vendor/htmx.min.js new file mode 100644 index 0000000..59937d7 --- /dev/null +++ b/backend/static/vendor/htmx.min.js @@ -0,0 +1 @@ +var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.4"};Q.onLoad=j;Q.process=kt;Q.on=ye;Q.off=be;Q.trigger=he;Q.ajax=Rn;Q.find=u;Q.findAll=x;Q.closest=g;Q.remove=z;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=$e;Q.defineExtension=Fn;Q.removeExtension=Bn;Q.logAll=V;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:le,canAccessLocalStorage:B,findThisElement:Se,filterValues:hn,swap:$e,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:o,getExpressionVars:En,getHeaders:fn,getInputValues:cn,getInternalData:ie,getSwapSpecification:gn,getTriggerSpecs:st,getTarget:Ee,makeFragment:P,mergeObjects:ce,makeSettleInfo:xn,oobSwap:He,querySelectorExt:ae,settleImmediately:Kt,shouldCancel:ht,triggerEvent:he,triggerErrorEvent:fe,withExtensions:Ft};const r=["get","post","put","delete","patch"];const H=r.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function m(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function o(e,t){while(e&&!t(e)){e=c(e)}return e||null}function i(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;o(t,function(e){return!!(r=i(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function T(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function q(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function A(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function N(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(N(e)){const t=A(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){O(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/]*)?>[\s\S]*?<\/head>/i,"");const n=T(t);let r;if(n==="html"){r=new DocumentFragment;const i=q(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=q(t);L(r,i.body);r.title=i.title}else{const i=q('");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function D(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function M(t){const n=[];if(t){for(let e=0;e=0}function le(e){return e.getRootNode({composed:true})===document}function F(e){return e.trim().split(/\s+/)}function ce(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function S(e){try{return JSON.parse(e)}catch(e){O(e);return null}}function B(){const e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function U(t){try{const e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function e(e){return vn(ne().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function u(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return u(ne(),e)}}function x(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return x(ne(),e)}}function E(){return window}function z(e,t){e=y(e);if(t){E().setTimeout(function(){z(e);e=null},t)}else{c(e).removeChild(e)}}function ue(e){return e instanceof Element?e:null}function $(e){return e instanceof HTMLElement?e:null}function J(e){return typeof e==="string"?e:null}function f(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function K(e,t,n){e=ue(y(e));if(!e){return}if(n){E().setTimeout(function(){K(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function G(e,t,n){let r=ue(y(e));if(!r){return}if(n){E().setTimeout(function(){G(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=y(e);e.classList.toggle(t)}function Z(e,t){e=y(e);se(e.parentElement.children,function(e){G(e,t)});K(ue(e),t)}function g(e,t){e=ue(y(e));if(e&&e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&ue(c(e)));return null}}function l(e,t){return e.substring(0,t.length)===t}function Y(e,t){return e.substring(e.length-t.length)===t}function ge(e){const t=e.trim();if(l(t,"<")&&Y(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function p(t,r,n){if(r.indexOf("global ")===0){return p(t,r.slice(7),true)}t=y(t);const o=[];{let t=0;let n=0;for(let e=0;e"){t--}}if(n0){const r=ge(o.shift());let e;if(r.indexOf("closest ")===0){e=g(ue(t),ge(r.substr(8)))}else if(r.indexOf("find ")===0){e=u(f(t),ge(r.substr(5)))}else if(r==="next"||r==="nextElementSibling"){e=ue(t).nextElementSibling}else if(r.indexOf("next ")===0){e=pe(t,ge(r.substr(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=ue(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=me(t,ge(r.substr(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=m(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const c=f(m(t,!!n));i.push(...M(c.querySelectorAll(e)))}return i}var pe=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=0;e=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ae(e,t){if(typeof e!=="string"){return p(e,t)[0]}else{return p(ne().body,e)[0]}}function y(e,t){if(typeof e==="string"){return u(f(t)||document,e)}else{return e}}function xe(e,t,n,r){if(k(t)){return{target:ne().body,event:J(e),listener:t,options:n}}else{return{target:y(e),event:J(t),listener:n,options:r}}}function ye(t,n,r,o){Vn(function(){const e=xe(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=k(n);return e?n:r}function be(t,n,r){Vn(function(){const e=xe(t,n,r);e.target.removeEventListener(e.event,e.listener)});return k(n)?n:r}const ve=ne().createElement("output");function we(e,t){const n=re(e,t);if(n){if(n==="this"){return[Se(e,t)]}else{const r=p(e,n);if(r.length===0){O('The selector "'+n+'" on '+t+" returned no matches!");return[ve]}else{return r}}}}function Se(e,t){return ue(o(e,function(e){return te(ue(e),t)!=null}))}function Ee(e){const t=re(e,"hx-target");if(t){if(t==="this"){return Se(e,"hx-target")}else{return ae(e,t)}}else{const n=ie(e);if(n.boosted){return ne().body}else{return e}}}function Ce(t){const n=Q.config.attributesToSettle;for(let e=0;e0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=p(t,n,false);if(r){se(r,function(e){let t;const n=o.cloneNode(true);t=ne().createDocumentFragment();t.appendChild(n);if(!Re(s,e)){t=f(n)}const r={shouldSwap:true,target:e,fragment:t};if(!he(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){qe(t);_e(s,e,e,t,i);Te()}se(i.elts,function(e){he(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(ne().body,"htmx:oobErrorNoTarget",{content:o})}return e}function Te(){const e=u("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=u("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function qe(e){se(x(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=te(e,"id");const n=ne().getElementById(t);if(n!=null){if(e.moveBefore){let e=u("#--htmx-preserve-pantry--");if(e==null){ne().body.insertAdjacentHTML("afterend","
");e=u("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function Le(l,e,c){se(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=f(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);c.tasks.push(function(){Oe(t,s)})}}})}function Ae(e){return function(){G(e,Q.config.addedClass);kt(ue(e));Ne(f(e));he(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=$(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function a(e,t,n,r){Le(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;K(ue(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ae(o))}}}function Ie(e,t){let n=0;while(n0}function $e(e,t,r,o){if(!o){o={}}e=y(e);const i=o.contextElement?m(o.contextElement,false):ne();const n=document.activeElement;let s={};try{s={elt:n,start:n?n.selectionStart:null,end:n?n.selectionEnd:null}}catch(e){}const l=xn(e);if(r.swapStyle==="textContent"){e.textContent=t}else{let n=P(t);l.title=n.title;if(o.selectOOB){const u=o.selectOOB.split(",");for(let t=0;t0){E().setTimeout(c,r.settleDelay)}else{c()}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=S(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(D(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}he(n,i,e)}}}else{const s=r.split(",");for(let e=0;e0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=vn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(ne().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function C(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=C(e,Qe).trim();e.shift()}else{t=C(e,v)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{C(o,w);const l=o.length;const c=C(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};C(o,w);u.pollInterval=d(C(o,/[,\[\s]/));C(o,w);var i=nt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const a={trigger:c};var i=nt(e,o,"event");if(i){a.eventFilter=i}C(o,w);while(o.length>0&&o[0]!==","){const f=o.shift();if(f==="changed"){a.changed=true}else if(f==="once"){a.once=true}else if(f==="consume"){a.consume=true}else if(f==="delay"&&o[0]===":"){o.shift();a.delay=d(C(o,v))}else if(f==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=C(o,v);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=rt(o);if(h.length>0){s+=" "+h}}}a.from=s}else if(f==="target"&&o[0]===":"){o.shift();a.target=rt(o)}else if(f==="throttle"&&o[0]===":"){o.shift();a.throttle=d(C(o,v))}else if(f==="queue"&&o[0]===":"){o.shift();a.queue=C(o,v)}else if(f==="root"&&o[0]===":"){o.shift();a[f]=rt(o)}else if(f==="threshold"&&o[0]===":"){o.shift();a[f]=C(o,v)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}r.push(a)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=te(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){ie(e).cancelled=true}function ct(e,t,n){const r=ie(e);r.timeout=E().setTimeout(function(){if(le(e)&&r.cancelled!==true){if(!gt(n,e,Mt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ut(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function at(e){return g(e,Q.config.disableSelector)}function ft(t,n,e){if(t instanceof HTMLAnchorElement&&ut(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(o==null||o===""){o=ne().location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){pt(t,function(e,t){const n=ue(e);if(at(n)){b(n);return}de(r,o,n,t)},n,e,true)})}}function ht(e,t){const n=ue(t);if(!n){return false}if(e.type==="submit"||e.type==="click"){if(n.tagName==="FORM"){return true}if(h(n,'input[type="submit"], button')&&(h(n,"[form]")||g(n,"form")!==null)){return true}if(n instanceof HTMLAnchorElement&&n.href&&(n.getAttribute("href")==="#"||n.getAttribute("href").indexOf("#")!==0)){return true}}return false}function dt(e,t){return ie(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function gt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(ne().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function pt(l,c,e,u,a){const f=ie(l);let t;if(u.from){t=p(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in f)){f.lastValue=new WeakMap}t.forEach(function(e){if(!f.lastValue.has(u)){f.lastValue.set(u,new WeakMap)}f.lastValue.get(u).set(e,e.value)})}se(t,function(i){const s=function(e){if(!le(l)){i.removeEventListener(u.trigger,s);return}if(dt(l,e)){return}if(a||ht(e,l)){e.preventDefault()}if(gt(u,l,e)){return}const t=ie(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!h(ue(e.target),u.target)){return}}if(u.once){if(f.triggeredOnce){return}else{f.triggeredOnce=true}}if(u.changed){const n=event.target;const r=n.value;const o=f.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(f.delayed){clearTimeout(f.delayed)}if(f.throttle){return}if(u.throttle>0){if(!f.throttle){he(l,"htmx:trigger");c(l,e);f.throttle=E().setTimeout(function(){f.throttle=null},u.throttle)}}else if(u.delay>0){f.delayed=E().setTimeout(function(){he(l,"htmx:trigger");c(l,e)},u.delay)}else{he(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let mt=false;let xt=null;function yt(){if(!xt){xt=function(){mt=true};window.addEventListener("scroll",xt);window.addEventListener("resize",xt);setInterval(function(){if(mt){mt=false;se(ne().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&X(e)){e.setAttribute("data-hx-revealed","true");const t=ie(e);if(t.initHash){he(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){he(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;he(e,"htmx:trigger");t(e)}};if(r>0){E().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;se(r,function(r){if(s(t,"hx-"+r)){const o=te(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ue(e);if(g(n,Q.config.disableSelector)){b(n);return}de(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){yt();pt(r,n,t,e);bt(ue(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ae(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e0){t.polling=true;ct(ue(r),n,e)}else{pt(r,n,t,e)}}function Et(e){const t=ue(e);if(!t){return false}const n=t.attributes;for(let e=0;e", "+e).join(""));return o}else{return[]}}function Tt(e){const t=g(ue(e.target),"button, input[type='submit']");const n=Lt(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=Lt(e);if(t){t.lastButtonClicked=null}}function Lt(e){const t=g(ue(e.target),"button, input[type='submit']");if(!t){return}const n=y("#"+ee(t,"form"),t.getRootNode())||g(t,"form");if(!n){return}return ie(n)}function At(e){e.addEventListener("click",Tt);e.addEventListener("focusin",Tt);e.addEventListener("focusout",qt)}function Nt(t,e,n){const r=ie(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){vn(t,function(){if(at(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function It(t){ke(t);for(let e=0;eQ.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(ne().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Vt(t){if(!B()){return null}t=U(t);const n=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e=200&&this.status<400){he(ne().body,"htmx:historyCacheMissLoad",i);const e=P(this.response);const t=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;const n=Ut();const r=xn(n);kn(e.title);qe(e);Ve(n,t,r);Te();Kt(r.tasks);Bt=o;he(ne().body,"htmx:historyRestore",{path:o,cacheMiss:true,serverResponse:this.response})}else{fe(ne().body,"htmx:historyCacheMissLoadError",i)}};e.send()}function Wt(e){zt();e=e||location.pathname+location.search;const t=Vt(e);if(t){const n=P(t.content);const r=Ut();const o=xn(r);kn(t.title);qe(n);Ve(r,n,o);Te();Kt(o.tasks);E().setTimeout(function(){window.scrollTo(0,t.scroll)},0);Bt=e;he(ne().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{Gt(e)}}}function Zt(e){let t=we(e,"hx-indicator");if(t==null){t=[e]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function Yt(e){let t=we(e,"hx-disabled-elt");if(t==null){t=[]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")});return t}function Qt(e,t){se(e.concat(t),function(e){const t=ie(e);t.requestCount=(t.requestCount||1)-1});se(e,function(e){const t=ie(e);if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});se(t,function(e){const t=ie(e);if(t.requestCount===0){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function en(t,n){for(let e=0;en.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);se(e,e=>r.append(t,e))}}function on(t,n,r,o,i){if(o==null||en(t,o)){return}else{t.push(o)}if(tn(o)){const s=ee(o,"name");let e=o.value;if(o instanceof HTMLSelectElement&&o.multiple){e=M(o.querySelectorAll("option:checked")).map(function(e){return e.value})}if(o instanceof HTMLInputElement&&o.files){e=M(o.files)}nn(s,e,n);if(i){sn(o,r)}}if(o instanceof HTMLFormElement){se(o.elements,function(e){if(t.indexOf(e)>=0){rn(e.name,e.value,n)}else{t.push(e)}if(i){sn(e,r)}});new FormData(o).forEach(function(e,t){if(e instanceof File&&e.name===""){return}nn(t,e,n)})}}function sn(e,t){const n=e;if(n.willValidate){he(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});he(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function ln(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function cn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=ie(e);if(s.lastButtonClicked&&!le(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||te(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){on(n,o,i,g(e,"form"),l)}on(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const u=s.lastButtonClicked||e;const a=ee(u,"name");nn(a,u.value,o)}const c=we(e,"hx-include");se(c,function(e){on(n,r,i,ue(e),l);if(!h(e,"form")){se(f(e).querySelectorAll(ot),function(e){on(n,r,i,e,l)})}});ln(r,o);return{errors:i,formData:r,values:An(r)}}function un(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function an(e){e=qn(e);let n="";e.forEach(function(e,t){n=un(n,t,e)});return n}function fn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":ne().location.href};bn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(ie(e).boosted){r["HX-Boosted"]="true"}return r}function hn(n,e){const t=re(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){se(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;se(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function dn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function gn(e,t){const n=t||re(e,"hx-swap");const r={swapStyle:ie(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ie(e).boosted&&!dn(e)){r.show="top"}if(n){const s=F(n);if(s.length>0){for(let e=0;e0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const a=l.slice(5);var o=a.split(":");const f=o.pop();var i=o.length>0?o.join(":"):null;r.show=f;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{O("Unknown modifier in hx-swap: "+l)}}}}return r}function pn(e){return re(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function mn(t,n,r){let o=null;Ft(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(pn(n)){return ln(new FormData,qn(r))}else{return an(r)}}}function xn(e){return{tasks:[],elts:[e]}}function yn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ue(ae(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ue(ae(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function bn(r,e,o,i){if(i==null){i={}}if(r==null){return i}const s=te(r,e);if(s){let e=s.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=vn(r,function(){return Function("return ("+e+")")()},{})}else{n=S(e)}for(const l in n){if(n.hasOwnProperty(l)){if(i[l]==null){i[l]=n[l]}}}}return bn(ue(c(r)),e,o,i)}function vn(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function wn(e,t){return bn(e,"hx-vars",true,t)}function Sn(e,t){return bn(e,"hx-vals",false,t)}function En(e){return ce(wn(e),Sn(e))}function Cn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function On(t){if(t.responseURL&&typeof URL!=="undefined"){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(ne().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function R(e,t){return t.test(e.getAllResponseHeaders())}function Rn(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return de(t,n,null,null,{targetOverride:y(r)||ve,returnPromise:true})}else{let e=y(r.target);if(r.target&&!e||r.source&&!e&&!y(r.source)){e=ve}return de(t,n,y(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return de(t,n,null,null,{returnPromise:true})}}function Hn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function Tn(e,t,n){let r;let o;if(typeof URL==="function"){o=new URL(t,document.location.href);const i=document.location.origin;r=i===o.origin}else{o=t;r=l(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!r){return false}}return he(e,"htmx:validateUrl",ce({url:o,sameHost:r},n))}function qn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Ln(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function An(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}else{return e[t]}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Ln(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function de(t,n,r,o,i,D){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ne().body}const M=i.handler||Dn;const X=i.select||null;if(!le(r)){oe(s);return e}const c=i.targetOverride||ue(Ee(r));if(c==null||c==ve){fe(r,"htmx:targetError",{target:te(r,"hx-target")});oe(l);return e}let u=ie(r);const a=u.lastButtonClicked;if(a){const L=ee(a,"formaction");if(L!=null){n=L}const A=ee(a,"formmethod");if(A!=null){if(A.toLowerCase()!=="dialog"){t=A}}}const f=re(r,"hx-confirm");if(D===undefined){const K=function(e){return de(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:f};if(he(r,"htmx:confirm",G)===false){oe(s);return e}}let h=r;let d=re(r,"hx-sync");let g=null;let F=false;if(d){const N=d.split(":");const I=N[0].trim();if(I==="this"){h=Se(r,"hx-sync")}else{h=ue(ae(r,I))}d=(N[1]||"drop").trim();u=ie(h);if(d==="drop"&&u.xhr&&u.abortable!==true){oe(s);return e}else if(d==="abort"){if(u.xhr){oe(s);return e}else{F=true}}else if(d==="replace"){he(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");g=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){he(h,"htmx:abort")}else{if(g==null){if(o){const P=ie(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){g=P.triggerSpec.queue}}if(g==null){g="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(g==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="all"){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){de(t,n,r,o,i)})}oe(s);return e}}const p=new XMLHttpRequest;u.xhr=p;u.abortable=F;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const B=re(r,"hx-prompt");if(B){var x=prompt(B);if(x===null||!he(r,"htmx:prompt",{prompt:x,target:c})){oe(s);m();return e}}if(f&&!D){if(!confirm(f)){oe(s);m();return e}}let y=fn(r,c,x);if(t!=="get"&&!pn(r)){y["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){y=ce(y,i.headers)}const U=cn(r,t);let b=U.errors;const j=U.formData;if(i.values){ln(j,qn(i.values))}const V=qn(En(r));const v=ln(j,V);let w=hn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(c,"id")||"true")}if(n==null||n===""){n=ne().location.href}const S=bn(r,"hx-request");const _=ie(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:_,useUrlParams:E,formData:w,parameters:An(w),unfilteredFormData:v,unfilteredParameters:An(v),headers:y,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!he(r,"htmx:configRequest",C)){oe(s);m();return e}n=C.path;t=C.verb;y=C.headers;w=qn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){he(r,"htmx:validation:halted",C);oe(s);m();return e}const z=n.split("#");const $=z[0];const O=z[1];let R=n;if(E){R=$;const Z=!w.keys().next().done;if(Z){if(R.indexOf("?")<0){R+="?"}else{R+="&"}R+=an(w);if(O){R+="#"+O}}}if(!Tn(r,R,C)){fe(r,"htmx:invalidPath",C);oe(l);return e}p.open(t.toUpperCase(),R,true);p.overrideMimeType("text/html");p.withCredentials=C.withCredentials;p.timeout=C.timeout;if(S.noHeaders){}else{for(const k in y){if(y.hasOwnProperty(k)){const Y=y[k];Cn(p,k,Y)}}}const H={xhr:p,target:c,requestConfig:C,etc:i,boosted:_,select:X,pathInfo:{requestPath:n,finalRequestPath:R,responsePath:null,anchor:O}};p.onload=function(){try{const t=Hn(r);H.pathInfo.responsePath=On(p);M(r,H);if(H.keepIndicators!==true){Qt(T,q)}he(r,"htmx:afterRequest",H);he(r,"htmx:afterOnLoad",H);if(!le(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(le(n)){e=n}}if(e){he(e,"htmx:afterRequest",H);he(e,"htmx:afterOnLoad",H)}}oe(s);m()}catch(e){fe(r,"htmx:onLoadError",ce({error:e},H));throw e}};p.onerror=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendError",H);oe(l);m()};p.onabort=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendAbort",H);oe(l);m()};p.ontimeout=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:timeout",H);oe(l);m()};if(!he(r,"htmx:beforeRequest",H)){oe(s);m();return e}var T=Zt(r);var q=Yt(r);se(["loadstart","loadend","progress","abort"],function(t){se([p,p.upload],function(e){e.addEventListener(t,function(e){he(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});he(r,"htmx:beforeSend",H);const J=E?null:mn(p,r,w);p.send(J);return e}function Nn(e,t){const n=t.xhr;let r=null;let o=null;if(R(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(R(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(R(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=re(e,"hx-push-url");const c=re(e,"hx-replace-url");const u=ie(e).boosted;let a=null;let f=null;if(l){a="push";f=l}else if(c){a="replace";f=c}else if(u){a="push";f=s||i}if(f){if(f==="false"){return{}}if(f==="true"){f=s||i}if(t.pathInfo.anchor&&f.indexOf("#")===-1){f=f+"#"+t.pathInfo.anchor}return{type:a,path:f}}else{return{}}}function In(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Pn(e){for(var t=0;t0){E().setTimeout(e,x.swapDelay)}else{e()}}if(f){fe(o,"htmx:responseError",ce({error:"Response Status Error Code "+s.status+" from "+i.pathInfo.requestPath},i))}}const Mn={};function Xn(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function Fn(e,t){if(t.init){t.init(n)}Mn[e]=ce(Xn(),t)}function Bn(e){delete Mn[e]}function Un(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=te(e,"hx-ext");if(t){se(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=Mn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return Un(ue(c(e)),n,r)}var jn=false;ne().addEventListener("DOMContentLoaded",function(){jn=true});function Vn(e){if(jn||ne().readyState==="complete"){e()}else{ne().addEventListener("DOMContentLoaded",e)}}function _n(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";ne().head.insertAdjacentHTML("beforeend"," ."+Q.config.indicatorClass+"{opacity:0} ."+Q.config.requestClass+" ."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ."+Q.config.requestClass+"."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ")}}function zn(){const e=ne().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function $n(){const e=zn();if(e){Q.config=ce(Q.config,e)}}Vn(function(){$n();_n();let e=ne().body;kt(e);const t=ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=ie(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){Wt();se(t,function(e){he(e,"htmx:restored",{document:ne(),triggerEvent:he})})}else{if(n){n(e)}}};E().setTimeout(function(){he(e,"htmx:load",{});e=null},0)});return Q}(); \ No newline at end of file diff --git a/backend/static/vendor/idiomorph-ext.min.js b/backend/static/vendor/idiomorph-ext.min.js new file mode 100644 index 0000000..c21426d --- /dev/null +++ b/backend/static/vendor/idiomorph-ext.min.js @@ -0,0 +1 @@ +var Idiomorph=function(){"use strict";let o=new Set;let n={morphStyle:"outerHTML",callbacks:{beforeNodeAdded:t,afterNodeAdded:t,beforeNodeMorphed:t,afterNodeMorphed:t,beforeNodeRemoved:t,afterNodeRemoved:t,beforeAttributeUpdated:t},head:{style:"merge",shouldPreserve:function(e){return e.getAttribute("im-preserve")==="true"},shouldReAppend:function(e){return e.getAttribute("im-re-append")==="true"},shouldRemove:t,afterHeadMorphed:t}};function e(e,t,n={}){if(e instanceof Document){e=e.documentElement}if(typeof t==="string"){t=y(t)}let l=M(t);let r=m(e,l,n);return a(e,l,r)}function a(r,i,o){if(o.head.block){let t=r.querySelector("head");let n=i.querySelector("head");if(t&&n){let e=c(n,t,o);Promise.all(e).then(function(){a(r,i,Object.assign(o,{head:{block:false,ignore:true}}))});return}}if(o.morphStyle==="innerHTML"){l(i,r,o);return r.children}else if(o.morphStyle==="outerHTML"||o.morphStyle==null){let e=N(i,r,o);let t=e?.previousSibling;let n=e?.nextSibling;let l=d(r,e,o);if(e){return k(t,l,n)}else{return[]}}else{throw"Do not understand how to morph style "+o.morphStyle}}function u(e,t){return t.ignoreActiveValue&&e===document.activeElement}function d(e,t,n){if(n.ignoreActive&&e===document.activeElement){}else if(t==null){if(n.callbacks.beforeNodeRemoved(e)===false)return e;e.remove();n.callbacks.afterNodeRemoved(e);return null}else if(!g(e,t)){if(n.callbacks.beforeNodeRemoved(e)===false)return e;if(n.callbacks.beforeNodeAdded(t)===false)return e;e.parentElement.replaceChild(t,e);n.callbacks.afterNodeAdded(t);n.callbacks.afterNodeRemoved(e);return t}else{if(n.callbacks.beforeNodeMorphed(e,t)===false)return e;if(e instanceof HTMLHeadElement&&n.head.ignore){}else if(e instanceof HTMLHeadElement&&n.head.style!=="morph"){c(t,e,n)}else{r(t,e,n);if(!u(e,n)){l(t,e,n)}}n.callbacks.afterNodeMorphed(e,t);return e}}function l(n,l,r){let i=n.firstChild;let o=l.firstChild;let a;while(i){a=i;i=a.nextSibling;if(o==null){if(r.callbacks.beforeNodeAdded(a)===false)return;l.appendChild(a);r.callbacks.afterNodeAdded(a);x(r,a);continue}if(b(a,o,r)){d(o,a,r);o=o.nextSibling;x(r,a);continue}let e=S(n,l,a,o,r);if(e){o=v(o,e,r);d(e,a,r);x(r,a);continue}let t=A(n,l,a,o,r);if(t){o=v(o,t,r);d(t,a,r);x(r,a);continue}if(r.callbacks.beforeNodeAdded(a)===false)return;l.insertBefore(a,o);r.callbacks.afterNodeAdded(a);x(r,a)}while(o!==null){let e=o;o=o.nextSibling;w(e,r)}}function f(e,t,n,l){if(e==="value"&&l.ignoreActiveValue&&t===document.activeElement){return true}return l.callbacks.beforeAttributeUpdated(e,t,n)===false}function r(t,n,l){let e=t.nodeType;if(e===1){const r=t.attributes;const i=n.attributes;for(const o of r){if(f(o.name,n,"update",l)){continue}if(n.getAttribute(o.name)!==o.value){n.setAttribute(o.name,o.value)}}for(let e=i.length-1;0<=e;e--){const a=i[e];if(f(a.name,n,"remove",l)){continue}if(!t.hasAttribute(a.name)){n.removeAttribute(a.name)}}}if(e===8||e===3){if(n.nodeValue!==t.nodeValue){n.nodeValue=t.nodeValue}}if(!u(n,l)){s(t,n,l)}}function i(t,n,l,r){if(t[l]!==n[l]){let e=f(l,n,"update",r);if(!e){n[l]=t[l]}if(t[l]){if(!e){n.setAttribute(l,t[l])}}else{if(!f(l,n,"remove",r)){n.removeAttribute(l)}}}}function s(n,l,r){if(n instanceof HTMLInputElement&&l instanceof HTMLInputElement&&n.type!=="file"){let e=n.value;let t=l.value;i(n,l,"checked",r);i(n,l,"disabled",r);if(!n.hasAttribute("value")){if(!f("value",l,"remove",r)){l.value="";l.removeAttribute("value")}}else if(e!==t){if(!f("value",l,"update",r)){l.setAttribute("value",e);l.value=e}}}else if(n instanceof HTMLOptionElement){i(n,l,"selected",r)}else if(n instanceof HTMLTextAreaElement&&l instanceof HTMLTextAreaElement){let e=n.value;let t=l.value;if(f("value",l,"update",r)){return}if(e!==t){l.value=e}if(l.firstChild&&l.firstChild.nodeValue!==e){l.firstChild.nodeValue=e}}}function c(e,t,l){let r=[];let i=[];let o=[];let a=[];let u=l.head.style;let d=new Map;for(const n of e.children){d.set(n.outerHTML,n)}for(const s of t.children){let e=d.has(s.outerHTML);let t=l.head.shouldReAppend(s);let n=l.head.shouldPreserve(s);if(e||n){if(t){i.push(s)}else{d.delete(s.outerHTML);o.push(s)}}else{if(u==="append"){if(t){i.push(s);a.push(s)}}else{if(l.head.shouldRemove(s)!==false){i.push(s)}}}}a.push(...d.values());p("to append: ",a);let f=[];for(const c of a){p("adding: ",c);let n=document.createRange().createContextualFragment(c.outerHTML).firstChild;p(n);if(l.callbacks.beforeNodeAdded(n)!==false){if(n.href||n.src){let t=null;let e=new Promise(function(e){t=e});n.addEventListener("load",function(){t()});f.push(e)}t.appendChild(n);l.callbacks.afterNodeAdded(n);r.push(n)}}for(const h of i){if(l.callbacks.beforeNodeRemoved(h)!==false){t.removeChild(h);l.callbacks.afterNodeRemoved(h)}}l.head.afterHeadMorphed(t,{added:r,kept:o,removed:i});return f}function p(){}function t(){}function h(e){let t={};Object.assign(t,n);Object.assign(t,e);t.callbacks={};Object.assign(t.callbacks,n.callbacks);Object.assign(t.callbacks,e.callbacks);t.head={};Object.assign(t.head,n.head);Object.assign(t.head,e.head);return t}function m(e,t,n){n=h(n);return{target:e,newContent:t,config:n,morphStyle:n.morphStyle,ignoreActive:n.ignoreActive,ignoreActiveValue:n.ignoreActiveValue,idMap:C(e,t),deadIds:new Set,callbacks:n.callbacks,head:n.head}}function b(e,t,n){if(e==null||t==null){return false}if(e.nodeType===t.nodeType&&e.tagName===t.tagName){if(e.id!==""&&e.id===t.id){return true}else{return L(n,e,t)>0}}return false}function g(e,t){if(e==null||t==null){return false}return e.nodeType===t.nodeType&&e.tagName===t.tagName}function v(t,e,n){while(t!==e){let e=t;t=t.nextSibling;w(e,n)}x(n,e);return e.nextSibling}function S(n,e,l,r,i){let o=L(i,l,e);let t=null;if(o>0){let e=r;let t=0;while(e!=null){if(b(l,e,i)){return e}t+=L(i,e,n);if(t>o){return null}e=e.nextSibling}}return t}function A(e,t,n,l,r){let i=l;let o=n.nextSibling;let a=0;while(i!=null){if(L(r,i,e)>0){return null}if(g(n,i)){return i}if(g(o,i)){a++;o=o.nextSibling;if(a>=2){return null}}i=i.nextSibling}return i}function y(n){let l=new DOMParser;let e=n.replace(/]*>|>)([\s\S]*?)<\/svg>/gim,"");if(e.match(/<\/html>/)||e.match(/<\/head>/)||e.match(/<\/body>/)){let t=l.parseFromString(n,"text/html");if(e.match(/<\/html>/)){t.generatedByIdiomorph=true;return t}else{let e=t.firstChild;if(e){e.generatedByIdiomorph=true;return e}else{return null}}}else{let e=l.parseFromString("","text/html");let t=e.body.querySelector("template").content;t.generatedByIdiomorph=true;return t}}function M(e){if(e==null){const t=document.createElement("div");return t}else if(e.generatedByIdiomorph){return e}else if(e instanceof Node){const t=document.createElement("div");t.append(e);return t}else{const t=document.createElement("div");for(const n of[...e]){t.append(n)}return t}}function k(e,t,n){let l=[];let r=[];while(e!=null){l.push(e);e=e.previousSibling}while(l.length>0){let e=l.pop();r.push(e);t.parentElement.insertBefore(e,t)}r.push(t);while(n!=null){l.push(n);r.push(n);n=n.nextSibling}while(l.length>0){t.parentElement.insertBefore(l.pop(),t.nextSibling)}return r}function N(e,t,n){let l;l=e.firstChild;let r=l;let i=0;while(l){let e=T(l,t,n);if(e>i){r=l;i=e}l=l.nextSibling}return r}function T(e,t,n){if(g(e,t)){return.5+L(n,e,t)}return 0}function w(e,t){x(t,e);if(t.callbacks.beforeNodeRemoved(e)===false)return;e.remove();t.callbacks.afterNodeRemoved(e)}function H(e,t){return!e.deadIds.has(t)}function E(e,t,n){let l=e.idMap.get(n)||o;return l.has(t)}function x(e,t){let n=e.idMap.get(t)||o;for(const l of n){e.deadIds.add(l)}}function L(e,t,n){let l=e.idMap.get(t)||o;let r=0;for(const i of l){if(H(e,i)&&E(e,i,n)){++r}}return r}function R(e,n){let l=e.parentElement;let t=e.querySelectorAll("[id]");for(const r of t){let t=r;while(t!==l&&t!=null){let e=n.get(t);if(e==null){e=new Set;n.set(t,e)}e.add(r.id);t=t.parentElement}}}function C(e,t){let n=new Map;R(e,n);R(t,n);return n}return{morph:e,defaults:n}}();(function(){function r(e){if(e==="morph"||e==="morph:outerHTML"){return{morphStyle:"outerHTML"}}else if(e==="morph:innerHTML"){return{morphStyle:"innerHTML"}}else if(e.startsWith("morph:")){return Function("return ("+e.slice(6)+")")()}}htmx.defineExtension("morph",{isInlineSwap:function(e){let t=r(e);return t.swapStyle==="outerHTML"||t.swapStyle==null},handleSwap:function(e,t,n){let l=r(e);if(l){return Idiomorph.morph(t,n.children,l)}}})})(); \ No newline at end of file diff --git a/backend/static/vendor/images/marker-icon-2x.png b/backend/static/vendor/images/marker-icon-2x.png new file mode 100644 index 0000000000000000000000000000000000000000..88f9e501888c9c6cb29ad340d9a888627dd1b6d8 GIT binary patch literal 2464 zcmV;R319Y!P)YnU^5s62$4H-fe}gSR(=wKRaTHh!@*b)YV6mo|a4Fn6Rgc&Rpk zvn_X|3VY?v=>nJ{slE^V1GaGWk}m@aIWGIpghbfPh8m@aIWEo_%AZI>==moIFVE^L=C zZJ91?mo03UEp3-BY?wBGur6$uD{Yr9Y?m%SHF8Fk1pc(Nva%QJ+{FLkalfypz3&M|||Fn`7|g3c~4(nXHKFmRnwn$J#_$xE8i z|Ns9!kC;(oC1qQk>LMp3_a2(odYyMT@>voX=UI)k>1cJdn;gjmJ-|6v4nb1Oryh)eQMwHP(i@!36%vGJyFK(JTj?Vb{{C=jx&)@1l zlFmnw%0`&bqruifkkHKC=vbiAM3&E`#Mv>2%tw;VK8?_|&E89cs{a1}$J*!f_xd-C z&F%B|oxRgPlh0F!txkxrQjNA`m9~?&&|jw4W0<`_iNHsX$VQXVK!B}Xkh4>av|f_8 zLY2?t?ejE=%(TnfV5iqOjm?d;&qI~ZGl|SzU77a)002XDQchC<95+*MjE@82?VLm= z3xf6%Vd@99z|q|-ua5l3kJxvZwan-8K1cPiwQAtlcNX~ZqLeoMB+a;7)WA|O#HOB% zg6SX;754xD1{Fy}K~#8Ntklac&zTpadXZ& zC*_=T&g7hfbI$R?v%9?sknIb97gJOJ=`-8YyS3ndqN+Jm+x33!p&Hc@@L$w))s2@N ztv~i}Emc?DykgwFWwma($8+~b>l?tqj$dh13R^nMZnva9 zn0Vflzv2Dvp`oVQw{Guby~i`JGbyBGTEC{y>yzCkg>K&CIeQ$u;lyQ+M{O~gEJ^)Z zrF3p)^>|uT;57}WY&IRwyOQ=dq%Az}_t=_hKowP!Z79q0;@Zu(SWEJJcHY+5T6I({ zw)wj*SNi4wrd+POUfZe4gF77vW?j zoFS}|r2n&$U9Y!S4VEOyN}OpZZi|?cr1VcE_tHsDQgp-ga(SwkBrkCm{|*-yb=}ZW zvcYvLvfA90TPn|!-TuYJV<6`}+RJeRgP3EA=qQcF9k0*#*{f&I_pjam%I6Dd#YE|G zqB!R}tW-K!wV1w+4JcFA_s6~=@9F&j8`u$-ifLN3vK;`lvaA-`jRn_}(8|)!3?-}I zvFi{H;@A$gEZYh?%|Qr_y#*UkOPjwiRCsJQ>mb6h5yGIk6C5_XA=8T?IBfm_?+P0; zhhUs)-(0R*H<&Kku(1>#cGtOpk&Z&kQcw&SJv-4VY<+;=8hYnoX zfNJMCa9)^5Z0;2dCUk;x-%#yS!I~Jr3pNuI!g_tHz!$hKwt1GL~sFvx)3u4TA zv>CLGdQtoZ7Du7ctJRfTqY;FPxs1G{ZJ?73D5J@OO{6BHcPbk{_mjg&p2QFeke%QI zlAJ-kvjuwy1<5D-6>su68A+i998aSZNnQX)+Q}6(GK-C%8G-!1bOJBONU{gT%IOOE z;Yk24YC@^lFW77>r6x7eS1Omc;8=GUp#&zLQ&L{ zv8$hGC`wp~$9pR>f%-_Ps3>YhzP(+vC(E*zr1CVO8ChN^MI-VGMX7+|(r!SGZ9gd5 zzO9sQd>sm|f1|X&oh=8lOzd6+ITvo zCXInR?>RZ#>Hb*PO=7dI!dZ(wY4O}ZGv zdfQFio7+0~PN*RFCZGM6@9-o~y*@?;k00NvOsw54t1^tt{*ATMs^2j}4Wp=4t3RH* z_+8b`F-{E=0sOgM<;VHTo!Ij3u zmmI`2?K7g(GOcGA)@h?$SW&pwHdtj1n57PLI8&6RHhx4R%Q7b z^JEqR)@06V!pbS*@D_ZyRMo_LlT}r{#sXOx4kM-V<_V{!5SSuM^SIVCA37|nY7LWQ zZA#B1h4l`6asz=Lvax_#GMRX|NF>=$=p{Qn0i@ExX1jGhy@B8a*_uR+ODEbVi8ObL zezG?azy>E~S~dl43&8<$(2H}P&*tuBdESUP83KQ?8B z?K(!uS>H1wlWQz;qOfB`T#TZ=EoSp~vZ5XtCvwm1h*Ex6mzTsn_y@_=xREIslV-%- zpdWkEzMjeNOGWrSM32gpBt27*O29NdhGzuDgYxcf`Jjjqw@B;Vmdb@fxdhCRi`Kg> zmUTr$=&@#i!%F4Q6mb&4QKfR^95KJ!<6~fqx-f^66AV!|ywG{6D^Vay-3b99>XOe# e-I|>x8~*?ZhF3snGbtJX0000cOl4 literal 0 HcmV?d00001 diff --git a/backend/static/vendor/images/marker-icon.png b/backend/static/vendor/images/marker-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..950edf24677ded147df13b26f91baa2b0fa70513 GIT binary patch literal 1466 zcmV;r1x5OaP)P001cn1^@s6z>|W`000GnNklGNuHDcIX17Zdjl&3`L?0sTjIws<{((Dh&g-s0<@jYQyl?D*X^?%13;ml^gy> ziMrY_^1WI=(g@LMizu=zCoA>C`6|QEq1eV92k*7m>G65*&@&6)aC&e}G zI)pf-Za|N`DT&Cn1J|o`19mumxW~hiKiKyc-P`S@q)rdTo84@QI@;0yXrG%9uhI>A zG5QHb6s4=<6xy{1 z@NMxEkryp{LS44%z$3lP^cX!9+2-;CTt3wM4(k*#C{aiIiLuB>jJj;KPhPzIC00bL zU3a#;aJld94lCW=`4&aAy8M7PY=HQ>O%$YEP4c4UY#CRxfgbE~(|uiI=YS8q;O9y6 zmIkXzR`}p7ti|PrM3a}WMnR=3NVnWdAAR>b9X@)DKL6=YsvmH%?I24wdq?Gh54_;# z$?_LvgjEdspdQlft#4CQ z`2Zyvy?*)N1Ftw|{_hakhG9WjS?Az@I@+IZ8JbWewR!XUK4&6346+d#~gsE0SY(LX8&JfY>Aj)RxGy96nwhs2rv zzW6pTnMpFkDSkT*a*6Dx|u@ds6ISVn0@^RmIsKZ5Y;bazbc;tTSq(kg(=481ODrPyNB6n z-$+U}(w$m6U6H$w17Bw+wDaFIe~GvNMYvnw31MpY0eQKT9l>SU``8k7w4)z!GZKMI z#_cEKq7k~i%nlK@6c-K?+R;B#5$?T#YpKD`t_4bAs^#E+@5QW$@OX3*`;(#{U^d-vY)&xEE>n5lYl&T?Amke9$Lam@{1K@O ze*LXqlKQHiv=gx+V^Cbb2?z@ISBQ*3amF;9UJ3SBg(N|710TLamQmYZ&Qjn2LuO<* zCZlB4n%@pc&7NNnY1}x+NWpHlq`OJEo|`aYN9<`RBUB+79g;>dgb6YlfN#kGL?lO_ z!6~M^7sOnbsUkKk<@Ysie&`G>ruxH&Mgy&8;i=A zB9OO!xR{AyODw>DS-q5YM{0ExFEAzt zm>RdS+ssW(-8|?xr0(?$vBVB*%(xDLtq3Hf0I5yFm<_g=W2`QWAax{1rWVH=I!VrP zs(rTFX@W#t$hXNvbgX`gK&^w_YD;CQ!B@e0QbLIWaKAXQe2-kkloo;{iF#6}z!4=W zi$giRj1{ zt;2w`VSCF#WE&*ev7jpsC=6175@(~nTE2;7M-L((0bH@yG}-TB$R~WXd?tA$s3|%y zA`9$sA(>F%J3ioz<-LJl*^o1|w84l>HBR`>3l9c8$5Xr@xCiIQ7{x$fMCzOk_-M=% z+{a_Q#;42`#KfUte@$NT77uaTz?b-fBe)1s5XE$yA79fm?KqM^VgLXD07*qoM6N<$ Ef<_J(9smFU literal 0 HcmV?d00001 diff --git a/backend/static/vendor/leaflet.css b/backend/static/vendor/leaflet.css new file mode 100644 index 0000000..2961b76 --- /dev/null +++ b/backend/static/vendor/leaflet.css @@ -0,0 +1,661 @@ +/* required styles */ + +.leaflet-pane, +.leaflet-tile, +.leaflet-marker-icon, +.leaflet-marker-shadow, +.leaflet-tile-container, +.leaflet-pane > svg, +.leaflet-pane > canvas, +.leaflet-zoom-box, +.leaflet-image-layer, +.leaflet-layer { + position: absolute; + left: 0; + top: 0; + } +.leaflet-container { + overflow: hidden; + } +.leaflet-tile, +.leaflet-marker-icon, +.leaflet-marker-shadow { + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + -webkit-user-drag: none; + } +/* Prevents IE11 from highlighting tiles in blue */ +.leaflet-tile::selection { + background: transparent; +} +/* Safari renders non-retina tile on retina better with this, but Chrome is worse */ +.leaflet-safari .leaflet-tile { + image-rendering: -webkit-optimize-contrast; + } +/* hack that prevents hw layers "stretching" when loading new tiles */ +.leaflet-safari .leaflet-tile-container { + width: 1600px; + height: 1600px; + -webkit-transform-origin: 0 0; + } +.leaflet-marker-icon, +.leaflet-marker-shadow { + display: block; + } +/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */ +/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */ +.leaflet-container .leaflet-overlay-pane svg { + max-width: none !important; + max-height: none !important; + } +.leaflet-container .leaflet-marker-pane img, +.leaflet-container .leaflet-shadow-pane img, +.leaflet-container .leaflet-tile-pane img, +.leaflet-container img.leaflet-image-layer, +.leaflet-container .leaflet-tile { + max-width: none !important; + max-height: none !important; + width: auto; + padding: 0; + } + +.leaflet-container img.leaflet-tile { + /* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */ + mix-blend-mode: plus-lighter; +} + +.leaflet-container.leaflet-touch-zoom { + -ms-touch-action: pan-x pan-y; + touch-action: pan-x pan-y; + } +.leaflet-container.leaflet-touch-drag { + -ms-touch-action: pinch-zoom; + /* Fallback for FF which doesn't support pinch-zoom */ + touch-action: none; + touch-action: pinch-zoom; +} +.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom { + -ms-touch-action: none; + touch-action: none; +} +.leaflet-container { + -webkit-tap-highlight-color: transparent; +} +.leaflet-container a { + -webkit-tap-highlight-color: rgba(51, 181, 229, 0.4); +} +.leaflet-tile { + filter: inherit; + visibility: hidden; + } +.leaflet-tile-loaded { + visibility: inherit; + } +.leaflet-zoom-box { + width: 0; + height: 0; + -moz-box-sizing: border-box; + box-sizing: border-box; + z-index: 800; + } +/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */ +.leaflet-overlay-pane svg { + -moz-user-select: none; + } + +.leaflet-pane { z-index: 400; } + +.leaflet-tile-pane { z-index: 200; } +.leaflet-overlay-pane { z-index: 400; } +.leaflet-shadow-pane { z-index: 500; } +.leaflet-marker-pane { z-index: 600; } +.leaflet-tooltip-pane { z-index: 650; } +.leaflet-popup-pane { z-index: 700; } + +.leaflet-map-pane canvas { z-index: 100; } +.leaflet-map-pane svg { z-index: 200; } + +.leaflet-vml-shape { + width: 1px; + height: 1px; + } +.lvml { + behavior: url(#default#VML); + display: inline-block; + position: absolute; + } + + +/* control positioning */ + +.leaflet-control { + position: relative; + z-index: 800; + pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ + pointer-events: auto; + } +.leaflet-top, +.leaflet-bottom { + position: absolute; + z-index: 1000; + pointer-events: none; + } +.leaflet-top { + top: 0; + } +.leaflet-right { + right: 0; + } +.leaflet-bottom { + bottom: 0; + } +.leaflet-left { + left: 0; + } +.leaflet-control { + float: left; + clear: both; + } +.leaflet-right .leaflet-control { + float: right; + } +.leaflet-top .leaflet-control { + margin-top: 10px; + } +.leaflet-bottom .leaflet-control { + margin-bottom: 10px; + } +.leaflet-left .leaflet-control { + margin-left: 10px; + } +.leaflet-right .leaflet-control { + margin-right: 10px; + } + + +/* zoom and fade animations */ + +.leaflet-fade-anim .leaflet-popup { + opacity: 0; + -webkit-transition: opacity 0.2s linear; + -moz-transition: opacity 0.2s linear; + transition: opacity 0.2s linear; + } +.leaflet-fade-anim .leaflet-map-pane .leaflet-popup { + opacity: 1; + } +.leaflet-zoom-animated { + -webkit-transform-origin: 0 0; + -ms-transform-origin: 0 0; + transform-origin: 0 0; + } +svg.leaflet-zoom-animated { + will-change: transform; +} + +.leaflet-zoom-anim .leaflet-zoom-animated { + -webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1); + -moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1); + transition: transform 0.25s cubic-bezier(0,0,0.25,1); + } +.leaflet-zoom-anim .leaflet-tile, +.leaflet-pan-anim .leaflet-tile { + -webkit-transition: none; + -moz-transition: none; + transition: none; + } + +.leaflet-zoom-anim .leaflet-zoom-hide { + visibility: hidden; + } + + +/* cursors */ + +.leaflet-interactive { + cursor: pointer; + } +.leaflet-grab { + cursor: -webkit-grab; + cursor: -moz-grab; + cursor: grab; + } +.leaflet-crosshair, +.leaflet-crosshair .leaflet-interactive { + cursor: crosshair; + } +.leaflet-popup-pane, +.leaflet-control { + cursor: auto; + } +.leaflet-dragging .leaflet-grab, +.leaflet-dragging .leaflet-grab .leaflet-interactive, +.leaflet-dragging .leaflet-marker-draggable { + cursor: move; + cursor: -webkit-grabbing; + cursor: -moz-grabbing; + cursor: grabbing; + } + +/* marker & overlays interactivity */ +.leaflet-marker-icon, +.leaflet-marker-shadow, +.leaflet-image-layer, +.leaflet-pane > svg path, +.leaflet-tile-container { + pointer-events: none; + } + +.leaflet-marker-icon.leaflet-interactive, +.leaflet-image-layer.leaflet-interactive, +.leaflet-pane > svg path.leaflet-interactive, +svg.leaflet-image-layer.leaflet-interactive path { + pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ + pointer-events: auto; + } + +/* visual tweaks */ + +.leaflet-container { + background: #ddd; + outline-offset: 1px; + } +.leaflet-container a { + color: #0078A8; + } +.leaflet-zoom-box { + border: 2px dotted #38f; + background: rgba(255,255,255,0.5); + } + + +/* general typography */ +.leaflet-container { + font-family: "Helvetica Neue", Arial, Helvetica, sans-serif; + font-size: 12px; + font-size: 0.75rem; + line-height: 1.5; + } + + +/* general toolbar styles */ + +.leaflet-bar { + box-shadow: 0 1px 5px rgba(0,0,0,0.65); + border-radius: 4px; + } +.leaflet-bar a { + background-color: #fff; + border-bottom: 1px solid #ccc; + width: 26px; + height: 26px; + line-height: 26px; + display: block; + text-align: center; + text-decoration: none; + color: black; + } +.leaflet-bar a, +.leaflet-control-layers-toggle { + background-position: 50% 50%; + background-repeat: no-repeat; + display: block; + } +.leaflet-bar a:hover, +.leaflet-bar a:focus { + background-color: #f4f4f4; + } +.leaflet-bar a:first-child { + border-top-left-radius: 4px; + border-top-right-radius: 4px; + } +.leaflet-bar a:last-child { + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + border-bottom: none; + } +.leaflet-bar a.leaflet-disabled { + cursor: default; + background-color: #f4f4f4; + color: #bbb; + } + +.leaflet-touch .leaflet-bar a { + width: 30px; + height: 30px; + line-height: 30px; + } +.leaflet-touch .leaflet-bar a:first-child { + border-top-left-radius: 2px; + border-top-right-radius: 2px; + } +.leaflet-touch .leaflet-bar a:last-child { + border-bottom-left-radius: 2px; + border-bottom-right-radius: 2px; + } + +/* zoom control */ + +.leaflet-control-zoom-in, +.leaflet-control-zoom-out { + font: bold 18px 'Lucida Console', Monaco, monospace; + text-indent: 1px; + } + +.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out { + font-size: 22px; + } + + +/* layers control */ + +.leaflet-control-layers { + box-shadow: 0 1px 5px rgba(0,0,0,0.4); + background: #fff; + border-radius: 5px; + } +.leaflet-control-layers-toggle { + background-image: url(images/layers.png); + width: 36px; + height: 36px; + } +.leaflet-retina .leaflet-control-layers-toggle { + background-image: url(images/layers-2x.png); + background-size: 26px 26px; + } +.leaflet-touch .leaflet-control-layers-toggle { + width: 44px; + height: 44px; + } +.leaflet-control-layers .leaflet-control-layers-list, +.leaflet-control-layers-expanded .leaflet-control-layers-toggle { + display: none; + } +.leaflet-control-layers-expanded .leaflet-control-layers-list { + display: block; + position: relative; + } +.leaflet-control-layers-expanded { + padding: 6px 10px 6px 6px; + color: #333; + background: #fff; + } +.leaflet-control-layers-scrollbar { + overflow-y: scroll; + overflow-x: hidden; + padding-right: 5px; + } +.leaflet-control-layers-selector { + margin-top: 2px; + position: relative; + top: 1px; + } +.leaflet-control-layers label { + display: block; + font-size: 13px; + font-size: 1.08333em; + } +.leaflet-control-layers-separator { + height: 0; + border-top: 1px solid #ddd; + margin: 5px -10px 5px -6px; + } + +/* Default icon URLs */ +.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */ + background-image: url(images/marker-icon.png); + } + + +/* attribution and scale controls */ + +.leaflet-container .leaflet-control-attribution { + background: #fff; + background: rgba(255, 255, 255, 0.8); + margin: 0; + } +.leaflet-control-attribution, +.leaflet-control-scale-line { + padding: 0 5px; + color: #333; + line-height: 1.4; + } +.leaflet-control-attribution a { + text-decoration: none; + } +.leaflet-control-attribution a:hover, +.leaflet-control-attribution a:focus { + text-decoration: underline; + } +.leaflet-attribution-flag { + display: inline !important; + vertical-align: baseline !important; + width: 1em; + height: 0.6669em; + } +.leaflet-left .leaflet-control-scale { + margin-left: 5px; + } +.leaflet-bottom .leaflet-control-scale { + margin-bottom: 5px; + } +.leaflet-control-scale-line { + border: 2px solid #777; + border-top: none; + line-height: 1.1; + padding: 2px 5px 1px; + white-space: nowrap; + -moz-box-sizing: border-box; + box-sizing: border-box; + background: rgba(255, 255, 255, 0.8); + text-shadow: 1px 1px #fff; + } +.leaflet-control-scale-line:not(:first-child) { + border-top: 2px solid #777; + border-bottom: none; + margin-top: -2px; + } +.leaflet-control-scale-line:not(:first-child):not(:last-child) { + border-bottom: 2px solid #777; + } + +.leaflet-touch .leaflet-control-attribution, +.leaflet-touch .leaflet-control-layers, +.leaflet-touch .leaflet-bar { + box-shadow: none; + } +.leaflet-touch .leaflet-control-layers, +.leaflet-touch .leaflet-bar { + border: 2px solid rgba(0,0,0,0.2); + background-clip: padding-box; + } + + +/* popup */ + +.leaflet-popup { + position: absolute; + text-align: center; + margin-bottom: 20px; + } +.leaflet-popup-content-wrapper { + padding: 1px; + text-align: left; + border-radius: 12px; + } +.leaflet-popup-content { + margin: 13px 24px 13px 20px; + line-height: 1.3; + font-size: 13px; + font-size: 1.08333em; + min-height: 1px; + } +.leaflet-popup-content p { + margin: 17px 0; + margin: 1.3em 0; + } +.leaflet-popup-tip-container { + width: 40px; + height: 20px; + position: absolute; + left: 50%; + margin-top: -1px; + margin-left: -20px; + overflow: hidden; + pointer-events: none; + } +.leaflet-popup-tip { + width: 17px; + height: 17px; + padding: 1px; + + margin: -10px auto 0; + pointer-events: auto; + + -webkit-transform: rotate(45deg); + -moz-transform: rotate(45deg); + -ms-transform: rotate(45deg); + transform: rotate(45deg); + } +.leaflet-popup-content-wrapper, +.leaflet-popup-tip { + background: white; + color: #333; + box-shadow: 0 3px 14px rgba(0,0,0,0.4); + } +.leaflet-container a.leaflet-popup-close-button { + position: absolute; + top: 0; + right: 0; + border: none; + text-align: center; + width: 24px; + height: 24px; + font: 16px/24px Tahoma, Verdana, sans-serif; + color: #757575; + text-decoration: none; + background: transparent; + } +.leaflet-container a.leaflet-popup-close-button:hover, +.leaflet-container a.leaflet-popup-close-button:focus { + color: #585858; + } +.leaflet-popup-scrolled { + overflow: auto; + } + +.leaflet-oldie .leaflet-popup-content-wrapper { + -ms-zoom: 1; + } +.leaflet-oldie .leaflet-popup-tip { + width: 24px; + margin: 0 auto; + + -ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)"; + filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678); + } + +.leaflet-oldie .leaflet-control-zoom, +.leaflet-oldie .leaflet-control-layers, +.leaflet-oldie .leaflet-popup-content-wrapper, +.leaflet-oldie .leaflet-popup-tip { + border: 1px solid #999; + } + + +/* div icon */ + +.leaflet-div-icon { + background: #fff; + border: 1px solid #666; + } + + +/* Tooltip */ +/* Base styles for the element that has a tooltip */ +.leaflet-tooltip { + position: absolute; + padding: 6px; + background-color: #fff; + border: 1px solid #fff; + border-radius: 3px; + color: #222; + white-space: nowrap; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + pointer-events: none; + box-shadow: 0 1px 3px rgba(0,0,0,0.4); + } +.leaflet-tooltip.leaflet-interactive { + cursor: pointer; + pointer-events: auto; + } +.leaflet-tooltip-top:before, +.leaflet-tooltip-bottom:before, +.leaflet-tooltip-left:before, +.leaflet-tooltip-right:before { + position: absolute; + pointer-events: none; + border: 6px solid transparent; + background: transparent; + content: ""; + } + +/* Directions */ + +.leaflet-tooltip-bottom { + margin-top: 6px; +} +.leaflet-tooltip-top { + margin-top: -6px; +} +.leaflet-tooltip-bottom:before, +.leaflet-tooltip-top:before { + left: 50%; + margin-left: -6px; + } +.leaflet-tooltip-top:before { + bottom: 0; + margin-bottom: -12px; + border-top-color: #fff; + } +.leaflet-tooltip-bottom:before { + top: 0; + margin-top: -12px; + margin-left: -6px; + border-bottom-color: #fff; + } +.leaflet-tooltip-left { + margin-left: -6px; +} +.leaflet-tooltip-right { + margin-left: 6px; +} +.leaflet-tooltip-left:before, +.leaflet-tooltip-right:before { + top: 50%; + margin-top: -6px; + } +.leaflet-tooltip-left:before { + right: 0; + margin-right: -12px; + border-left-color: #fff; + } +.leaflet-tooltip-right:before { + left: 0; + margin-left: -12px; + border-right-color: #fff; + } + +/* Printing */ + +@media print { + /* Prevent printers from removing background-images of controls. */ + .leaflet-control { + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + } diff --git a/backend/static/vendor/leaflet.js b/backend/static/vendor/leaflet.js new file mode 100644 index 0000000..a3bf693 --- /dev/null +++ b/backend/static/vendor/leaflet.js @@ -0,0 +1,6 @@ +/* @preserve + * Leaflet 1.9.4, a JS library for interactive maps. https://leafletjs.com + * (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).leaflet={})}(this,function(t){"use strict";function l(t){for(var e,i,n=1,o=arguments.length;n=this.min.x&&i.x<=this.max.x&&e.y>=this.min.y&&i.y<=this.max.y},intersects:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>=e.x&&n.x<=i.x,t=t.y>=e.y&&n.y<=i.y;return o&&t},overlaps:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>e.x&&n.xe.y&&n.y=n.lat&&i.lat<=o.lat&&e.lng>=n.lng&&i.lng<=o.lng},intersects:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>=e.lat&&n.lat<=i.lat,t=t.lng>=e.lng&&n.lng<=i.lng;return o&&t},overlaps:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>e.lat&&n.late.lng&&n.lng","http://www.w3.org/2000/svg"===(Wt.firstChild&&Wt.firstChild.namespaceURI));function y(t){return 0<=navigator.userAgent.toLowerCase().indexOf(t)}var b={ie:pt,ielt9:mt,edge:n,webkit:ft,android:gt,android23:vt,androidStock:yt,opera:xt,chrome:wt,gecko:bt,safari:Pt,phantom:Lt,opera12:o,win:Tt,ie3d:Mt,webkit3d:zt,gecko3d:_t,any3d:Ct,mobile:Zt,mobileWebkit:St,mobileWebkit3d:Et,msPointer:kt,pointer:Ot,touch:Bt,touchNative:At,mobileOpera:It,mobileGecko:Rt,retina:Nt,passiveEvents:Dt,canvas:jt,svg:Ht,vml:!Ht&&function(){try{var t=document.createElement("div"),e=(t.innerHTML='',t.firstChild);return e.style.behavior="url(#default#VML)",e&&"object"==typeof e.adj}catch(t){return!1}}(),inlineSvg:Wt,mac:0===navigator.platform.indexOf("Mac"),linux:0===navigator.platform.indexOf("Linux")},Ft=b.msPointer?"MSPointerDown":"pointerdown",Ut=b.msPointer?"MSPointerMove":"pointermove",Vt=b.msPointer?"MSPointerUp":"pointerup",qt=b.msPointer?"MSPointerCancel":"pointercancel",Gt={touchstart:Ft,touchmove:Ut,touchend:Vt,touchcancel:qt},Kt={touchstart:function(t,e){e.MSPOINTER_TYPE_TOUCH&&e.pointerType===e.MSPOINTER_TYPE_TOUCH&&O(e);ee(t,e)},touchmove:ee,touchend:ee,touchcancel:ee},Yt={},Xt=!1;function Jt(t,e,i){return"touchstart"!==e||Xt||(document.addEventListener(Ft,$t,!0),document.addEventListener(Ut,Qt,!0),document.addEventListener(Vt,te,!0),document.addEventListener(qt,te,!0),Xt=!0),Kt[e]?(i=Kt[e].bind(this,i),t.addEventListener(Gt[e],i,!1),i):(console.warn("wrong event specified:",e),u)}function $t(t){Yt[t.pointerId]=t}function Qt(t){Yt[t.pointerId]&&(Yt[t.pointerId]=t)}function te(t){delete Yt[t.pointerId]}function ee(t,e){if(e.pointerType!==(e.MSPOINTER_TYPE_MOUSE||"mouse")){for(var i in e.touches=[],Yt)e.touches.push(Yt[i]);e.changedTouches=[e],t(e)}}var ie=200;function ne(t,i){t.addEventListener("dblclick",i);var n,o=0;function e(t){var e;1!==t.detail?n=t.detail:"mouse"===t.pointerType||t.sourceCapabilities&&!t.sourceCapabilities.firesTouchEvents||((e=Ne(t)).some(function(t){return t instanceof HTMLLabelElement&&t.attributes.for})&&!e.some(function(t){return t instanceof HTMLInputElement||t instanceof HTMLSelectElement})||((e=Date.now())-o<=ie?2===++n&&i(function(t){var e,i,n={};for(i in t)e=t[i],n[i]=e&&e.bind?e.bind(t):e;return(t=n).type="dblclick",n.detail=2,n.isTrusted=!1,n._simulated=!0,n}(t)):n=1,o=e))}return t.addEventListener("click",e),{dblclick:i,simDblclick:e}}var oe,se,re,ae,he,le,ue=we(["transform","webkitTransform","OTransform","MozTransform","msTransform"]),ce=we(["webkitTransition","transition","OTransition","MozTransition","msTransition"]),de="webkitTransition"===ce||"OTransition"===ce?ce+"End":"transitionend";function _e(t){return"string"==typeof t?document.getElementById(t):t}function pe(t,e){var i=t.style[e]||t.currentStyle&&t.currentStyle[e];return"auto"===(i=i&&"auto"!==i||!document.defaultView?i:(t=document.defaultView.getComputedStyle(t,null))?t[e]:null)?null:i}function P(t,e,i){t=document.createElement(t);return t.className=e||"",i&&i.appendChild(t),t}function T(t){var e=t.parentNode;e&&e.removeChild(t)}function me(t){for(;t.firstChild;)t.removeChild(t.firstChild)}function fe(t){var e=t.parentNode;e&&e.lastChild!==t&&e.appendChild(t)}function ge(t){var e=t.parentNode;e&&e.firstChild!==t&&e.insertBefore(t,e.firstChild)}function ve(t,e){return void 0!==t.classList?t.classList.contains(e):0<(t=xe(t)).length&&new RegExp("(^|\\s)"+e+"(\\s|$)").test(t)}function M(t,e){var i;if(void 0!==t.classList)for(var n=F(e),o=0,s=n.length;othis.options.maxZoom)?this.setZoom(t):this},panInsideBounds:function(t,e){this._enforcingBounds=!0;var i=this.getCenter(),t=this._limitCenter(i,this._zoom,g(t));return i.equals(t)||this.panTo(t,e),this._enforcingBounds=!1,this},panInside:function(t,e){var i=m((e=e||{}).paddingTopLeft||e.padding||[0,0]),n=m(e.paddingBottomRight||e.padding||[0,0]),o=this.project(this.getCenter()),t=this.project(t),s=this.getPixelBounds(),i=_([s.min.add(i),s.max.subtract(n)]),s=i.getSize();return i.contains(t)||(this._enforcingBounds=!0,n=t.subtract(i.getCenter()),i=i.extend(t).getSize().subtract(s),o.x+=n.x<0?-i.x:i.x,o.y+=n.y<0?-i.y:i.y,this.panTo(this.unproject(o),e),this._enforcingBounds=!1),this},invalidateSize:function(t){if(!this._loaded)return this;t=l({animate:!1,pan:!0},!0===t?{animate:!0}:t);var e=this.getSize(),i=(this._sizeChanged=!0,this._lastCenter=null,this.getSize()),n=e.divideBy(2).round(),o=i.divideBy(2).round(),n=n.subtract(o);return n.x||n.y?(t.animate&&t.pan?this.panBy(n):(t.pan&&this._rawPanBy(n),this.fire("move"),t.debounceMoveend?(clearTimeout(this._sizeTimer),this._sizeTimer=setTimeout(a(this.fire,this,"moveend"),200)):this.fire("moveend")),this.fire("resize",{oldSize:e,newSize:i})):this},stop:function(){return this.setZoom(this._limitZoom(this._zoom)),this.options.zoomSnap||this.fire("viewreset"),this._stop()},locate:function(t){var e,i;return t=this._locateOptions=l({timeout:1e4,watch:!1},t),"geolocation"in navigator?(e=a(this._handleGeolocationResponse,this),i=a(this._handleGeolocationError,this),t.watch?this._locationWatchId=navigator.geolocation.watchPosition(e,i,t):navigator.geolocation.getCurrentPosition(e,i,t)):this._handleGeolocationError({code:0,message:"Geolocation not supported."}),this},stopLocate:function(){return navigator.geolocation&&navigator.geolocation.clearWatch&&navigator.geolocation.clearWatch(this._locationWatchId),this._locateOptions&&(this._locateOptions.setView=!1),this},_handleGeolocationError:function(t){var e;this._container._leaflet_id&&(e=t.code,t=t.message||(1===e?"permission denied":2===e?"position unavailable":"timeout"),this._locateOptions.setView&&!this._loaded&&this.fitWorld(),this.fire("locationerror",{code:e,message:"Geolocation error: "+t+"."}))},_handleGeolocationResponse:function(t){if(this._container._leaflet_id){var e,i,n=new v(t.coords.latitude,t.coords.longitude),o=n.toBounds(2*t.coords.accuracy),s=this._locateOptions,r=(s.setView&&(e=this.getBoundsZoom(o),this.setView(n,s.maxZoom?Math.min(e,s.maxZoom):e)),{latlng:n,bounds:o,timestamp:t.timestamp});for(i in t.coords)"number"==typeof t.coords[i]&&(r[i]=t.coords[i]);this.fire("locationfound",r)}},addHandler:function(t,e){return e&&(e=this[t]=new e(this),this._handlers.push(e),this.options[t]&&e.enable()),this},remove:function(){if(this._initEvents(!0),this.options.maxBounds&&this.off("moveend",this._panInsideMaxBounds),this._containerId!==this._container._leaflet_id)throw new Error("Map container is being reused by another instance");try{delete this._container._leaflet_id,delete this._containerId}catch(t){this._container._leaflet_id=void 0,this._containerId=void 0}for(var t in void 0!==this._locationWatchId&&this.stopLocate(),this._stop(),T(this._mapPane),this._clearControlPos&&this._clearControlPos(),this._resizeRequest&&(r(this._resizeRequest),this._resizeRequest=null),this._clearHandlers(),this._loaded&&this.fire("unload"),this._layers)this._layers[t].remove();for(t in this._panes)T(this._panes[t]);return this._layers=[],this._panes=[],delete this._mapPane,delete this._renderer,this},createPane:function(t,e){e=P("div","leaflet-pane"+(t?" leaflet-"+t.replace("Pane","")+"-pane":""),e||this._mapPane);return t&&(this._panes[t]=e),e},getCenter:function(){return this._checkIfLoaded(),this._lastCenter&&!this._moved()?this._lastCenter.clone():this.layerPointToLatLng(this._getCenterLayerPoint())},getZoom:function(){return this._zoom},getBounds:function(){var t=this.getPixelBounds();return new s(this.unproject(t.getBottomLeft()),this.unproject(t.getTopRight()))},getMinZoom:function(){return void 0===this.options.minZoom?this._layersMinZoom||0:this.options.minZoom},getMaxZoom:function(){return void 0===this.options.maxZoom?void 0===this._layersMaxZoom?1/0:this._layersMaxZoom:this.options.maxZoom},getBoundsZoom:function(t,e,i){t=g(t),i=m(i||[0,0]);var n=this.getZoom()||0,o=this.getMinZoom(),s=this.getMaxZoom(),r=t.getNorthWest(),t=t.getSouthEast(),i=this.getSize().subtract(i),t=_(this.project(t,n),this.project(r,n)).getSize(),r=b.any3d?this.options.zoomSnap:1,a=i.x/t.x,i=i.y/t.y,t=e?Math.max(a,i):Math.min(a,i),n=this.getScaleZoom(t,n);return r&&(n=Math.round(n/(r/100))*(r/100),n=e?Math.ceil(n/r)*r:Math.floor(n/r)*r),Math.max(o,Math.min(s,n))},getSize:function(){return this._size&&!this._sizeChanged||(this._size=new p(this._container.clientWidth||0,this._container.clientHeight||0),this._sizeChanged=!1),this._size.clone()},getPixelBounds:function(t,e){t=this._getTopLeftPoint(t,e);return new f(t,t.add(this.getSize()))},getPixelOrigin:function(){return this._checkIfLoaded(),this._pixelOrigin},getPixelWorldBounds:function(t){return this.options.crs.getProjectedBounds(void 0===t?this.getZoom():t)},getPane:function(t){return"string"==typeof t?this._panes[t]:t},getPanes:function(){return this._panes},getContainer:function(){return this._container},getZoomScale:function(t,e){var i=this.options.crs;return e=void 0===e?this._zoom:e,i.scale(t)/i.scale(e)},getScaleZoom:function(t,e){var i=this.options.crs,t=(e=void 0===e?this._zoom:e,i.zoom(t*i.scale(e)));return isNaN(t)?1/0:t},project:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.latLngToPoint(w(t),e)},unproject:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.pointToLatLng(m(t),e)},layerPointToLatLng:function(t){t=m(t).add(this.getPixelOrigin());return this.unproject(t)},latLngToLayerPoint:function(t){return this.project(w(t))._round()._subtract(this.getPixelOrigin())},wrapLatLng:function(t){return this.options.crs.wrapLatLng(w(t))},wrapLatLngBounds:function(t){return this.options.crs.wrapLatLngBounds(g(t))},distance:function(t,e){return this.options.crs.distance(w(t),w(e))},containerPointToLayerPoint:function(t){return m(t).subtract(this._getMapPanePos())},layerPointToContainerPoint:function(t){return m(t).add(this._getMapPanePos())},containerPointToLatLng:function(t){t=this.containerPointToLayerPoint(m(t));return this.layerPointToLatLng(t)},latLngToContainerPoint:function(t){return this.layerPointToContainerPoint(this.latLngToLayerPoint(w(t)))},mouseEventToContainerPoint:function(t){return De(t,this._container)},mouseEventToLayerPoint:function(t){return this.containerPointToLayerPoint(this.mouseEventToContainerPoint(t))},mouseEventToLatLng:function(t){return this.layerPointToLatLng(this.mouseEventToLayerPoint(t))},_initContainer:function(t){t=this._container=_e(t);if(!t)throw new Error("Map container not found.");if(t._leaflet_id)throw new Error("Map container is already initialized.");S(t,"scroll",this._onScroll,this),this._containerId=h(t)},_initLayout:function(){var t=this._container,e=(this._fadeAnimated=this.options.fadeAnimation&&b.any3d,M(t,"leaflet-container"+(b.touch?" leaflet-touch":"")+(b.retina?" leaflet-retina":"")+(b.ielt9?" leaflet-oldie":"")+(b.safari?" leaflet-safari":"")+(this._fadeAnimated?" leaflet-fade-anim":"")),pe(t,"position"));"absolute"!==e&&"relative"!==e&&"fixed"!==e&&"sticky"!==e&&(t.style.position="relative"),this._initPanes(),this._initControlPos&&this._initControlPos()},_initPanes:function(){var t=this._panes={};this._paneRenderers={},this._mapPane=this.createPane("mapPane",this._container),Z(this._mapPane,new p(0,0)),this.createPane("tilePane"),this.createPane("overlayPane"),this.createPane("shadowPane"),this.createPane("markerPane"),this.createPane("tooltipPane"),this.createPane("popupPane"),this.options.markerZoomAnimation||(M(t.markerPane,"leaflet-zoom-hide"),M(t.shadowPane,"leaflet-zoom-hide"))},_resetView:function(t,e,i){Z(this._mapPane,new p(0,0));var n=!this._loaded,o=(this._loaded=!0,e=this._limitZoom(e),this.fire("viewprereset"),this._zoom!==e);this._moveStart(o,i)._move(t,e)._moveEnd(o),this.fire("viewreset"),n&&this.fire("load")},_moveStart:function(t,e){return t&&this.fire("zoomstart"),e||this.fire("movestart"),this},_move:function(t,e,i,n){void 0===e&&(e=this._zoom);var o=this._zoom!==e;return this._zoom=e,this._lastCenter=t,this._pixelOrigin=this._getNewPixelOrigin(t),n?i&&i.pinch&&this.fire("zoom",i):((o||i&&i.pinch)&&this.fire("zoom",i),this.fire("move",i)),this},_moveEnd:function(t){return t&&this.fire("zoomend"),this.fire("moveend")},_stop:function(){return r(this._flyToFrame),this._panAnim&&this._panAnim.stop(),this},_rawPanBy:function(t){Z(this._mapPane,this._getMapPanePos().subtract(t))},_getZoomSpan:function(){return this.getMaxZoom()-this.getMinZoom()},_panInsideMaxBounds:function(){this._enforcingBounds||this.panInsideBounds(this.options.maxBounds)},_checkIfLoaded:function(){if(!this._loaded)throw new Error("Set map center and zoom first.")},_initEvents:function(t){this._targets={};var e=t?k:S;e((this._targets[h(this._container)]=this)._container,"click dblclick mousedown mouseup mouseover mouseout mousemove contextmenu keypress keydown keyup",this._handleDOMEvent,this),this.options.trackResize&&e(window,"resize",this._onResize,this),b.any3d&&this.options.transform3DLimit&&(t?this.off:this.on).call(this,"moveend",this._onMoveEnd)},_onResize:function(){r(this._resizeRequest),this._resizeRequest=x(function(){this.invalidateSize({debounceMoveend:!0})},this)},_onScroll:function(){this._container.scrollTop=0,this._container.scrollLeft=0},_onMoveEnd:function(){var t=this._getMapPanePos();Math.max(Math.abs(t.x),Math.abs(t.y))>=this.options.transform3DLimit&&this._resetView(this.getCenter(),this.getZoom())},_findEventTargets:function(t,e){for(var i,n=[],o="mouseout"===e||"mouseover"===e,s=t.target||t.srcElement,r=!1;s;){if((i=this._targets[h(s)])&&("click"===e||"preclick"===e)&&this._draggableMoved(i)){r=!0;break}if(i&&i.listens(e,!0)){if(o&&!We(s,t))break;if(n.push(i),o)break}if(s===this._container)break;s=s.parentNode}return n=n.length||r||o||!this.listens(e,!0)?n:[this]},_isClickDisabled:function(t){for(;t&&t!==this._container;){if(t._leaflet_disable_click)return!0;t=t.parentNode}},_handleDOMEvent:function(t){var e,i=t.target||t.srcElement;!this._loaded||i._leaflet_disable_events||"click"===t.type&&this._isClickDisabled(i)||("mousedown"===(e=t.type)&&Me(i),this._fireDOMEvent(t,e))},_mouseEvents:["click","dblclick","mouseover","mouseout","contextmenu"],_fireDOMEvent:function(t,e,i){"click"===t.type&&((a=l({},t)).type="preclick",this._fireDOMEvent(a,a.type,i));var n=this._findEventTargets(t,e);if(i){for(var o=[],s=0;sthis.options.zoomAnimationThreshold)return!1;var n=this.getZoomScale(e),n=this._getCenterOffset(t)._divideBy(1-1/n);if(!0!==i.animate&&!this.getSize().contains(n))return!1;x(function(){this._moveStart(!0,i.noMoveStart||!1)._animateZoom(t,e,!0)},this)}return!0},_animateZoom:function(t,e,i,n){this._mapPane&&(i&&(this._animatingZoom=!0,this._animateToCenter=t,this._animateToZoom=e,M(this._mapPane,"leaflet-zoom-anim")),this.fire("zoomanim",{center:t,zoom:e,noUpdate:n}),this._tempFireZoomEvent||(this._tempFireZoomEvent=this._zoom!==this._animateToZoom),this._move(this._animateToCenter,this._animateToZoom,void 0,!0),setTimeout(a(this._onZoomTransitionEnd,this),250))},_onZoomTransitionEnd:function(){this._animatingZoom&&(this._mapPane&&z(this._mapPane,"leaflet-zoom-anim"),this._animatingZoom=!1,this._move(this._animateToCenter,this._animateToZoom,void 0,!0),this._tempFireZoomEvent&&this.fire("zoom"),delete this._tempFireZoomEvent,this.fire("move"),this._moveEnd(!0))}});function Ue(t){return new B(t)}var B=et.extend({options:{position:"topright"},initialize:function(t){c(this,t)},getPosition:function(){return this.options.position},setPosition:function(t){var e=this._map;return e&&e.removeControl(this),this.options.position=t,e&&e.addControl(this),this},getContainer:function(){return this._container},addTo:function(t){this.remove(),this._map=t;var e=this._container=this.onAdd(t),i=this.getPosition(),t=t._controlCorners[i];return M(e,"leaflet-control"),-1!==i.indexOf("bottom")?t.insertBefore(e,t.firstChild):t.appendChild(e),this._map.on("unload",this.remove,this),this},remove:function(){return this._map&&(T(this._container),this.onRemove&&this.onRemove(this._map),this._map.off("unload",this.remove,this),this._map=null),this},_refocusOnMap:function(t){this._map&&t&&0",e=document.createElement("div");return e.innerHTML=t,e.firstChild},_addItem:function(t){var e,i=document.createElement("label"),n=this._map.hasLayer(t.layer),n=(t.overlay?((e=document.createElement("input")).type="checkbox",e.className="leaflet-control-layers-selector",e.defaultChecked=n):e=this._createRadioElement("leaflet-base-layers_"+h(this),n),this._layerControlInputs.push(e),e.layerId=h(t.layer),S(e,"click",this._onInputClick,this),document.createElement("span")),o=(n.innerHTML=" "+t.name,document.createElement("span"));return i.appendChild(o),o.appendChild(e),o.appendChild(n),(t.overlay?this._overlaysList:this._baseLayersList).appendChild(i),this._checkDisabledLayers(),i},_onInputClick:function(){if(!this._preventClick){var t,e,i=this._layerControlInputs,n=[],o=[];this._handlingClick=!0;for(var s=i.length-1;0<=s;s--)t=i[s],e=this._getLayer(t.layerId).layer,t.checked?n.push(e):t.checked||o.push(e);for(s=0;se.options.maxZoom},_expandIfNotCollapsed:function(){return this._map&&!this.options.collapsed&&this.expand(),this},_expandSafely:function(){var t=this._section,e=(this._preventClick=!0,S(t,"click",O),this.expand(),this);setTimeout(function(){k(t,"click",O),e._preventClick=!1})}})),qe=B.extend({options:{position:"topleft",zoomInText:'',zoomInTitle:"Zoom in",zoomOutText:'',zoomOutTitle:"Zoom out"},onAdd:function(t){var e="leaflet-control-zoom",i=P("div",e+" leaflet-bar"),n=this.options;return this._zoomInButton=this._createButton(n.zoomInText,n.zoomInTitle,e+"-in",i,this._zoomIn),this._zoomOutButton=this._createButton(n.zoomOutText,n.zoomOutTitle,e+"-out",i,this._zoomOut),this._updateDisabled(),t.on("zoomend zoomlevelschange",this._updateDisabled,this),i},onRemove:function(t){t.off("zoomend zoomlevelschange",this._updateDisabled,this)},disable:function(){return this._disabled=!0,this._updateDisabled(),this},enable:function(){return this._disabled=!1,this._updateDisabled(),this},_zoomIn:function(t){!this._disabled&&this._map._zoomthis._map.getMinZoom()&&this._map.zoomOut(this._map.options.zoomDelta*(t.shiftKey?3:1))},_createButton:function(t,e,i,n,o){i=P("a",i,n);return i.innerHTML=t,i.href="#",i.title=e,i.setAttribute("role","button"),i.setAttribute("aria-label",e),Ie(i),S(i,"click",Re),S(i,"click",o,this),S(i,"click",this._refocusOnMap,this),i},_updateDisabled:function(){var t=this._map,e="leaflet-disabled";z(this._zoomInButton,e),z(this._zoomOutButton,e),this._zoomInButton.setAttribute("aria-disabled","false"),this._zoomOutButton.setAttribute("aria-disabled","false"),!this._disabled&&t._zoom!==t.getMinZoom()||(M(this._zoomOutButton,e),this._zoomOutButton.setAttribute("aria-disabled","true")),!this._disabled&&t._zoom!==t.getMaxZoom()||(M(this._zoomInButton,e),this._zoomInButton.setAttribute("aria-disabled","true"))}}),Ge=(A.mergeOptions({zoomControl:!0}),A.addInitHook(function(){this.options.zoomControl&&(this.zoomControl=new qe,this.addControl(this.zoomControl))}),B.extend({options:{position:"bottomleft",maxWidth:100,metric:!0,imperial:!0},onAdd:function(t){var e="leaflet-control-scale",i=P("div",e),n=this.options;return this._addScales(n,e+"-line",i),t.on(n.updateWhenIdle?"moveend":"move",this._update,this),t.whenReady(this._update,this),i},onRemove:function(t){t.off(this.options.updateWhenIdle?"moveend":"move",this._update,this)},_addScales:function(t,e,i){t.metric&&(this._mScale=P("div",e,i)),t.imperial&&(this._iScale=P("div",e,i))},_update:function(){var t=this._map,e=t.getSize().y/2,t=t.distance(t.containerPointToLatLng([0,e]),t.containerPointToLatLng([this.options.maxWidth,e]));this._updateScales(t)},_updateScales:function(t){this.options.metric&&t&&this._updateMetric(t),this.options.imperial&&t&&this._updateImperial(t)},_updateMetric:function(t){var e=this._getRoundNum(t);this._updateScale(this._mScale,e<1e3?e+" m":e/1e3+" km",e/t)},_updateImperial:function(t){var e,i,t=3.2808399*t;5280'+(b.inlineSvg?' ':"")+"Leaflet"},initialize:function(t){c(this,t),this._attributions={}},onAdd:function(t){for(var e in(t.attributionControl=this)._container=P("div","leaflet-control-attribution"),Ie(this._container),t._layers)t._layers[e].getAttribution&&this.addAttribution(t._layers[e].getAttribution());return this._update(),t.on("layeradd",this._addAttribution,this),this._container},onRemove:function(t){t.off("layeradd",this._addAttribution,this)},_addAttribution:function(t){t.layer.getAttribution&&(this.addAttribution(t.layer.getAttribution()),t.layer.once("remove",function(){this.removeAttribution(t.layer.getAttribution())},this))},setPrefix:function(t){return this.options.prefix=t,this._update(),this},addAttribution:function(t){return t&&(this._attributions[t]||(this._attributions[t]=0),this._attributions[t]++,this._update()),this},removeAttribution:function(t){return t&&this._attributions[t]&&(this._attributions[t]--,this._update()),this},_update:function(){if(this._map){var t,e=[];for(t in this._attributions)this._attributions[t]&&e.push(t);var i=[];this.options.prefix&&i.push(this.options.prefix),e.length&&i.push(e.join(", ")),this._container.innerHTML=i.join(' ')}}}),n=(A.mergeOptions({attributionControl:!0}),A.addInitHook(function(){this.options.attributionControl&&(new Ke).addTo(this)}),B.Layers=Ve,B.Zoom=qe,B.Scale=Ge,B.Attribution=Ke,Ue.layers=function(t,e,i){return new Ve(t,e,i)},Ue.zoom=function(t){return new qe(t)},Ue.scale=function(t){return new Ge(t)},Ue.attribution=function(t){return new Ke(t)},et.extend({initialize:function(t){this._map=t},enable:function(){return this._enabled||(this._enabled=!0,this.addHooks()),this},disable:function(){return this._enabled&&(this._enabled=!1,this.removeHooks()),this},enabled:function(){return!!this._enabled}})),ft=(n.addTo=function(t,e){return t.addHandler(e,this),this},{Events:e}),Ye=b.touch?"touchstart mousedown":"mousedown",Xe=it.extend({options:{clickTolerance:3},initialize:function(t,e,i,n){c(this,n),this._element=t,this._dragStartTarget=e||t,this._preventOutline=i},enable:function(){this._enabled||(S(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!0)},disable:function(){this._enabled&&(Xe._dragging===this&&this.finishDrag(!0),k(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!1,this._moved=!1)},_onDown:function(t){var e,i;this._enabled&&(this._moved=!1,ve(this._element,"leaflet-zoom-anim")||(t.touches&&1!==t.touches.length?Xe._dragging===this&&this.finishDrag():Xe._dragging||t.shiftKey||1!==t.which&&1!==t.button&&!t.touches||((Xe._dragging=this)._preventOutline&&Me(this._element),Le(),re(),this._moving||(this.fire("down"),i=t.touches?t.touches[0]:t,e=Ce(this._element),this._startPoint=new p(i.clientX,i.clientY),this._startPos=Pe(this._element),this._parentScale=Ze(e),i="mousedown"===t.type,S(document,i?"mousemove":"touchmove",this._onMove,this),S(document,i?"mouseup":"touchend touchcancel",this._onUp,this)))))},_onMove:function(t){var e;this._enabled&&(t.touches&&1e&&(i.push(t[n]),o=n);oe.max.x&&(i|=2),t.ye.max.y&&(i|=8),i}function ri(t,e,i,n){var o=e.x,e=e.y,s=i.x-o,r=i.y-e,a=s*s+r*r;return 0this._layersMaxZoom&&this.setZoom(this._layersMaxZoom),void 0===this.options.minZoom&&this._layersMinZoom&&this.getZoom()t.y!=n.y>t.y&&t.x<(n.x-i.x)*(t.y-i.y)/(n.y-i.y)+i.x&&(l=!l);return l||yi.prototype._containsPoint.call(this,t,!0)}});var wi=ci.extend({initialize:function(t,e){c(this,e),this._layers={},t&&this.addData(t)},addData:function(t){var e,i,n,o=d(t)?t:t.features;if(o){for(e=0,i=o.length;es.x&&(r=i.x+a-s.x+o.x),i.x-r-n.x<(a=0)&&(r=i.x-n.x),i.y+e+o.y>s.y&&(a=i.y+e-s.y+o.y),i.y-a-n.y<0&&(a=i.y-n.y),(r||a)&&(this.options.keepInView&&(this._autopanning=!0),t.fire("autopanstart").panBy([r,a]))))},_getAnchor:function(){return m(this._source&&this._source._getPopupAnchor?this._source._getPopupAnchor():[0,0])}})),Ii=(A.mergeOptions({closePopupOnClick:!0}),A.include({openPopup:function(t,e,i){return this._initOverlay(Bi,t,e,i).openOn(this),this},closePopup:function(t){return(t=arguments.length?t:this._popup)&&t.close(),this}}),o.include({bindPopup:function(t,e){return this._popup=this._initOverlay(Bi,this._popup,t,e),this._popupHandlersAdded||(this.on({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!0),this},unbindPopup:function(){return this._popup&&(this.off({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!1,this._popup=null),this},openPopup:function(t){return this._popup&&(this instanceof ci||(this._popup._source=this),this._popup._prepareOpen(t||this._latlng)&&this._popup.openOn(this._map)),this},closePopup:function(){return this._popup&&this._popup.close(),this},togglePopup:function(){return this._popup&&this._popup.toggle(this),this},isPopupOpen:function(){return!!this._popup&&this._popup.isOpen()},setPopupContent:function(t){return this._popup&&this._popup.setContent(t),this},getPopup:function(){return this._popup},_openPopup:function(t){var e;this._popup&&this._map&&(Re(t),e=t.layer||t.target,this._popup._source!==e||e instanceof fi?(this._popup._source=e,this.openPopup(t.latlng)):this._map.hasLayer(this._popup)?this.closePopup():this.openPopup(t.latlng))},_movePopup:function(t){this._popup.setLatLng(t.latlng)},_onKeyPress:function(t){13===t.originalEvent.keyCode&&this._openPopup(t)}}),Ai.extend({options:{pane:"tooltipPane",offset:[0,0],direction:"auto",permanent:!1,sticky:!1,opacity:.9},onAdd:function(t){Ai.prototype.onAdd.call(this,t),this.setOpacity(this.options.opacity),t.fire("tooltipopen",{tooltip:this}),this._source&&(this.addEventParent(this._source),this._source.fire("tooltipopen",{tooltip:this},!0))},onRemove:function(t){Ai.prototype.onRemove.call(this,t),t.fire("tooltipclose",{tooltip:this}),this._source&&(this.removeEventParent(this._source),this._source.fire("tooltipclose",{tooltip:this},!0))},getEvents:function(){var t=Ai.prototype.getEvents.call(this);return this.options.permanent||(t.preclick=this.close),t},_initLayout:function(){var t="leaflet-tooltip "+(this.options.className||"")+" leaflet-zoom-"+(this._zoomAnimated?"animated":"hide");this._contentNode=this._container=P("div",t),this._container.setAttribute("role","tooltip"),this._container.setAttribute("id","leaflet-tooltip-"+h(this))},_updateLayout:function(){},_adjustPan:function(){},_setPosition:function(t){var e,i=this._map,n=this._container,o=i.latLngToContainerPoint(i.getCenter()),i=i.layerPointToContainerPoint(t),s=this.options.direction,r=n.offsetWidth,a=n.offsetHeight,h=m(this.options.offset),l=this._getAnchor(),i="top"===s?(e=r/2,a):"bottom"===s?(e=r/2,0):(e="center"===s?r/2:"right"===s?0:"left"===s?r:i.xthis.options.maxZoom||nthis.options.maxZoom||void 0!==this.options.minZoom&&oi.max.x)||!e.wrapLat&&(t.yi.max.y))return!1}return!this.options.bounds||(e=this._tileCoordsToBounds(t),g(this.options.bounds).overlaps(e))},_keyToBounds:function(t){return this._tileCoordsToBounds(this._keyToTileCoords(t))},_tileCoordsToNwSe:function(t){var e=this._map,i=this.getTileSize(),n=t.scaleBy(i),i=n.add(i);return[e.unproject(n,t.z),e.unproject(i,t.z)]},_tileCoordsToBounds:function(t){t=this._tileCoordsToNwSe(t),t=new s(t[0],t[1]);return t=this.options.noWrap?t:this._map.wrapLatLngBounds(t)},_tileCoordsToKey:function(t){return t.x+":"+t.y+":"+t.z},_keyToTileCoords:function(t){var t=t.split(":"),e=new p(+t[0],+t[1]);return e.z=+t[2],e},_removeTile:function(t){var e=this._tiles[t];e&&(T(e.el),delete this._tiles[t],this.fire("tileunload",{tile:e.el,coords:this._keyToTileCoords(t)}))},_initTile:function(t){M(t,"leaflet-tile");var e=this.getTileSize();t.style.width=e.x+"px",t.style.height=e.y+"px",t.onselectstart=u,t.onmousemove=u,b.ielt9&&this.options.opacity<1&&C(t,this.options.opacity)},_addTile:function(t,e){var i=this._getTilePos(t),n=this._tileCoordsToKey(t),o=this.createTile(this._wrapCoords(t),a(this._tileReady,this,t));this._initTile(o),this.createTile.length<2&&x(a(this._tileReady,this,t,null,o)),Z(o,i),this._tiles[n]={el:o,coords:t,current:!0},e.appendChild(o),this.fire("tileloadstart",{tile:o,coords:t})},_tileReady:function(t,e,i){e&&this.fire("tileerror",{error:e,tile:i,coords:t});var n=this._tileCoordsToKey(t);(i=this._tiles[n])&&(i.loaded=+new Date,this._map._fadeAnimated?(C(i.el,0),r(this._fadeFrame),this._fadeFrame=x(this._updateOpacity,this)):(i.active=!0,this._pruneTiles()),e||(M(i.el,"leaflet-tile-loaded"),this.fire("tileload",{tile:i.el,coords:t})),this._noTilesToLoad()&&(this._loading=!1,this.fire("load"),b.ielt9||!this._map._fadeAnimated?x(this._pruneTiles,this):setTimeout(a(this._pruneTiles,this),250)))},_getTilePos:function(t){return t.scaleBy(this.getTileSize()).subtract(this._level.origin)},_wrapCoords:function(t){var e=new p(this._wrapX?H(t.x,this._wrapX):t.x,this._wrapY?H(t.y,this._wrapY):t.y);return e.z=t.z,e},_pxBoundsToTileRange:function(t){var e=this.getTileSize();return new f(t.min.unscaleBy(e).floor(),t.max.unscaleBy(e).ceil().subtract([1,1]))},_noTilesToLoad:function(){for(var t in this._tiles)if(!this._tiles[t].loaded)return!1;return!0}});var Di=Ni.extend({options:{minZoom:0,maxZoom:18,subdomains:"abc",errorTileUrl:"",zoomOffset:0,tms:!1,zoomReverse:!1,detectRetina:!1,crossOrigin:!1,referrerPolicy:!1},initialize:function(t,e){this._url=t,(e=c(this,e)).detectRetina&&b.retina&&0')}}catch(t){}return function(t){return document.createElement("<"+t+' xmlns="urn:schemas-microsoft.com:vml" class="lvml">')}}(),zt={_initContainer:function(){this._container=P("div","leaflet-vml-container")},_update:function(){this._map._animatingZoom||(Wi.prototype._update.call(this),this.fire("update"))},_initPath:function(t){var e=t._container=Vi("shape");M(e,"leaflet-vml-shape "+(this.options.className||"")),e.coordsize="1 1",t._path=Vi("path"),e.appendChild(t._path),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){var e=t._container;this._container.appendChild(e),t.options.interactive&&t.addInteractiveTarget(e)},_removePath:function(t){var e=t._container;T(e),t.removeInteractiveTarget(e),delete this._layers[h(t)]},_updateStyle:function(t){var e=t._stroke,i=t._fill,n=t.options,o=t._container;o.stroked=!!n.stroke,o.filled=!!n.fill,n.stroke?(e=e||(t._stroke=Vi("stroke")),o.appendChild(e),e.weight=n.weight+"px",e.color=n.color,e.opacity=n.opacity,n.dashArray?e.dashStyle=d(n.dashArray)?n.dashArray.join(" "):n.dashArray.replace(/( *, *)/g," "):e.dashStyle="",e.endcap=n.lineCap.replace("butt","flat"),e.joinstyle=n.lineJoin):e&&(o.removeChild(e),t._stroke=null),n.fill?(i=i||(t._fill=Vi("fill")),o.appendChild(i),i.color=n.fillColor||n.color,i.opacity=n.fillOpacity):i&&(o.removeChild(i),t._fill=null)},_updateCircle:function(t){var e=t._point.round(),i=Math.round(t._radius),n=Math.round(t._radiusY||i);this._setPath(t,t._empty()?"M0 0":"AL "+e.x+","+e.y+" "+i+","+n+" 0,23592600")},_setPath:function(t,e){t._path.v=e},_bringToFront:function(t){fe(t._container)},_bringToBack:function(t){ge(t._container)}},qi=b.vml?Vi:ct,Gi=Wi.extend({_initContainer:function(){this._container=qi("svg"),this._container.setAttribute("pointer-events","none"),this._rootGroup=qi("g"),this._container.appendChild(this._rootGroup)},_destroyContainer:function(){T(this._container),k(this._container),delete this._container,delete this._rootGroup,delete this._svgSize},_update:function(){var t,e,i;this._map._animatingZoom&&this._bounds||(Wi.prototype._update.call(this),e=(t=this._bounds).getSize(),i=this._container,this._svgSize&&this._svgSize.equals(e)||(this._svgSize=e,i.setAttribute("width",e.x),i.setAttribute("height",e.y)),Z(i,t.min),i.setAttribute("viewBox",[t.min.x,t.min.y,e.x,e.y].join(" ")),this.fire("update"))},_initPath:function(t){var e=t._path=qi("path");t.options.className&&M(e,t.options.className),t.options.interactive&&M(e,"leaflet-interactive"),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){this._rootGroup||this._initContainer(),this._rootGroup.appendChild(t._path),t.addInteractiveTarget(t._path)},_removePath:function(t){T(t._path),t.removeInteractiveTarget(t._path),delete this._layers[h(t)]},_updatePath:function(t){t._project(),t._update()},_updateStyle:function(t){var e=t._path,t=t.options;e&&(t.stroke?(e.setAttribute("stroke",t.color),e.setAttribute("stroke-opacity",t.opacity),e.setAttribute("stroke-width",t.weight),e.setAttribute("stroke-linecap",t.lineCap),e.setAttribute("stroke-linejoin",t.lineJoin),t.dashArray?e.setAttribute("stroke-dasharray",t.dashArray):e.removeAttribute("stroke-dasharray"),t.dashOffset?e.setAttribute("stroke-dashoffset",t.dashOffset):e.removeAttribute("stroke-dashoffset")):e.setAttribute("stroke","none"),t.fill?(e.setAttribute("fill",t.fillColor||t.color),e.setAttribute("fill-opacity",t.fillOpacity),e.setAttribute("fill-rule",t.fillRule||"evenodd")):e.setAttribute("fill","none"))},_updatePoly:function(t,e){this._setPath(t,dt(t._parts,e))},_updateCircle:function(t){var e=t._point,i=Math.max(Math.round(t._radius),1),n="a"+i+","+(Math.max(Math.round(t._radiusY),1)||i)+" 0 1,0 ",e=t._empty()?"M0 0":"M"+(e.x-i)+","+e.y+n+2*i+",0 "+n+2*-i+",0 ";this._setPath(t,e)},_setPath:function(t,e){t._path.setAttribute("d",e)},_bringToFront:function(t){fe(t._path)},_bringToBack:function(t){ge(t._path)}});function Ki(t){return b.svg||b.vml?new Gi(t):null}b.vml&&Gi.include(zt),A.include({getRenderer:function(t){t=(t=t.options.renderer||this._getPaneRenderer(t.options.pane)||this.options.renderer||this._renderer)||(this._renderer=this._createRenderer());return this.hasLayer(t)||this.addLayer(t),t},_getPaneRenderer:function(t){var e;return"overlayPane"!==t&&void 0!==t&&(void 0===(e=this._paneRenderers[t])&&(e=this._createRenderer({pane:t}),this._paneRenderers[t]=e),e)},_createRenderer:function(t){return this.options.preferCanvas&&Ui(t)||Ki(t)}});var Yi=xi.extend({initialize:function(t,e){xi.prototype.initialize.call(this,this._boundsToLatLngs(t),e)},setBounds:function(t){return this.setLatLngs(this._boundsToLatLngs(t))},_boundsToLatLngs:function(t){return[(t=g(t)).getSouthWest(),t.getNorthWest(),t.getNorthEast(),t.getSouthEast()]}});Gi.create=qi,Gi.pointsToPath=dt,wi.geometryToLayer=bi,wi.coordsToLatLng=Li,wi.coordsToLatLngs=Ti,wi.latLngToCoords=Mi,wi.latLngsToCoords=zi,wi.getFeature=Ci,wi.asFeature=Zi,A.mergeOptions({boxZoom:!0});var _t=n.extend({initialize:function(t){this._map=t,this._container=t._container,this._pane=t._panes.overlayPane,this._resetStateTimeout=0,t.on("unload",this._destroy,this)},addHooks:function(){S(this._container,"mousedown",this._onMouseDown,this)},removeHooks:function(){k(this._container,"mousedown",this._onMouseDown,this)},moved:function(){return this._moved},_destroy:function(){T(this._pane),delete this._pane},_resetState:function(){this._resetStateTimeout=0,this._moved=!1},_clearDeferredResetState:function(){0!==this._resetStateTimeout&&(clearTimeout(this._resetStateTimeout),this._resetStateTimeout=0)},_onMouseDown:function(t){if(!t.shiftKey||1!==t.which&&1!==t.button)return!1;this._clearDeferredResetState(),this._resetState(),re(),Le(),this._startPoint=this._map.mouseEventToContainerPoint(t),S(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseMove:function(t){this._moved||(this._moved=!0,this._box=P("div","leaflet-zoom-box",this._container),M(this._container,"leaflet-crosshair"),this._map.fire("boxzoomstart")),this._point=this._map.mouseEventToContainerPoint(t);var t=new f(this._point,this._startPoint),e=t.getSize();Z(this._box,t.min),this._box.style.width=e.x+"px",this._box.style.height=e.y+"px"},_finish:function(){this._moved&&(T(this._box),z(this._container,"leaflet-crosshair")),ae(),Te(),k(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseUp:function(t){1!==t.which&&1!==t.button||(this._finish(),this._moved&&(this._clearDeferredResetState(),this._resetStateTimeout=setTimeout(a(this._resetState,this),0),t=new s(this._map.containerPointToLatLng(this._startPoint),this._map.containerPointToLatLng(this._point)),this._map.fitBounds(t).fire("boxzoomend",{boxZoomBounds:t})))},_onKeyDown:function(t){27===t.keyCode&&(this._finish(),this._clearDeferredResetState(),this._resetState())}}),Ct=(A.addInitHook("addHandler","boxZoom",_t),A.mergeOptions({doubleClickZoom:!0}),n.extend({addHooks:function(){this._map.on("dblclick",this._onDoubleClick,this)},removeHooks:function(){this._map.off("dblclick",this._onDoubleClick,this)},_onDoubleClick:function(t){var e=this._map,i=e.getZoom(),n=e.options.zoomDelta,i=t.originalEvent.shiftKey?i-n:i+n;"center"===e.options.doubleClickZoom?e.setZoom(i):e.setZoomAround(t.containerPoint,i)}})),Zt=(A.addInitHook("addHandler","doubleClickZoom",Ct),A.mergeOptions({dragging:!0,inertia:!0,inertiaDeceleration:3400,inertiaMaxSpeed:1/0,easeLinearity:.2,worldCopyJump:!1,maxBoundsViscosity:0}),n.extend({addHooks:function(){var t;this._draggable||(t=this._map,this._draggable=new Xe(t._mapPane,t._container),this._draggable.on({dragstart:this._onDragStart,drag:this._onDrag,dragend:this._onDragEnd},this),this._draggable.on("predrag",this._onPreDragLimit,this),t.options.worldCopyJump&&(this._draggable.on("predrag",this._onPreDragWrap,this),t.on("zoomend",this._onZoomEnd,this),t.whenReady(this._onZoomEnd,this))),M(this._map._container,"leaflet-grab leaflet-touch-drag"),this._draggable.enable(),this._positions=[],this._times=[]},removeHooks:function(){z(this._map._container,"leaflet-grab"),z(this._map._container,"leaflet-touch-drag"),this._draggable.disable()},moved:function(){return this._draggable&&this._draggable._moved},moving:function(){return this._draggable&&this._draggable._moving},_onDragStart:function(){var t,e=this._map;e._stop(),this._map.options.maxBounds&&this._map.options.maxBoundsViscosity?(t=g(this._map.options.maxBounds),this._offsetLimit=_(this._map.latLngToContainerPoint(t.getNorthWest()).multiplyBy(-1),this._map.latLngToContainerPoint(t.getSouthEast()).multiplyBy(-1).add(this._map.getSize())),this._viscosity=Math.min(1,Math.max(0,this._map.options.maxBoundsViscosity))):this._offsetLimit=null,e.fire("movestart").fire("dragstart"),e.options.inertia&&(this._positions=[],this._times=[])},_onDrag:function(t){var e,i;this._map.options.inertia&&(e=this._lastTime=+new Date,i=this._lastPos=this._draggable._absPos||this._draggable._newPos,this._positions.push(i),this._times.push(e),this._prunePositions(e)),this._map.fire("move",t).fire("drag",t)},_prunePositions:function(t){for(;1e.max.x&&(t.x=this._viscousLimit(t.x,e.max.x)),t.y>e.max.y&&(t.y=this._viscousLimit(t.y,e.max.y)),this._draggable._newPos=this._draggable._startPos.add(t))},_onPreDragWrap:function(){var t=this._worldWidth,e=Math.round(t/2),i=this._initialWorldOffset,n=this._draggable._newPos.x,o=(n-e+i)%t+e-i,n=(n+e+i)%t-e-i,t=Math.abs(o+i)e.getMaxZoom()&&1 +
{{ counts.pending }}
Unassigned
+ +
+
{{ counts.assigned }}
Assigned
+
+
+
{{ counts.in_progress }}
In Progress
+
+
+
{{ counts.completed }}
Completed
+
+
+
{{ on_hold }}
On Hold
+
+
+
{{ failed }}
Failed
+
+
+
+
{{ counts.tech_available }}
Techs Active
+
+
+
{{ counts.tech_off }}
Off Duty
+
diff --git a/backend/templates/_job_table.html b/backend/templates/_job_table.html new file mode 100644 index 0000000..08af989 --- /dev/null +++ b/backend/templates/_job_table.html @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + {% for j in jobs %} + {% set slot_end_min = (j.time_slot_end[:2]|int * 60 + j.time_slot_end[3:]|int) if j.time_slot_end else None %} + {% set overdue = slot_end_min and now_minutes > slot_end_min and j.status.value not in ("completed", "cancelled") %} + + + + + + + + + + + + {% else %} + + {% endfor %} + +
JOB IDTYPESTATUSTECHPRICUSTOMERRTECADDRESSSLOT
{{ j.id }}{{ j.job_type.value }}{{ j.status.value|upper|replace('_', ' ') }}{{ j.assignment.technician.name if j.assignment else "—" }}{{ j.priority }}{{ j.customer_name }}{{ j.route_criteria or "—" }}{{ j.service_address }}{{ j.time_slot_start or "—" }}{% if j.time_slot_end %}–{{ j.time_slot_end }}{% endif %}
No jobs for today.
diff --git a/backend/templates/_override_modal.html b/backend/templates/_override_modal.html new file mode 100644 index 0000000..6836c73 --- /dev/null +++ b/backend/templates/_override_modal.html @@ -0,0 +1,29 @@ +{# Override warning modal — rendered into #modal-host when CanDo issues exist. #} + diff --git a/backend/templates/_sim_bar.html b/backend/templates/_sim_bar.html new file mode 100644 index 0000000..e7b2abd --- /dev/null +++ b/backend/templates/_sim_bar.html @@ -0,0 +1,39 @@ +{% if sim.is_demo %} +
+ DEMO + + {% if sim.is_paused %}■ PAUSED + {% elif sim.mode == 'real' %}■ STOPPED + {% else %}● RUNNING{% endif %} + + {% if sim.mode == 'real' %}--:--:--{% else %}{{ sim.hms }}{% endif %} + + {% if sim.mode == 'real' %} +
+ + +
+ {% else %} + {% if sim.is_paused %} + + {% else %} + + {% endif %} + + {% endif %} + +
+ Speed + +
+
+{% else %} +
+{% endif %} diff --git a/backend/templates/_tech_list.html b/backend/templates/_tech_list.html new file mode 100644 index 0000000..a32be71 --- /dev/null +++ b/backend/templates/_tech_list.html @@ -0,0 +1,33 @@ + + + + + + + + + + + + + {% for t in techs %} + {% set assigned = t.assignments|selectattr("job.status.value", "in", ["assigned", "in_progress"])|list|length %} + {% set completed = t.assignments|selectattr("job.status.value", "equalto", "completed")|list|length %} + + + + + + + + + {% else %} + + {% endfor %} + +
TECH IDNAMESTATUSSHIFTJOBS A:CROUTES
{{ t.employee_id or t.id }}{{ t.name }}{{ t.status.value|upper|replace('_', ' ') }}{{ t.shift_start }}–{{ t.shift_end }}{{ assigned }}:{{ completed }}{{ (t.assigned_routes or [])|join(', ') }}
No technicians.
diff --git a/backend/templates/_timeline.html b/backend/templates/_timeline.html new file mode 100644 index 0000000..c64b9cd --- /dev/null +++ b/backend/templates/_timeline.html @@ -0,0 +1,41 @@ +{# Timeline: rows of techs, each with assignment blocks. Pure SVG, no client lib. #} +{% set row_h = 22 %} +{% set name_w = 140 %} +{% set px_per_min = 1.2 %} {# 1.2px = 1 virtual minute → 540min × 1.2 = 648px wide #} +{% set chart_w = total_min * px_per_min %} +{% set svg_w = name_w + chart_w + 12 %} +{% set svg_h = (rows|length * row_h) + 28 %} +
+ + + {% for h in hours %} + {% set x = name_w + (loop.index0 * 60 * px_per_min) %} + + {{ "%02d:00"|format(h) }} + {% endfor %} + + + {% for r in rows %} + {% set y = 24 + (loop.index0 * row_h) %} + {{ r.tech.name }} + + {% for b in r.blocks %} + {% set bx = name_w + (b.offset_min * px_per_min) %} + {% set bw = b.width_min * px_per_min %} + + #{{ b.job_number }} {{ b.customer }} — ETA {{ b.eta_hms }} ({{ b.width_min|int }}m) + + {% if bw > 36 %} + #{{ b.job_number }} + {% endif %} + + {% endfor %} + {% endfor %} + + + {% if now_offset_min < total_min %} + {% set nx = name_w + (now_offset_min * px_per_min) %} + + {% endif %} + +
diff --git a/backend/templates/_win_filter.html b/backend/templates/_win_filter.html new file mode 100644 index 0000000..5e93cd8 --- /dev/null +++ b/backend/templates/_win_filter.html @@ -0,0 +1,71 @@ +
+

+ Filters apply live to the Jobs grid. Multi-select with checkboxes. +

+ +
+
+ Status + + + + +
+
+ {% for s in statuses %} + + {% endfor %} +
+
+ +
+
+ Job type + + + + +
+
+ {% for t in job_types %} + + {% endfor %} +
+
+ +
+
+ Route + + + + +
+
+ {% for r in routes %} + + {% endfor %} +
+
+ +
+
+ Technician + + + + +
+
+ + {% for t in techs %} + + {% endfor %} +
+
+ +
+ + +
+
diff --git a/backend/templates/_win_job_detail.html b/backend/templates/_win_job_detail.html new file mode 100644 index 0000000..7dafafa --- /dev/null +++ b/backend/templates/_win_job_detail.html @@ -0,0 +1,59 @@ +
+
+ {{ job.status.value|upper|replace('_', ' ') }} + {{ job.job_type.value }} + Priority {{ job.priority }} +
+ +
+
Customer
+
Name{{ job.customer_name }}
+ {% if job.customer_phone %}
Phone{{ job.customer_phone }}
{% endif %} + {% if job.customer_email %}
Email{{ job.customer_email }}
{% endif %} +
+ +
+
Service Location
+
Address{{ job.service_address }}
+
City / Zip{{ (job.service_city or '') }} {{ job.service_zip or '' }}
+
Route{{ job.route_criteria or '—' }}
+
Lat / Lon{{ "%.4f"|format(job.latitude) }}, {{ "%.4f"|format(job.longitude) }}
+
+ +
+
Schedule
+
Date{{ job.scheduled_date.strftime('%Y-%m-%d') if job.scheduled_date else '—' }}
+
Time Slot{% if job.time_slot_start and job.time_slot_end %}{{ job.time_slot_start }}–{{ job.time_slot_end }}{% else %}—{% endif %}
+
Duration{{ job.estimated_duration }} min
+
+ +
+
Assignment
+
+ Tech + {{ job.assignment.technician.name if job.assignment else 'Unassigned' }} +
+ {% if job.assignment %} +
ETA{{ job.assignment.estimated_arrival.strftime('%H:%M') if job.assignment.estimated_arrival else '—' }}
+
Travel{{ job.assignment.estimated_travel_time or '—' }} min · {{ "%.1f"|format(job.assignment.estimated_distance or 0) }} mi
+ {% if job.assignment.actual_duration_minutes %}
Sim duration{{ job.assignment.actual_duration_minutes }} min
{% endif %} + {% endif %} +
+ +
+
Required Skills
+
+ {% for s in (job.required_skills or []) %}{{ s }}{% else %}None{% endfor %} +
+
+ + {% if job.description %}
Description
{{ job.description }}
{% endif %} + {% if job.special_instructions %}
Special Instructions
{{ job.special_instructions }}
{% endif %} + {% if job.notes %}
Notes
{{ job.notes }}
{% endif %} + +
+
Created{{ job.created_at.strftime('%Y-%m-%d %H:%M') if job.created_at else '—' }}
+ {% if job.started_at %}
Started{{ job.started_at.strftime('%Y-%m-%d %H:%M') }}
{% endif %} + {% if job.completed_at %}
Completed{{ job.completed_at.strftime('%Y-%m-%d %H:%M') }}
{% endif %} +
+
diff --git a/backend/templates/_win_personnel.html b/backend/templates/_win_personnel.html new file mode 100644 index 0000000..94a1df4 --- /dev/null +++ b/backend/templates/_win_personnel.html @@ -0,0 +1,30 @@ +
+ + {{ techs|length }} / {{ techs|length }} +
+
+ + + + + + + + {% for t in techs %} + + + + + + + + + {% endfor %} + +
IDNAMESTATUSSHIFTSKILLSROUTES
{{ t.employee_id or t.id }}{{ t.name }}{{ t.status.value|upper|replace('_', ' ') }}{{ t.shift_start }}–{{ t.shift_end }}{{ (t.skills or [])|join(', ') }}{{ (t.assigned_routes or [])|join(', ') }}
+
diff --git a/backend/templates/_win_search.html b/backend/templates/_win_search.html new file mode 100644 index 0000000..4545ee5 --- /dev/null +++ b/backend/templates/_win_search.html @@ -0,0 +1,71 @@ +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + {% if any_criteria %}{{ jobs|length }} result{% if jobs|length != 1 %}s{% endif %}{% if jobs|length == 200 %} (limit){% endif %}{% endif %} +
+
+
+ {% if jobs %} + + + + + + {% for j in jobs %} + + + + + + + + + + + {% endfor %} + +
JOB IDDATESTATUSTYPEPRICUSTOMERRTECADDRESS
{{ j.id }}{{ j.scheduled_date.strftime('%Y-%m-%d') if j.scheduled_date else '—' }}{{ j.status.value|upper|replace('_', ' ') }}{{ j.job_type.value }}{{ j.priority }}{{ j.customer_name }}{{ j.route_criteria or '—' }}{{ j.service_address }}
+ {% elif any_criteria %} +
No matches.
+ {% else %} +
Enter criteria above and click Search.
Searches across all dates (limit 200).
+ {% endif %} +
diff --git a/backend/templates/_win_settings.html b/backend/templates/_win_settings.html new file mode 100644 index 0000000..57ad439 --- /dev/null +++ b/backend/templates/_win_settings.html @@ -0,0 +1,59 @@ +
+
+ + +
+ +
+ + +
+ + +
+ +
{{ sim.mode|upper }}{% if sim.is_demo %} · DEMO build{% endif %}
+
+ +
+ + + +
+ +
+ +
+
⌘/Ctrl+A Select all (in hovered pane)
+
Esc Clear selection / close modal
+
D Complete selected jobs
+
S Start selected jobs
+
C Cancel selected jobs
+
H Hold selected jobs
+
U Unassign selected jobs
+
R Refresh all panes
+
/ Open Job Search
+
F Open Filter
+
P Open Personnel
+
+
+
diff --git a/backend/templates/_win_tech_detail.html b/backend/templates/_win_tech_detail.html new file mode 100644 index 0000000..1238b48 --- /dev/null +++ b/backend/templates/_win_tech_detail.html @@ -0,0 +1,58 @@ +
+
+ {{ tech.status.value|upper|replace('_', ' ') }} + {{ tech.employee_id or '#' ~ tech.id }} +
+ +
+
Identity
+
Name{{ tech.name }}
+ {% if tech.phone %}
Phone{{ tech.phone }}
{% endif %} + {% if tech.email %}
Email{{ tech.email }}
{% endif %} +
+ +
+
Schedule
+
Shift{{ tech.shift_start }}–{{ tech.shift_end }}
+
Max jobs/day{{ tech.max_jobs_per_day }}
+
+ +
+
Home Base
+
Address{{ tech.home_address or '—' }}
+
Lat / Lon{{ "%.4f"|format(tech.home_latitude) }}, {{ "%.4f"|format(tech.home_longitude) }}
+
+ +
+
Skills
+
+ {% for s in (tech.skills or []) %}{{ s }}{% else %}None{% endfor %} +
+
+ +
+
Assigned Routes
+
+ {% for r in (tech.assigned_routes or []) %}{{ r }}{% else %}None{% endfor %} +
+
+ +
+
Today's Assignments ({{ assignments|length }})
+ {% if assignments %} + + + + {% for a in assignments %} + + + + + + + {% endfor %} + +
JOBETASTATUSCUST
{{ a.job.job_number or a.job.id }}{{ a.estimated_arrival.strftime('%H:%M') if a.estimated_arrival else '—' }}{{ a.job.status.value|upper|replace('_', ' ') }}{{ a.job.customer_name }}
+ {% else %}None today.{% endif %} +
+
diff --git a/backend/templates/base.html b/backend/templates/base.html new file mode 100644 index 0000000..be18069 --- /dev/null +++ b/backend/templates/base.html @@ -0,0 +1,16 @@ + + + + + + {% block title %}FieldOpt{% endblock %} + + + + + + + + {% block body %}{% endblock %} + + diff --git a/backend/templates/dashboard.html b/backend/templates/dashboard.html new file mode 100644 index 0000000..fe8a1f2 --- /dev/null +++ b/backend/templates/dashboard.html @@ -0,0 +1,905 @@ +{% extends "base.html" %} +{% block title %}FieldOpt — Dispatch{% endblock %} +{% block body %} +
+ + +
+
+
+ FieldOpt +
+ --:--:-- + + +
+ + + +
+ +
+ + + + + + + + +
+
+ + + {% if sim.is_demo %} +
+ {% include "_sim_bar.html" %} +
+ {% endif %} + + +
+ {% include "_dash_bar.html" %} +
+ + +
+ +
+
+ Technicians + {{ techs|length }} + +
+
+ {% include "_tech_list.html" %} +
+
+ +
+ +
+
+ Jobs + {{ jobs|length }} + +
+ {# Hidden status input — driven by dash-bar status cells. Filter window covers type/tech/route client-side. #} + +
+ {% include "_job_table.html" %} +
+
+ +
+ + + +
+
+ + + + +
+
+ + + + + +{% endblock %} diff --git a/backend/templates/map_window.html b/backend/templates/map_window.html new file mode 100644 index 0000000..f79dd96 --- /dev/null +++ b/backend/templates/map_window.html @@ -0,0 +1,68 @@ + + + + + FieldOpt — Map + + + + + + +
+ + + diff --git a/db/functions/00_media.sql b/db/functions/00_media.sql new file mode 100644 index 0000000..ce231d1 --- /dev/null +++ b/db/functions/00_media.sql @@ -0,0 +1,11 @@ +-- Media type handler: a domain named after a media type makes PostgREST serve a +-- function's scalar result raw with that Content-Type (when the request Accepts it). +-- The HTMX UI must send `Accept: text/html` (htmx:configRequest hook) — its default +-- `*/*` negotiates to JSON. +do $$ +begin + create domain "text/html" as text; +exception + when duplicate_object then null; +end +$$; diff --git a/db/functions/01_helpers.sql b/db/functions/01_helpers.sql new file mode 100644 index 0000000..5660217 --- /dev/null +++ b/db/functions/01_helpers.sql @@ -0,0 +1,23 @@ +-- Shared render helpers for the api.* HTML fragment functions. + +-- Escape a string for safe interpolation into HTML text/attributes. +-- Ampersand first so later entities aren't double-escaped. +create or replace function api.html_escape(s text) +returns text language sql immutable as $$ + select replace(replace(replace(replace(replace( + coalesce(s, ''), + '&', '&'), '<', '<'), '>', '>'), '"', '"'), '''', '''); +$$; + +-- Render a double as Python's str(float) does. The only divergence from +-- Postgres float8out is integer-valued floats: Python str(140.0) = '140.0', +-- Postgres 140.0::text = '140'. Both use shortest round-trip repr otherwise, +-- so non-integers pass through unchanged. Needed for SVG coord parity with +-- Jinja-rendered templates (e.g. _timeline.html). +create or replace function api.py_float(x double precision) +returns text language sql immutable as $$ + select case + when x = trunc(x) and abs(x) < 1e16 then trunc(x)::bigint::text || '.0' + else x::text + end; +$$; diff --git a/db/functions/02_clock.sql b/db/functions/02_clock.sql new file mode 100644 index 0000000..a52004b --- /dev/null +++ b/db/functions/02_clock.sql @@ -0,0 +1,48 @@ +-- Pg-native virtual clock (replaces backend/simulation/clock.py singleton). +-- Path C REDESIGNS the clock: the Python continuous wall*speed interpolation is dropped +-- for a DISCRETE stepped clock — a stored virtual_now that api.sim_tick() advances. +-- The tick is SCHEDULER-AGNOSTIC (pg_cron OR external cron OR sidecar just CALL it), because +-- pg_cron isn't in postgres:15-alpine and managed PG may lack it (see release-packaging plan). + +-- Single-row clock state. Seeded with one row (defaults = REAL mode). +create table if not exists api.sim_state ( + id boolean primary key default true check (id), -- enforces at most one row + virtual_now timestamptz +); +-- Phase 4 columns (idempotent add for existing deployments). +alter table api.sim_state add column if not exists mode text not null default 'real'; -- 'real'|'simulated' +alter table api.sim_state add column if not exists is_paused boolean not null default false; +alter table api.sim_state add column if not exists speed double precision not null default 1.0; -- virtual seconds per wall second +insert into api.sim_state (id) values (true) on conflict do nothing; + +-- Current virtual time. REAL mode -> wall clock; SIMULATED -> stored virtual_now. +-- Falls back to now() when no row / no virtual_now (keeps render ports diffing clean in real mode). +create or replace function api.sim_now() +returns timestamptz language sql stable as $$ + select coalesce( + (select case when mode = 'simulated' then virtual_now else now() end from api.sim_state limit 1), + now()); +$$; + +-- Advance the virtual clock by (speed * elapsed-wall-seconds). Called by whatever scheduler +-- is wired (pg_cron every ~10s -> sim_tick(10)); no-op unless simulated and not paused. +-- SECURITY DEFINER so an unprivileged scheduler role can drive it. +create or replace function api.sim_tick(p_wall_seconds double precision default 10) +returns void language sql volatile security definer set search_path = api, public as $$ + update api.sim_state + set virtual_now = virtual_now + make_interval(secs => speed * p_wall_seconds) + where mode = 'simulated' and not is_paused and virtual_now is not null; +$$; + +-- Deploy config (NOT runtime state): whether this is a demo build. GUC set per-deploy via +-- `ALTER DATABASE SET app.is_demo = 'true'` (raw + docker share the same mechanism). +-- Runtime clock state lives in sim_state; this constant governs whether the sim UI exists. +create or replace function api.is_demo() +returns boolean language sql stable as $$ + select coalesce(current_setting('app.is_demo', true)::boolean, false); +$$; + +grant select on api.sim_state to web_anon; +grant execute on function api.sim_now() to web_anon; +grant execute on function api.sim_tick(double precision) to web_anon; +grant execute on function api.is_demo() to web_anon; diff --git a/db/functions/10_tech_list.sql b/db/functions/10_tech_list.sql new file mode 100644 index 0000000..aee950f --- /dev/null +++ b/db/functions/10_tech_list.sql @@ -0,0 +1,46 @@ +-- Port of backend/templates/_tech_list.html — the polled techs fragment. +-- Mirrors the FastAPI /techs route: all technicians, ordered by name. +-- DB enum columns are UPPERCASE (status='AVAILABLE'); pill class + data attr use lowercase. +create or replace function api.tech_list() +returns "text/html" language sql stable as $$ + select + '' + || '' + || '' + || '' + || coalesce( + string_agg(row_html, '' order by name), + '') + || '
TECH IDNAMESTATUSSHIFTJOBS A:CROUTES
No technicians.
' + from ( + select + t.name, + '
' || api.html_escape(coalesce(t.employee_id, t.id::text)) || '' || api.html_escape(t.name) || '' + || replace(t.status::text, '_', ' ') || '' || coalesce(t.shift_start, '') || '–' || coalesce(t.shift_end, '') || '' || cnt.assigned || ':' || cnt.completed || '' || coalesce( + (select string_agg(api.html_escape(value), ', ') + from jsonb_array_elements_text(t.assigned_routes)), '') || '
' || j.id || '' || lower(j.job_type::text) || '' + || replace(j.status::text, '_', ' ') || '' || case when a.id is null then '—' else api.html_escape(t.name) end || '' || j.priority || '' || api.html_escape(j.customer_name) || '' || coalesce(api.html_escape(nullif(j.route_criteria, '')), '—') || '' || api.html_escape(j.service_address) || '' || coalesce(nullif(j.time_slot_start, ''), '—') + || case when nullif(j.time_slot_end, '') is not null + then '–' || j.time_slot_end else '' end || '
' + || '' + || '' + || '' + || coalesce( + string_agg(row_html, '' order by ord_slot asc nulls first, ord_id), + '') + || '
JOB IDTYPESTATUSTECHPRICUSTOMERRTECADDRESSSLOT
No jobs for today.
' + from rows; +$$; + +grant execute on function api.job_table(text, text, text, text) to web_anon; diff --git a/db/functions/13_timeline.sql b/db/functions/13_timeline.sql new file mode 100644 index 0000000..f6bcd3e --- /dev/null +++ b/db/functions/13_timeline.sql @@ -0,0 +1,97 @@ +-- Port of backend/templates/_timeline.html + _timeline_rows/timeline_fragment. +-- Pure-SVG per-tech assignment timeline. Constants mirror the template exactly: +-- row_h=22, name_w=140, px_per_min=1.2 -> chart_w=540*1.2=648, svg_w=800.0 (const). +-- Coordinates are floats -> api.py_float() reproduces Python's str(float) '.0' repr. +-- Blocks: eta = estimated_arrival or scheduled_date; offset from 08:00; width>=15; +-- ordered by offset_min within a tech; techs ordered by name. +-- p_selected: optional comma-separated tech ids to keep (mirrors ?selected=). +create or replace function api.timeline(p_selected text default '') +returns "text/html" language sql stable as $$ + with clk as ( + select api.sim_now() as t + ), + d as ( + select + date_trunc('day', t) as day_start, + greatest(0, + (extract(hour from (t at time zone 'UTC'))::int - 8) * 60 + + extract(minute from (t at time zone 'UTC'))::int) as now_off + from clk + ), + techs as ( + select id, name, (row_number() over (order by name) - 1)::int as idx + from technicians + where is_active + and (p_selected = '' or id = any ( + string_to_array(p_selected, ',')::int[])) + ), + nrows as ( + select count(*)::int as n, count(*)::int * 22 + 28 as svg_h from techs + ), + -- per-block html, keyed to its tech row's y-position (needs techs.idx) + blk as ( + select + tk.idx, + bo.offset_min, + '' + || '#' || api.html_escape(coalesce(nullif(j.job_number, ''), j.id::text)) + || ' ' || api.html_escape(j.customer_name) + || ' — ETA ' || to_char(bo.eta at time zone 'UTC', 'HH24:MI') + || ' (' || trunc(bo.width_min)::bigint || 'm)' + || '' + || case when bo.width_min * 1.2 > 36 then + '#' + || api.html_escape(coalesce(nullif(j.job_number, ''), j.id::text)) || '' + else '' end + || '' as block_html + from assignments a + join jobs j on j.id = a.job_id + join techs tk on tk.id = a.technician_id + cross join d + cross join lateral ( + select + coalesce(a.estimated_arrival, j.scheduled_date) as eta, + greatest(0, extract(epoch from + (coalesce(a.estimated_arrival, j.scheduled_date) - d.day_start)) / 60.0 - 480) as offset_min, + greatest(15, coalesce(a.actual_duration_minutes, j.estimated_duration, 60)::float8) as width_min + ) bo + where j.scheduled_date >= d.day_start + and j.scheduled_date < d.day_start + interval '1 day' + and coalesce(a.estimated_arrival, j.scheduled_date) is not null + ) + select + '
' + || '' + || '' + || (select string_agg( + '' + || '' + || to_char(8 + g.i, 'FM00') || ':00', + '' order by g.i) + from generate_series(0, 9) as g(i)) + || '' + || coalesce((select string_agg( + '' + || api.html_escape(tk.name) || '' + || '' + || coalesce((select string_agg(b.block_html, '' order by b.offset_min) + from blk b where b.idx = tk.idx), ''), + '' order by tk.idx) + from techs tk), '') + || '' + || case when d.now_off < 540 then + '' + else '' end + || '
' + from nrows, d; +$$; + +grant execute on function api.timeline(text) to web_anon; diff --git a/db/functions/14_win_personnel.sql b/db/functions/14_win_personnel.sql new file mode 100644 index 0000000..904e4c4 --- /dev/null +++ b/db/functions/14_win_personnel.sql @@ -0,0 +1,46 @@ +-- Port of backend/templates/_win_personnel.html — the Personnel floating window. +-- Mirrors FastAPI /window/personnel: all technicians ordered by name, read-only. +-- DB enum columns UPPERCASE; template emits lowercase .value for pill class + data attrs. +-- Jinja autoescape ON -> html_escape mirrors it. skills/assigned_routes are jsonb arrays +-- joined with ', ' (array order preserved by jsonb_array_elements_text). +create or replace function api.personnel_window() +returns "text/html" language sql stable as $$ + with rows as ( + select + t.name, + '' + || '' || api.html_escape(coalesce(nullif(t.employee_id, ''), t.id::text)) || '' + || '' || api.html_escape(t.name) || '' + || '' + || replace(t.status::text, '_', ' ') || '' + || '' || coalesce(t.shift_start, '') || '–' || coalesce(t.shift_end, '') || '' + || '' + || coalesce((select string_agg(api.html_escape(value), ', ') + from jsonb_array_elements_text(t.skills) value), '') || '' + || '' + || coalesce((select string_agg(api.html_escape(value), ', ') + from jsonb_array_elements_text(t.assigned_routes) value), '') || '' + || '' as row_html + from technicians t + ), + c as (select count(*) as cnt from technicians) + select + '
' + || '' + || '' || c.cnt || ' / ' || c.cnt || '' + || '
' + || '
' + || '' + || '' + || coalesce((select string_agg(row_html, '' order by name) from rows), '') + || '
IDNAMESTATUSSHIFTSKILLSROUTES
' + from c; +$$; + +grant execute on function api.personnel_window() to web_anon; diff --git a/db/functions/15_win_tech_detail.sql b/db/functions/15_win_tech_detail.sql new file mode 100644 index 0000000..be3436a --- /dev/null +++ b/db/functions/15_win_tech_detail.sql @@ -0,0 +1,91 @@ +-- Port of backend/templates/_win_tech_detail.html — Tech Detail floating window. +-- Mirrors FastAPI /window/tech/{tech_id}: one tech + today's assignments (this tech), +-- ordered by estimated_arrival nulls first. Missing tech -> the not-found body (FastAPI +-- sends it with 404; PostgREST returns 200 + same body — status handled at the proxy). +-- Chips render each element, or a "None" span when the jsonb array is empty. +-- Lat/Lon use Python "%.4f" -> to_char FM9990.0000 (coords are |x|>=1, no leading-zero gap). +create or replace function api.tech_detail(p_tech_id integer) +returns "text/html" language sql stable as $$ + select coalesce( + (select + '
' + || '
' + || '' + || replace(t.status::text, '_', ' ') || '' + || '' + || api.html_escape(coalesce(nullif(t.employee_id, ''), '#' || t.id)) || '' + || '
' + -- Identity + || '
Identity
' + || '
Name' + || api.html_escape(t.name) || '
' + || case when nullif(t.phone, '') is not null then + '
Phone' + || api.html_escape(t.phone) || '
' else '' end + || case when nullif(t.email, '') is not null then + '
Email' + || api.html_escape(t.email) || '
' else '' end + || '
' + -- Schedule + || '
Schedule
' + || '
Shift' + || coalesce(t.shift_start, '') || '–' || coalesce(t.shift_end, '') || '
' + || '
Max jobs/day' + || t.max_jobs_per_day || '
' + || '
' + -- Home Base + || '
Home Base
' + || '
Address' + || coalesce(api.html_escape(nullif(t.home_address, '')), '—') || '
' + || '
Lat / Lon' + || to_char(t.home_latitude, 'FM9990.0000') || ', ' + || to_char(t.home_longitude, 'FM9990.0000') || '
' + || '
' + -- Skills + || '
Skills
' + || coalesce((select string_agg('' || api.html_escape(value) || '', '' order by ord) + from jsonb_array_elements_text(t.skills) with ordinality x(value, ord)), + 'None') + || '
' + -- Assigned Routes + || '
Assigned Routes
' + || coalesce((select string_agg('' || api.html_escape(value) || '', '' order by ord) + from jsonb_array_elements_text(t.assigned_routes) with ordinality x(value, ord)), + 'None') + || '
' + -- Today's Assignments + || '
Today''s Assignments (' + || asg.cnt || ')
' + || case when asg.cnt > 0 then + '' + || '' + || asg.rows || '
JOBETASTATUSCUST
' + else 'None today.' end + || '
' + || '
' + from technicians t + cross join lateral ( + select + count(*) as cnt, + string_agg( + '' + || '' || api.html_escape(coalesce(nullif(j.job_number, ''), j.id::text)) || '' + || '' || coalesce(to_char(a.estimated_arrival at time zone 'UTC', 'HH24:MI'), '—') || '' + || '' + || replace(j.status::text, '_', ' ') || '' + || '' || api.html_escape(j.customer_name) || '' + || '', + '' order by a.estimated_arrival asc nulls first) as rows + from assignments a + join jobs j on j.id = a.job_id + cross join (select date_trunc('day', api.sim_now()) as day_start) dd + where a.technician_id = t.id + and j.scheduled_date >= dd.day_start + and j.scheduled_date < dd.day_start + interval '1 day' + ) asg + where t.id = p_tech_id), + '
Tech not found.
'); +$$; + +grant execute on function api.tech_detail(integer) to web_anon; diff --git a/db/functions/16_win_job_detail.sql b/db/functions/16_win_job_detail.sql new file mode 100644 index 0000000..9c55c40 --- /dev/null +++ b/db/functions/16_win_job_detail.sql @@ -0,0 +1,100 @@ +-- Port of backend/templates/_win_job_detail.html — Job Detail floating window. +-- Mirrors FastAPI /window/job/{job_id}: one job + its (0..1) assignment/tech. +-- Missing job -> not-found body (FastAPI 404 / PostgREST 200+body; status is proxy-layer). +-- Conditional sections render only when the field is truthy (non-null AND non-empty/non-zero, +-- matching Jinja truthiness): phone/email/desc/notes/timestamps, travel-time `or '—'` (0 -> '—'). +-- Python "%.4f"/"%.1f" -> to_char FM990.0000/FM990.0 (the forced 0 before '.' keeps leading zero). +create or replace function api.job_detail(p_job_id integer) +returns "text/html" language sql stable as $$ + select coalesce( + (select + '
' + || '
' + || '' + || replace(j.status::text, '_', ' ') || '' + || '' || lower(j.job_type::text) || '' + || 'Priority ' || j.priority || '' + || '
' + -- Customer + || '
Customer
' + || '
Name' + || api.html_escape(j.customer_name) || '
' + || case when nullif(j.customer_phone, '') is not null then + '
Phone' + || api.html_escape(j.customer_phone) || '
' else '' end + || case when nullif(j.customer_email, '') is not null then + '
Email' + || api.html_escape(j.customer_email) || '
' else '' end + || '
' + -- Service Location + || '
Service Location
' + || '
Address' + || api.html_escape(j.service_address) || '
' + || '
City / Zip' + || api.html_escape(coalesce(j.service_city, '')) || ' ' || api.html_escape(coalesce(j.service_zip, '')) || '
' + || '
Route' + || coalesce(api.html_escape(nullif(j.route_criteria, '')), '—') || '
' + || '
Lat / Lon' + || to_char(j.latitude, 'FM9990.0000') || ', ' || to_char(j.longitude, 'FM9990.0000') || '
' + || '
' + -- Schedule + || '
Schedule
' + || '
Date' + || coalesce(to_char(j.scheduled_date at time zone 'UTC', 'YYYY-MM-DD'), '—') || '
' + || '
Time Slot' + || case when nullif(j.time_slot_start, '') is not null and nullif(j.time_slot_end, '') is not null + then j.time_slot_start || '–' || j.time_slot_end else '—' end || '
' + || '
Duration' + || j.estimated_duration || ' min
' + || '
' + -- Assignment + || '
Assignment
' + || '
Tech' + || case when a.id is null then 'Unassigned' else api.html_escape(tk.name) end || '
' + || case when a.id is not null then + '
ETA' + || coalesce(to_char(a.estimated_arrival at time zone 'UTC', 'HH24:MI'), '—') || '
' + || '
Travel' + || case when coalesce(a.estimated_travel_time, 0) = 0 then '—' else a.estimated_travel_time::text end + || ' min · ' || to_char(coalesce(a.estimated_distance, 0), 'FM990.0') || ' mi
' + || case when coalesce(a.actual_duration_minutes, 0) <> 0 then + '
Sim duration' + || a.actual_duration_minutes || ' min
' else '' end + else '' end + || '
' + -- Required Skills + || '
Required Skills
' + || coalesce((select string_agg('' || api.html_escape(value) || '', '' order by ord) + from jsonb_array_elements_text(j.required_skills) with ordinality x(value, ord)), + 'None') + || '
' + -- Optional free-text sections + || case when nullif(j.description, '') is not null then + '
Description
' + || api.html_escape(j.description) || '
' else '' end + || case when nullif(j.special_instructions, '') is not null then + '
Special Instructions
' + || '
' || api.html_escape(j.special_instructions) || '
' else '' end + || case when nullif(j.notes, '') is not null then + '
Notes
' + || api.html_escape(j.notes) || '
' else '' end + -- Timestamps + || '
' + || '
Created' + || coalesce(to_char(j.created_at at time zone 'UTC', 'YYYY-MM-DD HH24:MI'), '—') || '
' + || case when j.started_at is not null then + '
Started' + || to_char(j.started_at at time zone 'UTC', 'YYYY-MM-DD HH24:MI') || '
' else '' end + || case when j.completed_at is not null then + '
Completed' + || to_char(j.completed_at at time zone 'UTC', 'YYYY-MM-DD HH24:MI') || '
' else '' end + || '
' + || '
' + from jobs j + left join assignments a on a.job_id = j.id + left join technicians tk on tk.id = a.technician_id + where j.id = p_job_id), + '
Job not found.
'); +$$; + +grant execute on function api.job_detail(integer) to web_anon; diff --git a/db/functions/17_win_filter.sql b/db/functions/17_win_filter.sql new file mode 100644 index 0000000..edacd90 --- /dev/null +++ b/db/functions/17_win_filter.sql @@ -0,0 +1,69 @@ +-- Port of backend/templates/_win_filter.html — Filter floating window. +-- Mirrors FastAPI /window/filter: status/job_type from enum .value lists (enum_range gives +-- definition order == Python enum iteration order; lower() == .value), routes = distinct +-- route_criteria over TODAY's jobs sorted asc, techs all by name. All checkboxes checked. +create or replace function api.filter_window() +returns "text/html" language sql stable as $$ + with d as (select date_trunc('day', api.sim_now()) as day_start) + select + '
' + || '

' + || 'Filters apply live to the Jobs grid. Multi-select with checkboxes.

' + -- Status + || '
Status' + || '' + || '
' + || '
' + || (select string_agg( + '', + '' order by ord) + from unnest(enum_range(null::public.jobstatus)) with ordinality e(s, ord), + lateral (select lower(s::text) as v) lv) + || '
' + -- Job type + || '
Job type' + || '' + || '
' + || '
' + || (select string_agg( + '', + '' order by ord) + from unnest(enum_range(null::public.jobtype)) with ordinality e(t, ord), + lateral (select lower(t::text) as v) lv) + || '
' + -- Route + || '
Route' + || '' + || '
' + || '
' + || coalesce((select string_agg( + '', + '' order by rc) + from ( + select distinct j.route_criteria as rc + from jobs j, d + where j.scheduled_date >= d.day_start + and j.scheduled_date < d.day_start + interval '1 day' + and nullif(j.route_criteria, '') is not null + ) r), '') + || '
' + -- Technician + || '
Technician' + || '' + || '
' + || '
' + || '' + || coalesce((select string_agg( + '', + '' order by tk.name) + from technicians tk), '') + || '
' + -- Footer buttons + || '
' + || '' + || '' + || '
' + || '
'; +$$; + +grant execute on function api.filter_window() to web_anon; diff --git a/db/functions/18_win_search.sql b/db/functions/18_win_search.sql new file mode 100644 index 0000000..422a916 --- /dev/null +++ b/db/functions/18_win_search.sql @@ -0,0 +1,120 @@ +-- Port of backend/templates/_win_search.html — Search floating window. +-- Mirrors FastAPI /window/search: a criteria form (echoes the 9 params back into inputs/ +-- selects) + a results table shown only when any criterion is set. Query filters mirror +-- search_window(): q -> ilike over job_number/customer/address; date_from/to; job_id/tech_id +-- (non-int -> no filter, matching Python's ValueError pass); status/job_type via enum upper; +-- route exact. Order scheduled_date desc nulls last, id desc, limit 200. +create or replace function api.search_window( + p_q text default '', + p_date_from text default '', + p_date_to text default '', + p_job_id text default '', + p_tech_id text default '', + p_customer text default '', + p_status text default '', + p_job_type text default '', + p_route text default '' +) +returns "text/html" language sql stable as $$ + with flags as ( + select (p_q <> '' or p_date_from <> '' or p_date_to <> '' or p_job_id <> '' + or p_tech_id <> '' or p_customer <> '' or p_status <> '' or p_job_type <> '' + or p_route <> '') as anyc + ), + res as ( + select j.scheduled_date as sd, j.id as jid, + '' + || '' || j.id || '' + || '' || coalesce(to_char(j.scheduled_date at time zone 'UTC', 'YYYY-MM-DD'), '—') || '' + || '' + || replace(j.status::text, '_', ' ') || '' + || '' || lower(j.job_type::text) || '' + || '' || j.priority || '' + || '' || api.html_escape(j.customer_name) || '' + || '' || coalesce(api.html_escape(nullif(j.route_criteria, '')), '—') || '' + || '' || api.html_escape(j.service_address) || '' + || '' as row_html + from jobs j, flags + where flags.anyc + and (p_q = '' or (j.job_number ilike '%' || p_q || '%' + or j.customer_name ilike '%' || p_q || '%' + or j.service_address ilike '%' || p_q || '%')) + and (p_date_from = '' or j.scheduled_date >= (p_date_from::timestamp at time zone 'UTC')) + and (p_date_to = '' or j.scheduled_date < (p_date_to::timestamp at time zone 'UTC') + interval '1 day') + and (p_job_id = '' or p_job_id !~ '^[0-9]+$' or j.id = p_job_id::int) + and (p_customer = '' or j.customer_name ilike '%' || p_customer || '%') + and (p_status = '' or j.status::text = upper(p_status)) + and (p_job_type = '' or j.job_type::text = upper(p_job_type)) + and (p_route = '' or j.route_criteria = p_route) + and (p_tech_id = '' or p_tech_id !~ '^[0-9]+$' + or j.id in (select a.job_id from assignments a where a.technician_id = p_tech_id::int)) + order by j.scheduled_date desc nulls last, j.id desc + limit 200 + ), + agg as ( + select count(*)::int as cnt, + coalesce(string_agg(row_html, '' order by sd desc nulls last, jid desc), '') as rows_html + from res + ) + select + '
' + || '
' + || '' + || '' + || '' + || '
' + || '
' + || '' + || '' + || '' + || '
' + || '
' + || '' + || '' + || '' + || '
' + || '
' + || '' + || '' + || case when flags.anyc then + '' || agg.cnt || ' result' + || case when agg.cnt <> 1 then 's' else '' end + || case when agg.cnt = 200 then ' (limit)' else '' end || '' + else '' end + || '
' + || '
' + || '
' + || case + when agg.cnt > 0 then + '' + || '' + || agg.rows_html || '
JOB IDDATESTATUSTYPEPRICUSTOMERRTECADDRESS
' + when flags.anyc then '
No matches.
' + else '
Enter criteria above and click Search.
Searches across all dates (limit 200).
' + end + || '
' + from flags, agg; +$$; + +grant execute on function api.search_window(text, text, text, text, text, text, text, text, text) to web_anon; diff --git a/db/functions/19_map_markers.sql b/db/functions/19_map_markers.sql new file mode 100644 index 0000000..015c0cd --- /dev/null +++ b/db/functions/19_map_markers.sql @@ -0,0 +1,76 @@ +-- Port of FastAPI /map/markers — native JSON (no text/html domain). Consumed by the +-- Leaflet map JS, so the contract is the JSON STRUCTURE/VALUES, not byte layout. +-- Uses json (not jsonb) to keep float8 shortest-repr for coords. +-- jobs: today's jobs (ordered like _todays_jobs). techs: active techs, lat/lng = +-- current_* or home_* (Python `or` -> non-null AND non-zero, else home; home is NOT NULL). +-- routes: per active tech WITH assignments, path = [tech point] + job points ordered by ETA; +-- done_count = completed/cancelled legs. job_number is str when set else the int id (mixed type). +create or replace function api.map_markers() +returns json language sql stable as $$ + with d as (select date_trunc('day', api.sim_now()) as day_start), + tj as ( + select j.* from jobs j, d + where j.scheduled_date >= d.day_start + and j.scheduled_date < d.day_start + interval '1 day' + ), + active as (select * from technicians where is_active), + asg as ( + select a.technician_id, a.estimated_arrival, + j2.latitude as jlat, j2.longitude as jlng, j2.scheduled_date as jsd, j2.status as jstatus + from assignments a join jobs j2 on j2.id = a.job_id, d + where j2.scheduled_date >= d.day_start + and j2.scheduled_date < d.day_start + interval '1 day' + ) + select json_build_object( + 'jobs', coalesce(( + select json_agg(json_build_object( + 'id', j.id, + 'job_number', case when nullif(j.job_number, '') is not null + then to_json(j.job_number) else to_json(j.id) end, + 'customer', j.customer_name, + 'address', j.service_address, + 'status', lower(j.status::text), + 'lat', j.latitude, + 'lng', j.longitude + ) order by j.time_slot_start asc nulls first, j.id) + from tj j), '[]'::json), + 'techs', coalesce(( + select json_agg(json_build_object( + 'id', t.id, + 'name', t.name, + 'status', lower(t.status::text), + 'lat', case when t.current_latitude is not null and t.current_latitude <> 0 + then t.current_latitude else t.home_latitude end, + 'lng', case when t.current_longitude is not null and t.current_longitude <> 0 + then t.current_longitude else t.home_longitude end + )) + from active t + where (case when t.current_latitude is not null and t.current_latitude <> 0 + then t.current_latitude else t.home_latitude end) is not null), '[]'::json), + 'routes', coalesce(( + select json_agg(json_build_object( + 'tech_id', t.id, + 'tech_name', t.name, + 'path', ( + select json_agg(pt order by ord) + from ( + select 0 as ord, json_build_array( + case when t.current_latitude is not null and t.current_latitude <> 0 + then t.current_latitude else t.home_latitude end, + case when t.current_longitude is not null and t.current_longitude <> 0 + then t.current_longitude else t.home_longitude end) as pt + union all + select row_number() over (order by coalesce(a.estimated_arrival, a.jsd)) as ord, + json_build_array(a.jlat, a.jlng) as pt + from asg a where a.technician_id = t.id + ) pts + ), + 'done_count', (select count(*) from asg a + where a.technician_id = t.id and lower(a.jstatus::text) in ('completed', 'cancelled')) + )) + from active t + where exists (select 1 from asg a where a.technician_id = t.id)), '[]'::json) + ); +$$; + +grant execute on function api.map_markers() to web_anon; diff --git a/db/functions/20_assign.sql b/db/functions/20_assign.sql new file mode 100644 index 0000000..5596289 --- /dev/null +++ b/db/functions/20_assign.sql @@ -0,0 +1,122 @@ +-- Port of POST /assign (backend/api/routes/htmx.py:260) — the assignment write path. +-- SLICE 1 (this file, current): candoo skill/route checks + override modal (deterministic, +-- byte-verifiable) AND the mutation (reassign/create + toast + HX-Trigger refreshAll). +-- Deferred (Phase 4, flagged): demo-lock (needs sim clock mode/paused) — skipped = real mode, +-- matches non-demo app; actual_duration_minutes = sample_duration (numpy PCG64 lognormal on +-- Python tuple-hash seed) is NOT reproducible in SQL and is sim-internal -> stored NULL here, +-- Phase 4 sim owns duration. estimated_arrival uses api.sim_now() (live -> not byte-comparable, +-- but not in any response). haversine + travel_time are deterministic and match Python. +-- _candoo_issues: skill missing = required_skills not in tech.skills (order preserved); +-- route pass = job has NO route_criteria (advisory-permissive, mirrors Slice-6 backend). +-- Toast msg is raw (FastAPI f-string, NOT autoescaped); modal IS autoescaped (template). +-- SECURITY DEFINER: the write runs as the function owner (owns the tables); web_anon gets +-- only EXECUTE, never direct table writes. search_path pinned to avoid definer hijack. +create or replace function api.assign(p_job_id integer, p_tech_id integer, p_override integer default 0) +returns "text/html" language plpgsql volatile +security definer set search_path = api, public as $fn$ +declare + j jobs%rowtype; + t technicians%rowtype; + missing text[]; + skill_pass boolean; + route_pass boolean; + skill_label text; + route_label text; + num text; + existing_tid integer; + olat double precision; + olon double precision; + dist double precision; + travel integer; +begin + select * into j from jobs where id = p_job_id; + if not found then raise exception 'Job or tech not found' using errcode = 'P0002'; end if; + select * into t from technicians where id = p_tech_id; + if not found then raise exception 'Job or tech not found' using errcode = 'P0002'; end if; + + num := coalesce(nullif(j.job_number, ''), j.id::text); + + -- ── CanDo checks ──────────────────────────────────────────────────────── + missing := array( + select s from jsonb_array_elements_text(coalesce(j.required_skills, '[]'::jsonb)) s + where s not in (select jsonb_array_elements_text(coalesce(t.skills, '[]'::jsonb))) + ); + skill_pass := array_length(missing, 1) is null; + skill_label := 'Skill' || case when not skill_pass + then ' (missing: ' || array_to_string(missing, ', ') || ')' else '' end; + route_pass := nullif(j.route_criteria, '') is null; + route_label := 'Route' || case when route_pass then '' else ' (' || j.route_criteria || ')' end; + + -- ── Not overriding + issues -> return override modal (no write) ────────── + if p_override = 0 and (not skill_pass or not route_pass) then + return + $html$$html$; + end if; + + -- ── Mutation ──────────────────────────────────────────────────────────── + select technician_id into existing_tid from assignments where job_id = p_job_id; + if found then + if existing_tid = p_tech_id then + return api.toast('Already on ' || t.name, 'warning'); + else + -- unassign_job: delete + revert to PENDING (re-set to ASSIGNED below) + delete from assignments where job_id = p_job_id; + update jobs set status = 'PENDING' where id = p_job_id; + end if; + end if; + + olat := coalesce(t.current_latitude, t.home_latitude); + olon := coalesce(t.current_longitude, t.home_longitude); + dist := 3959.0 * 2 * asin(sqrt( + sin(radians(j.latitude - olat) / 2) ^ 2 + + cos(radians(olat)) * cos(radians(j.latitude)) * sin(radians(j.longitude - olon) / 2) ^ 2)); + travel := trunc(dist / 30.0 * 60)::int; + + insert into assignments (job_id, technician_id, sequence, estimated_distance, + estimated_travel_time, actual_duration_minutes, estimated_arrival, assigned_at, created_at, updated_at) + values (p_job_id, p_tech_id, null, dist, travel, null, + api.sim_now() + make_interval(mins => travel), now(), now(), now()); + + update jobs set status = 'ASSIGNED' where id = p_job_id; + update technicians set status = 'EN_ROUTE' where id = p_tech_id and status = 'AVAILABLE'; + + -- htmx: re-fetch dependent panels (mirrors HX-Trigger: refreshAll) + perform set_config('response.headers', '[{"HX-Trigger": "refreshAll"}]', true); + return api.toast('Job #' || num || ' → ' || t.name, 'success'); +end; +$fn$; + +-- OOB toast helper (raw msg — FastAPI builds it via f-string, not autoescaped). +create or replace function api.toast(msg text, kind text default 'success') +returns "text/html" language sql immutable as $$ + select '
' || msg || '
'; +$$; + +-- Port of POST /unassign (htmx.py:313) — unassign_job + toast. Note the toast uses the +-- job_id param (NOT job_number, unlike assign's success toast). Demo-lock deferred (Phase 4). +create or replace function api.unassign(p_job_id integer) +returns "text/html" language plpgsql volatile +security definer set search_path = api, public as $fn$ +begin + delete from assignments where job_id = p_job_id; + if not found then + return api.toast('Job not assigned', 'warning'); + end if; + update jobs set status = 'PENDING' where id = p_job_id; + perform set_config('response.headers', '[{"HX-Trigger": "refreshAll"}]', true); + return api.toast('Job #' || p_job_id || ' unassigned', 'success'); +end; +$fn$; + +grant execute on function api.assign(integer, integer, integer) to web_anon; +grant execute on function api.unassign(integer) to web_anon; +grant execute on function api.toast(text, text) to web_anon; diff --git a/db/functions/21_sim.sql b/db/functions/21_sim.sql new file mode 100644 index 0000000..d471d67 --- /dev/null +++ b/db/functions/21_sim.sql @@ -0,0 +1,136 @@ +-- Phase 4 sim UI + controls. Ports _sim_bar.html, _win_settings.html, and the /sim/* POSTs. +-- NOTE the clock is REDESIGNED (stepped, see 02_clock.sql), so sim_bar's simulated/running +-- states are NOT byte-comparable to FastAPI's live-interpolated clock — only the deterministic +-- states (is_demo=false -> empty; real/STOPPED -> --:--:-- ) byte-match. Controls write clock +-- state only; the reseed + auto_route + dispatch-loop that /sim/start also does are the sim +-- ENGINE (deferred — needs strategy.py->PL/pgSQL). is_demo gates whether the bar renders at all. + +-- ── Render: sim bar ───────────────────────────────────────────────────────── +create or replace function api.sim_bar() +returns "text/html" language sql stable as $$ + select case when not api.is_demo() then '
' + else ( + select + '
DEMO' + || '' + || case when s.is_paused then '■ PAUSED' + when s.mode = 'real' then '■ STOPPED' + else '● RUNNING' end + || '' + || '' + || case when s.mode = 'real' then '--:--:--' + else to_char(api.sim_now() at time zone 'UTC', 'HH24:MI:SS') end + || '' + || case when s.mode = 'real' then + '
' + || '' + || '
' + else + case when s.is_paused then + '' + else + '' + end + || '' + end + || '
Speed' + || '
' + || '
' + from api.sim_state s limit 1 + ) end; +$$; + +-- ── Render: settings window ───────────────────────────────────────────────── +create or replace function api.settings_window() +returns "text/html" language sql stable as $$ + select + '
' + || '
' + || '
' + || '
' + || '
' + || '' + || '
' + || '
' || upper((select mode from api.sim_state limit 1)) + || case when api.is_demo() then ' · DEMO build' else '' end || '
' + || '
' + || '' + || '
' + || '
' + || '
' + || '
⌘/Ctrl+A Select all (in hovered pane)
' + || '
Esc Clear selection / close modal
' + || '
D Complete selected jobs
' + || '
S Start selected jobs
' + || '
C Cancel selected jobs
' + || '
H Hold selected jobs
' + || '
U Unassign selected jobs
' + || '
R Refresh all panes
' + || '
/ Open Job Search
' + || '
F Open Filter
' + || '
P Open Personnel
' + || '
'; +$$; + +-- ── Controls (write clock state, return the bar). SECURITY DEFINER for the writes. ── +-- DEFERRED vs FastAPI: /sim/start also reseeds + auto_routes + starts dispatch loop (the sim +-- ENGINE). Here start only sets clock state; engine port is separate (strategy.py->PL/pgSQL). +create or replace function api.sim_start(p_speed double precision default 500) +returns "text/html" language plpgsql volatile security definer set search_path = api, public as $$ +begin + update api.sim_state set mode = 'simulated', is_paused = false, speed = p_speed, + virtual_now = date_trunc('day', now()) + interval '8 hours'; + return api.sim_bar(); +end $$; + +create or replace function api.sim_pause() +returns "text/html" language plpgsql volatile security definer set search_path = api, public as $$ +begin update api.sim_state set is_paused = true; return api.sim_bar(); end $$; + +create or replace function api.sim_resume() +returns "text/html" language plpgsql volatile security definer set search_path = api, public as $$ +begin update api.sim_state set is_paused = false; return api.sim_bar(); end $$; + +create or replace function api.sim_stop() +returns "text/html" language plpgsql volatile security definer set search_path = api, public as $$ +begin + update api.sim_state set mode = 'real', is_paused = false, speed = 1.0, virtual_now = null; + return api.sim_bar(); +end $$; + +create or replace function api.sim_speed(p_speed double precision) +returns "text/html" language plpgsql volatile security definer set search_path = api, public as $$ +begin + if (select mode from api.sim_state limit 1) <> 'simulated' then + raise exception 'Simulation not running' using errcode = 'P0001'; + end if; + update api.sim_state set speed = p_speed; + return api.sim_bar(); +end $$; + +grant execute on function api.sim_bar() to web_anon; +grant execute on function api.settings_window() to web_anon; +grant execute on function api.sim_start(double precision) to web_anon; +grant execute on function api.sim_pause() to web_anon; +grant execute on function api.sim_resume() to web_anon; +grant execute on function api.sim_stop() to web_anon; +grant execute on function api.sim_speed(double precision) to web_anon; diff --git a/db/postgrest/00_bootstrap.sql b/db/postgrest/00_bootstrap.sql new file mode 100644 index 0000000..4cd9a50 --- /dev/null +++ b/db/postgrest/00_bootstrap.sql @@ -0,0 +1,20 @@ +-- PostgREST bootstrap: exposed schema + anonymous web role. +-- Additive only — does not touch the app's existing tables. +-- The `api` schema holds RPC functions (HTML fragments + JSON) that PostgREST exposes. +-- Functions read the app-owned `public` tables; nothing here writes. + +create schema if not exists api; + +-- Anonymous role PostgREST authenticates as for unauthenticated requests. +do $$ +begin + if not exists (select from pg_roles where rolname = 'web_anon') then + create role web_anon nologin; + end if; +end +$$; + +grant usage on schema api to web_anon; +grant usage on schema public to web_anon; +grant select on all tables in schema public to web_anon; +alter default privileges in schema public grant select on tables to web_anon; diff --git a/db/schema/schema.sql b/db/schema/schema.sql new file mode 100644 index 0000000..546fa6c --- /dev/null +++ b/db/schema/schema.sql @@ -0,0 +1,335 @@ +-- +-- PostgreSQL database dump +-- + +\restrict KEZmDKpq7KbQOfa7xkmovd66aKbLPXaAkpxd4Vnr5JTbleGGJSYrdO4YxHF1SyQ + +-- Dumped from database version 15.18 +-- Dumped by pg_dump version 15.18 + +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + +-- +-- Name: jobstatus; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.jobstatus AS ENUM ( + 'PENDING', + 'ASSIGNED', + 'IN_PROGRESS', + 'COMPLETED', + 'CANCELLED', + 'ON_HOLD' +); + + +-- +-- Name: jobtype; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.jobtype AS ENUM ( + 'INSTALL', + 'REPAIR', + 'MAINTENANCE', + 'INSPECTION', + 'DISCONNECT', + 'SERVICE_CHANGE' +); + + +-- +-- Name: technicianstatus; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.technicianstatus AS ENUM ( + 'AVAILABLE', + 'ON_JOB', + 'EN_ROUTE', + 'ON_BREAK', + 'OFF_DUTY' +); + + +SET default_tablespace = ''; + +SET default_table_access_method = heap; + +-- +-- Name: assignments; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.assignments ( + id integer NOT NULL, + job_id integer NOT NULL, + technician_id integer NOT NULL, + assigned_at timestamp with time zone NOT NULL, + sequence integer, + estimated_travel_time integer, + estimated_distance double precision, + estimated_arrival timestamp with time zone, + actual_travel_time integer, + actual_arrival timestamp with time zone, + actual_completion timestamp with time zone, + actual_duration_minutes integer, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL +); + + +-- +-- Name: assignments_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.assignments_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: assignments_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.assignments_id_seq OWNED BY public.assignments.id; + + +-- +-- Name: jobs; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.jobs ( + id integer NOT NULL, + job_number character varying(50), + job_type public.jobtype NOT NULL, + status public.jobstatus NOT NULL, + customer_name character varying(100) NOT NULL, + customer_phone character varying(20), + customer_email character varying(100), + service_address character varying(255) NOT NULL, + service_city character varying(100), + service_zip character varying(10), + latitude double precision NOT NULL, + longitude double precision NOT NULL, + required_skills jsonb NOT NULL, + route_criteria character varying(50), + priority integer NOT NULL, + scheduled_date timestamp with time zone, + time_slot_start character varying(5), + time_slot_end character varying(5), + estimated_duration integer NOT NULL, + description text, + notes text, + special_instructions text, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + started_at timestamp with time zone, + completed_at timestamp with time zone +); + + +-- +-- Name: jobs_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.jobs_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: jobs_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.jobs_id_seq OWNED BY public.jobs.id; + + +-- +-- Name: technicians; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.technicians ( + id integer NOT NULL, + name character varying(100) NOT NULL, + employee_id character varying(50), + phone character varying(20), + email character varying(100), + status public.technicianstatus NOT NULL, + is_active boolean NOT NULL, + current_latitude double precision, + current_longitude double precision, + last_location_update timestamp with time zone, + home_latitude double precision NOT NULL, + home_longitude double precision NOT NULL, + home_address character varying(255), + skills jsonb NOT NULL, + assigned_routes jsonb NOT NULL, + speed_factor double precision NOT NULL, + skill_bonuses jsonb NOT NULL, + shift_start character varying(5), + shift_end character varying(5), + max_jobs_per_day integer NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL +); + + +-- +-- Name: technicians_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.technicians_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: technicians_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.technicians_id_seq OWNED BY public.technicians.id; + + +-- +-- Name: assignments id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.assignments ALTER COLUMN id SET DEFAULT nextval('public.assignments_id_seq'::regclass); + + +-- +-- Name: jobs id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.jobs ALTER COLUMN id SET DEFAULT nextval('public.jobs_id_seq'::regclass); + + +-- +-- Name: technicians id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.technicians ALTER COLUMN id SET DEFAULT nextval('public.technicians_id_seq'::regclass); + + +-- +-- Name: assignments assignments_job_id_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.assignments + ADD CONSTRAINT assignments_job_id_key UNIQUE (job_id); + + +-- +-- Name: assignments assignments_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.assignments + ADD CONSTRAINT assignments_pkey PRIMARY KEY (id); + + +-- +-- Name: jobs jobs_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.jobs + ADD CONSTRAINT jobs_pkey PRIMARY KEY (id); + + +-- +-- Name: technicians technicians_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.technicians + ADD CONSTRAINT technicians_pkey PRIMARY KEY (id); + + +-- +-- Name: ix_assignments_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX ix_assignments_id ON public.assignments USING btree (id); + + +-- +-- Name: ix_jobs_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX ix_jobs_id ON public.jobs USING btree (id); + + +-- +-- Name: ix_jobs_job_number; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX ix_jobs_job_number ON public.jobs USING btree (job_number); + + +-- +-- Name: ix_jobs_route_criteria; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX ix_jobs_route_criteria ON public.jobs USING btree (route_criteria); + + +-- +-- Name: ix_jobs_status; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX ix_jobs_status ON public.jobs USING btree (status); + + +-- +-- Name: ix_technicians_employee_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX ix_technicians_employee_id ON public.technicians USING btree (employee_id); + + +-- +-- Name: ix_technicians_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX ix_technicians_id ON public.technicians USING btree (id); + + +-- +-- Name: assignments assignments_job_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.assignments + ADD CONSTRAINT assignments_job_id_fkey FOREIGN KEY (job_id) REFERENCES public.jobs(id); + + +-- +-- Name: assignments assignments_technician_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.assignments + ADD CONSTRAINT assignments_technician_id_fkey FOREIGN KEY (technician_id) REFERENCES public.technicians(id); + + +-- +-- PostgreSQL database dump complete +-- + +\unrestrict KEZmDKpq7KbQOfa7xkmovd66aKbLPXaAkpxd4Vnr5JTbleGGJSYrdO4YxHF1SyQ + diff --git a/deploy/nginx.conf b/deploy/nginx.conf deleted file mode 100644 index a1fc42a..0000000 --- a/deploy/nginx.conf +++ /dev/null @@ -1,41 +0,0 @@ -server { - listen 80; - server_name _; - return 301 https://$host$request_uri; -} - -server { - listen 443 ssl http2; - server_name _; - - ssl_certificate /etc/letsencrypt/live/demo.fieldopt.dev/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/demo.fieldopt.dev/privkey.pem; - - ssl_protocols TLSv1.2 TLSv1.3; - ssl_ciphers HIGH:!aNULL:!MD5; - - root /usr/share/nginx/html; - index index.html; - - location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { - expires 30d; - add_header Cache-Control "public, immutable"; - } - - location /api/ { - proxy_pass http://backend:8000; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - location / { - try_files $uri /index.html; - } - - location ~ /\. { - deny all; - } -} diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js deleted file mode 100644 index 4fa125d..0000000 --- a/frontend/eslint.config.js +++ /dev/null @@ -1,29 +0,0 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import { defineConfig, globalIgnores } from 'eslint/config' - -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{js,jsx}'], - extends: [ - js.configs.recommended, - reactHooks.configs.flat.recommended, - reactRefresh.configs.vite, - ], - languageOptions: { - ecmaVersion: 2020, - globals: globals.browser, - parserOptions: { - ecmaVersion: 'latest', - ecmaFeatures: { jsx: true }, - sourceType: 'module', - }, - }, - rules: { - 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], - }, - }, -]) diff --git a/frontend/index.html b/frontend/index.html deleted file mode 100644 index 7ccbabc..0000000 --- a/frontend/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - FieldOpt — Dispatch Console - - -
- - - diff --git a/frontend/package-lock.json b/frontend/package-lock.json deleted file mode 100644 index 1518aa3..0000000 --- a/frontend/package-lock.json +++ /dev/null @@ -1,3287 +0,0 @@ -{ - "name": "fieldopt-frontend", - "version": "0.0.7", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "fieldopt-frontend", - "version": "0.0.7", - "dependencies": { - "ag-grid-community": "^33.2.5", - "ag-grid-react": "^33.2.5", - "axios": "^1.13.2", - "ci": "^2.3.0", - "leaflet": "^1.9.4", - "react": "^19.2.0", - "react-dom": "^19.2.0", - "react-leaflet": "^5.0.0" - }, - "devDependencies": { - "@eslint/js": "^9.39.1", - "@types/react": "^19.2.2", - "@types/react-dom": "^19.2.2", - "@vitejs/plugin-react": "^5.1.0", - "baseline-browser-mapping": "^2.10.18", - "eslint": "^9.39.1", - "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.4.24", - "globals": "^16.5.0", - "vite": "^7.2.2" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", - "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", - "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", - "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.5" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", - "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.5" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", - "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@eslint/js": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@react-leaflet/core": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", - "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==", - "license": "Hippocratic-2.1", - "peerDependencies": { - "leaflet": "^1.9.0", - "react": "^19.0.0", - "react-dom": "^19.0.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.47", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", - "integrity": "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", - "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", - "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", - "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", - "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", - "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", - "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", - "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", - "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", - "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", - "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", - "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", - "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", - "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", - "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", - "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", - "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", - "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", - "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", - "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", - "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", - "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", - "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", - "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", - "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", - "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.4.tgz", - "integrity": "sha512-tBFxBp9Nfyy5rsmefN+WXc1JeW/j2BpBHFdLZbEVfs9wn3E3NRFxwV0pJg8M1qQAexFpvz73hJXFofV0ZAu92A==", - "dev": true, - "license": "MIT", - "dependencies": { - "csstype": "^3.0.2" - } - }, - "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" - } - }, - "node_modules/@vitejs/plugin-react": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.1.tgz", - "integrity": "sha512-WQfkSw0QbQ5aJ2CHYw23ZGkqnRwqKHD/KYsMeTkZzPT4Jcf0DcBxBtwMJxnu6E7oxw5+JC6ZAiePgh28uJ1HBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.28.5", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.47", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.18.0" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" - } - }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ag-charts-types": { - "version": "11.3.2", - "resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-11.3.2.tgz", - "integrity": "sha512-trPGqgGYiTeLgtf9nLuztDYOPOFOLbqHn1g2D99phf7QowcwdX0TPx0wfWG8Hm90LjB8IH+G2s3AZe2vrdAtMQ==", - "license": "MIT" - }, - "node_modules/ag-grid-community": { - "version": "33.3.2", - "resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-33.3.2.tgz", - "integrity": "sha512-9bx0e/+ykOyLvUxHqmdy0cRVANH6JAtv0yZdnBZEXYYqBAwN+G5a4NY+2I1KvoOCYzbk8SnStG7y4hCdVAAWOQ==", - "license": "MIT", - "dependencies": { - "ag-charts-types": "11.3.2" - } - }, - "node_modules/ag-grid-react": { - "version": "33.3.2", - "resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-33.3.2.tgz", - "integrity": "sha512-5bv4JIJvGov23sduIUIyQTqpa/qhoQrRkQm5pFOQb7RMwusfx6xBPrkLwIIlCJiQ8g0OOinxWzZ2kQ2Zml6tLw==", - "license": "MIT", - "dependencies": { - "ag-grid-community": "33.3.2", - "prop-types": "^15.8.1" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/axios": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", - "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", - "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", - "proxy-from-env": "^2.1.0" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.18", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.18.tgz", - "integrity": "sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/browserslist": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", - "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.8.25", - "caniuse-lite": "^1.0.30001754", - "electron-to-chromium": "^1.5.249", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.1.4" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001754", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz", - "integrity": "sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/ci": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/ci/-/ci-2.3.0.tgz", - "integrity": "sha512-0MGXkzJKkwV3enG7RUxjJKdiAkbaZ7visCjitfpCN2BQjv02KGRMxCHLv4RPokkjJ4xR33FLMAXweS+aQ0pFSQ==", - "bin": { - "ci": "dist/cli.js" - }, - "funding": { - "url": "https://github.com/privatenumber/ci?sponsor=1" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.252", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.252.tgz", - "integrity": "sha512-53uTpjtRgS7gjIxZ4qCgFdNO2q+wJt/Z8+xAvxbCqXPJrY6h7ighUkadQmNMXH96crtpa6gPFNP7BF4UBGDuaA==", - "dev": true, - "license": "ISC" - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", - "dev": true, - "hasInstallScript": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", - "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.2", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.5", - "@eslint/js": "9.39.4", - "@eslint/plugin-kit": "^0.4.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.14.0", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.5", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-plugin-react-hooks": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", - "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.24.4", - "@babel/parser": "^7.24.4", - "hermes-parser": "^0.25.1", - "zod": "^3.25.0 || ^4.0.0", - "zod-validation-error": "^3.5.0 || ^4.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" - } - }, - "node_modules/eslint-plugin-react-refresh": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz", - "integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "eslint": ">=8.40" - } - }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", - "dev": true, - "license": "ISC" - }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/globals": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", - "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hermes-estree": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", - "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", - "dev": true, - "license": "MIT" - }, - "node_modules/hermes-parser": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", - "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "hermes-estree": "0.25.1" - } - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/leaflet": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", - "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", - "license": "BSD-2-Clause" - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "engines": { - "node": ">=10" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/react": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", - "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", - "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", - "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.0" - } - }, - "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, - "node_modules/react-leaflet": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", - "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==", - "license": "Hippocratic-2.1", - "dependencies": { - "@react-leaflet/core": "^3.0.0" - }, - "peerDependencies": { - "leaflet": "^1.9.0", - "react": "^19.0.0", - "react-dom": "^19.0.0" - } - }, - "node_modules/react-refresh": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", - "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/rollup": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", - "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.0", - "@rollup/rollup-android-arm64": "4.60.0", - "@rollup/rollup-darwin-arm64": "4.60.0", - "@rollup/rollup-darwin-x64": "4.60.0", - "@rollup/rollup-freebsd-arm64": "4.60.0", - "@rollup/rollup-freebsd-x64": "4.60.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", - "@rollup/rollup-linux-arm-musleabihf": "4.60.0", - "@rollup/rollup-linux-arm64-gnu": "4.60.0", - "@rollup/rollup-linux-arm64-musl": "4.60.0", - "@rollup/rollup-linux-loong64-gnu": "4.60.0", - "@rollup/rollup-linux-loong64-musl": "4.60.0", - "@rollup/rollup-linux-ppc64-gnu": "4.60.0", - "@rollup/rollup-linux-ppc64-musl": "4.60.0", - "@rollup/rollup-linux-riscv64-gnu": "4.60.0", - "@rollup/rollup-linux-riscv64-musl": "4.60.0", - "@rollup/rollup-linux-s390x-gnu": "4.60.0", - "@rollup/rollup-linux-x64-gnu": "4.60.0", - "@rollup/rollup-linux-x64-musl": "4.60.0", - "@rollup/rollup-openbsd-x64": "4.60.0", - "@rollup/rollup-openharmony-arm64": "4.60.0", - "@rollup/rollup-win32-arm64-msvc": "4.60.0", - "@rollup/rollup-win32-ia32-msvc": "4.60.0", - "@rollup/rollup-win32-x64-gnu": "4.60.0", - "@rollup/rollup-win32-x64-msvc": "4.60.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", - "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/vite": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", - "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", - "dev": true, - "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zod": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", - "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-validation-error": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", - "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - } - } - } -} diff --git a/frontend/package.json b/frontend/package.json deleted file mode 100644 index d2b874f..0000000 --- a/frontend/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "fieldopt-frontend", - "private": true, - "version": "0.0.8", - "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build", - "lint": "eslint .", - "preview": "vite preview" - }, - "dependencies": { - "ag-grid-community": "^33.2.5", - "ag-grid-react": "^33.2.5", - "axios": "^1.13.2", - "ci": "^2.3.0", - "leaflet": "^1.9.4", - "react": "^19.2.0", - "react-dom": "^19.2.0", - "react-leaflet": "^5.0.0" - }, - "devDependencies": { - "@eslint/js": "^9.39.1", - "@types/react": "^19.2.2", - "@types/react-dom": "^19.2.2", - "@vitejs/plugin-react": "^5.1.0", - "baseline-browser-mapping": "^2.10.18", - "eslint": "^9.39.1", - "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.4.24", - "globals": "^16.5.0", - "vite": "^7.2.2" - } -} diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/frontend/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src.bak/App.css b/frontend/src.bak/App.css deleted file mode 100644 index 24f4bfd..0000000 --- a/frontend/src.bak/App.css +++ /dev/null @@ -1,4 +0,0 @@ -#root { - height: 100%; - width: 100%; -} diff --git a/frontend/src.bak/App.jsx b/frontend/src.bak/App.jsx deleted file mode 100644 index b9049bc..0000000 --- a/frontend/src.bak/App.jsx +++ /dev/null @@ -1,8 +0,0 @@ -import Dashboard from './components/Dashboard'; -import './App.css'; - -function App() { - return ; -} - -export default App; diff --git a/frontend/src.bak/api/client.js b/frontend/src.bak/api/client.js deleted file mode 100644 index 7aa28f2..0000000 --- a/frontend/src.bak/api/client.js +++ /dev/null @@ -1,31 +0,0 @@ -import axios from 'axios'; - -const API_BASE_URL = 'http://localhost:8000/api/v1'; - -const apiClient = axios.create({ - baseURL: API_BASE_URL, - headers: { - 'Content-Type': 'application/json', - }, -}); - -export const api = { - getTechnicians: () => apiClient.get('/technicians/'), - getTechnician: (id) => apiClient.get(`/technicians/${id}`), - createTechnician: (data) => apiClient.post('/technicians/', data), - updateTechLocation: (id, data) => apiClient.patch(`/technicians/${id}/location`, data), - - getJobs: () => apiClient.get('/jobs/'), - getJob: (id) => apiClient.get(`/jobs/${id}`), - createJob: (data) => apiClient.post('/jobs/', data), - updateJobStatus: (id, status) => apiClient.patch(`/jobs/${id}/status`, { status }), - getJobsSummary: () => apiClient.get('/jobs/summary'), - - createAssignment: (data) => apiClient.post('/assignments/', data), - getJobAssignment: (jobId) => apiClient.get(`/assignments/job/${jobId}`), - - autoRoute: (data = {}) => apiClient.post('/routing/auto-route', data), - getBestTech: (jobId) => apiClient.get(`/routing/best-tech/${jobId}`), -}; - -export default api; diff --git a/frontend/src.bak/assets/react.svg b/frontend/src.bak/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/frontend/src.bak/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src.bak/components/Dashboard.jsx b/frontend/src.bak/components/Dashboard.jsx deleted file mode 100644 index 0be1267..0000000 --- a/frontend/src.bak/components/Dashboard.jsx +++ /dev/null @@ -1,211 +0,0 @@ -import { useState, useEffect } from 'react'; -import { Zap, Users, Briefcase, CheckCircle, RefreshCw, MapPin } from 'lucide-react'; -import Map from './Map'; -import JobList from './JobList'; -import { api } from '../api/client'; - -export default function Dashboard() { - const [technicians, setTechnicians] = useState([]); - const [jobs, setJobs] = useState([]); - const [summary, setSummary] = useState(null); - const [loading, setLoading] = useState(true); - const [autoRouting, setAutoRouting] = useState(false); - const [refreshing, setRefreshing] = useState(false); - - const loadData = async (showRefresh = false) => { - if (showRefresh) setRefreshing(true); - try { - const [techsRes, jobsRes, summaryRes] = await Promise.all([ - api.getTechnicians(), - api.getJobs(), - api.getJobsSummary(), - ]); - - setTechnicians(techsRes.data); - setJobs(jobsRes.data); - setSummary(summaryRes.data); - } catch (error) { - console.error('Error loading data:', error); - alert('Error connecting to backend. Is it running on http://localhost:8000?'); - } finally { - setLoading(false); - setRefreshing(false); - } - }; - - useEffect(() => { - loadData(); - const interval = setInterval(() => loadData(), 30000); - return () => clearInterval(interval); - }, []); - - const handleAutoRoute = async () => { - setAutoRouting(true); - try { - const response = await api.autoRoute(); - alert(`✅ Auto-routing complete!\n\n📊 Results:\n • Jobs assigned: ${response.data.jobs_assigned}\n • Jobs unassigned: ${response.data.jobs_unassigned}\n\n${response.data.jobs_unassigned > 0 ? '⚠️ Some jobs could not be assigned (check skills/availability)' : '🎉 All jobs successfully assigned!'}`); - await loadData(true); - } catch (error) { - console.error('Error auto-routing:', error); - alert('❌ Error during auto-routing. Check console for details.'); - } finally { - setAutoRouting(false); - } - }; - - if (loading) { - return ( -
-
-
-
Loading FieldOpt...
-
-
- ); - } - - const pendingJobs = jobs.filter(j => j.status === 'pending'); - const activeTechs = technicians.filter(t => t.status === 'available' || t.status === 'on_job').length; - - return ( -
- {/* Modern Header with Gradient */} -
-
-
-
-
- -
-
-

FieldOpt

-

Intelligent Field Service Dispatch

-
-
- -
- - - -
-
-
-
- - {/* Modern Stats Cards */} - {summary && ( -
-
-
-
-
-
- -
-
-
{activeTechs}
-
of {technicians.length}
-
-
-
Active Technicians
-
- -
-
-
- -
-
-
{summary.pending}
-
unassigned
-
-
-
Pending Jobs
-
- -
-
-
- -
-
-
{summary.in_progress}
-
active now
-
-
-
In Progress
-
- -
-
-
- -
-
-
{summary.completed}
-
today
-
-
-
Completed
-
-
-
-
- )} - - {/* Main Content Area */} -
- {/* Map Section */} -
- {technicians.length === 0 && jobs.length === 0 ? ( -
-
- -

No Data Yet

-

- Run the seed script to populate technicians and jobs: -

- - python3 quick_seed.py - -
-
- ) : ( - - )} -
- - {/* Sidebar */} -
-
-

- Jobs ({jobs.length}) -

- {jobs.length > 0 && ( -

- {pendingJobs.length} pending • {summary?.in_progress || 0} active -

- )} -
-
- -
-
-
-
- ); -} diff --git a/frontend/src.bak/components/JobList.jsx b/frontend/src.bak/components/JobList.jsx deleted file mode 100644 index 6cdfd0f..0000000 --- a/frontend/src.bak/components/JobList.jsx +++ /dev/null @@ -1,86 +0,0 @@ -import { Clock, MapPin, AlertCircle } from 'lucide-react'; - -const statusColors = { - pending: 'bg-yellow-100 text-yellow-800', - assigned: 'bg-blue-100 text-blue-800', - in_progress: 'bg-purple-100 text-purple-800', - completed: 'bg-green-100 text-green-800', - cancelled: 'bg-red-100 text-red-800', - on_hold: 'bg-gray-100 text-gray-800', -}; - -const priorityColors = { - 1: 'text-red-600 font-bold', - 2: 'text-orange-600 font-semibold', - 3: 'text-yellow-600', - 4: 'text-blue-600', - 5: 'text-gray-600', -}; - -export default function JobList({ jobs, onJobClick }) { - return ( -
- {jobs.length === 0 ? ( -
-

No jobs found

-
- ) : ( - jobs.map(job => ( -
onJobClick && onJobClick(job)} - className="bg-white p-4 rounded-lg shadow hover:shadow-md transition-shadow cursor-pointer border-l-4" - style={{ borderLeftColor: job.priority === 1 ? '#dc2626' : '#3b82f6' }} - > -
-

{job.customer_name}

- - {job.status.replace('_', ' ').toUpperCase()} - -
- -
-
- - {job.service_address} -
- - {job.scheduled_date && ( -
- - - {new Date(job.scheduled_date).toLocaleDateString()} - {job.time_slot_start && ` ${job.time_slot_start}-${job.time_slot_end}`} - -
- )} - -
- - - Priority {job.priority} • {job.job_type} - -
-
- - {job.description && ( -

- {job.description} -

- )} - - {job.required_skills.length > 0 && ( -
- {job.required_skills.map(skill => ( - - {skill} - - ))} -
- )} -
- )) - )} -
- ); -} diff --git a/frontend/src.bak/components/Map.jsx b/frontend/src.bak/components/Map.jsx deleted file mode 100644 index e0746f1..0000000 --- a/frontend/src.bak/components/Map.jsx +++ /dev/null @@ -1,116 +0,0 @@ -import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet'; -import { Icon } from 'leaflet'; -import 'leaflet/dist/leaflet.css'; -import { useEffect } from 'react'; - -// Fix for default marker icons in React-Leaflet -import icon from 'leaflet/dist/images/marker-icon.png'; -import iconShadow from 'leaflet/dist/images/marker-shadow.png'; -let DefaultIcon = Icon.Default; -DefaultIcon.prototype.options.iconUrl = icon; -DefaultIcon.prototype.options.shadowUrl = iconShadow; - -const techIcon = new Icon({ - iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-blue.png', - shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png', - iconSize: [25, 41], - iconAnchor: [12, 41], - popupAnchor: [1, -34], - shadowSize: [41, 41] -}); - -const jobIcon = new Icon({ - iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-red.png', - shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png', - iconSize: [25, 41], - iconAnchor: [12, 41], - popupAnchor: [1, -34], - shadowSize: [41, 41] -}); - -function AutoZoom({ technicians, jobs }) { - const map = useMap(); - - useEffect(() => { - if (technicians.length > 0 || jobs.length > 0) { - const bounds = []; - - technicians.forEach(tech => { - if (tech.current_latitude && tech.current_longitude) { - bounds.push([tech.current_latitude, tech.current_longitude]); - } - }); - - jobs.forEach(job => { - if (job.latitude && job.longitude) { - bounds.push([job.latitude, job.longitude]); - } - }); - - if (bounds.length > 0) { - map.fitBounds(bounds, { padding: [50, 50] }); - } - } - }, [technicians, jobs, map]); - - return null; -} - -export default function Map({ technicians = [], jobs = [] }) { - const center = [40.7128, -74.0060]; // NYC default - - return ( - - - - - - {/* Technician Markers */} - {technicians.map(tech => ( - tech.current_latitude && tech.current_longitude && ( - - -
-

{tech.name}

-

Status: {tech.status}

-

Skills: {tech.skills.join(', ')}

-
-
-
- ) - ))} - - {/* Job Markers */} - {jobs.map(job => ( - job.latitude && job.longitude && ( - - -
-

{job.customer_name}

-

{job.service_address}

-

Type: {job.job_type}

-

Status: {job.status}

-

Priority: {job.priority}

-
-
-
- ) - ))} -
- ); -} diff --git a/frontend/src.bak/index.css b/frontend/src.bak/index.css deleted file mode 100644 index db03d69..0000000 --- a/frontend/src.bak/index.css +++ /dev/null @@ -1,20 +0,0 @@ -@import "tailwindcss"; - -*, *::before, *::after { - box-sizing: border-box; -} - -html, body, #root { - height: 100%; - width: 100%; - margin: 0; - padding: 0; -} - -body { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - min-width: 320px; -} diff --git a/frontend/src.bak/main.jsx b/frontend/src.bak/main.jsx deleted file mode 100644 index 24f06aa..0000000 --- a/frontend/src.bak/main.jsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import App from './App.jsx'; -import './index.css'; - -ReactDOM.createRoot(document.getElementById('root')).render( - - - , -); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx deleted file mode 100644 index af54ec3..0000000 --- a/frontend/src/App.jsx +++ /dev/null @@ -1,7 +0,0 @@ -import Dashboard from './components/Dashboard'; - -function App() { - return ; -} - -export default App; diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js deleted file mode 100644 index 42b5794..0000000 --- a/frontend/src/api/client.js +++ /dev/null @@ -1,76 +0,0 @@ -import axios from 'axios'; - -// Use relative path so it works on any domain/IP -const API_BASE_URL = '/api/v1'; - -const apiClient = axios.create({ - baseURL: API_BASE_URL, - headers: { - 'Content-Type': 'application/json', - }, -}); - -export const api = { - // Technicians - getTechnicians: () => apiClient.get('/technicians/'), - getTechnician: (id) => apiClient.get(`/technicians/${id}`), - createTechnician: (data) => apiClient.post('/technicians/', data), - updateTechLocation: (id, data) => apiClient.patch(`/technicians/${id}/location`, data), - updateTechStatus: (id, status) => apiClient.patch(`/technicians/${id}/status`, { status }), - getTechWorkload: (id) => apiClient.get(`/technicians/${id}/workload`), - - // Jobs - getJobs: (params = {}) => apiClient.get('/jobs/', { params }), - getJob: (id) => apiClient.get(`/jobs/${id}`), - createJob: (data) => apiClient.post('/jobs/', data), - updateJob: (id, data) => apiClient.patch(`/jobs/${id}`, data), - getJobsSummary: (params = {}) => apiClient.get('/jobs/summary', { params }), - getPendingJobs: () => apiClient.get('/jobs/pending'), - startJob: (jobId) => apiClient.post(`/jobs/${jobId}/start`, {}), - completeJob: (jobId) => apiClient.post(`/jobs/${jobId}/complete`, {}), - cancelJob: (jobId) => apiClient.post(`/jobs/${jobId}/cancel`, {}), - canDo: (jobId, techId) => apiClient.get(`/jobs/${jobId}/can-do/${techId}`), - - // Assignments - createAssignment: (data) => apiClient.post('/assignments/', data), - getJobAssignment: (jobId) => apiClient.get(`/assignments/job/${jobId}`), - getTechAssignments: (techId) => apiClient.get(`/assignments/technician/${techId}`), - unassignJob: (jobId) => apiClient.post('/assignments/unassign', { job_id: jobId }), - reassignJob: (jobId, newTechId) => apiClient.post('/assignments/reassign', { - job_id: jobId, - new_technician_id: newTechId, - }), - batchAssign: (jobIds, techId) => apiClient.post('/assignments/batch-assign', { - job_ids: jobIds, - technician_id: techId, - }), - batchUnassign: (jobIds) => apiClient.post('/assignments/batch-unassign', { - job_ids: jobIds, - }), - - // Routing - autoRoute: (data = {}) => apiClient.post('/routing/auto-route', data), - getBestTech: (jobId) => apiClient.get(`/routing/best-tech/${jobId}`), - canDo: (jobId, techId) => apiClient.get(`/jobs/${jobId}/can-do/${techId}`), - - // Job Search — multi-criteria query - searchJobs: (params = {}) => apiClient.get('/jobs/search/query', { params }), - - // Simulation control (IS_DEMO-gated — 404 in production) - simStatus: () => apiClient.get('/simulation/status'), - simStart: (speed = 200) => apiClient.post('/simulation/start', { speed }), - simPause: () => apiClient.post('/simulation/pause'), - simResume: () => apiClient.post('/simulation/resume'), - simStop: () => apiClient.post('/simulation/stop'), - simSetSpeed: (speed) => apiClient.post('/simulation/speed', { speed }), - - // Batch CanDo — evaluate all techs for a single job - canDoAll: async (jobId, techIds) => { - const results = await Promise.all( - techIds.map((tid) => apiClient.get(`/jobs/${jobId}/can-do/${tid}`).catch(() => null)) - ); - return results.filter(Boolean).map((r) => r.data); - }, -}; - -export default api; diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/frontend/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/components/CalendarPicker.jsx b/frontend/src/components/CalendarPicker.jsx deleted file mode 100644 index 4f7fceb..0000000 --- a/frontend/src/components/CalendarPicker.jsx +++ /dev/null @@ -1,75 +0,0 @@ -import { useState, useEffect, useRef } from 'react'; -import { createPortal } from 'react-dom'; - -const DAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']; -const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', - 'July', 'August', 'September', 'October', 'November', 'December']; - -function sameDay(a, b) { - return a.getFullYear() === b.getFullYear() && - a.getMonth() === b.getMonth() && - a.getDate() === b.getDate(); -} - -export default function CalendarPicker({ value, onChange, onClose, anchorRef }) { - const [cursor, setCursor] = useState(() => new Date(value.getFullYear(), value.getMonth(), 1)); - const ref = useRef(null); - const today = new Date(); - - // Position below the anchor element - const [pos, setPos] = useState({ top: 0, left: 0 }); - useEffect(() => { - if (anchorRef?.current) { - const r = anchorRef.current.getBoundingClientRect(); - setPos({ top: r.bottom + 6, left: r.left + r.width / 2 }); - } - }, [anchorRef]); - - // Close on outside click - useEffect(() => { - const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); - }, [onClose]); - - const prevMonth = () => setCursor(new Date(cursor.getFullYear(), cursor.getMonth() - 1, 1)); - const nextMonth = () => setCursor(new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1)); - - // Build grid: leading blanks + days of month - const firstDow = cursor.getDay(); - const daysInMonth = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 0).getDate(); - const cells = []; - for (let i = 0; i < firstDow; i++) cells.push(null); - for (let d = 1; d <= daysInMonth; d++) cells.push(new Date(cursor.getFullYear(), cursor.getMonth(), d)); - - return createPortal( -
-
- - {MONTHS[cursor.getMonth()]} {cursor.getFullYear()} - -
-
- {DAYS.map((d) => {d})} - {cells.map((d, i) => { - if (!d) return ; - const isSelected = sameDay(d, value); - const isToday = sameDay(d, today); - return ( - - ); - })} -
-
- -
-
, - document.body - ); -} diff --git a/frontend/src/components/ContextMenu.jsx b/frontend/src/components/ContextMenu.jsx deleted file mode 100644 index 7e8db5e..0000000 --- a/frontend/src/components/ContextMenu.jsx +++ /dev/null @@ -1,182 +0,0 @@ -import { useState, useRef, useEffect } from 'react'; - -export default function ContextMenu({ - x, y, type, data, technicians, - selectedJobIds = [], - selectedTechIds = [], - onJobAction, onTechAction, onAssignToTech, -}) { - const [submenu, setSubmenu] = useState(null); - const menuRef = useRef(null); - - // Reposition if menu would overflow viewport - useEffect(() => { - if (!menuRef.current) return; - const rect = menuRef.current.getBoundingClientRect(); - const el = menuRef.current; - if (rect.right > window.innerWidth) el.style.left = `${window.innerWidth - rect.width - 4}px`; - if (rect.bottom > window.innerHeight) el.style.top = `${window.innerHeight - rect.height - 4}px`; - }, [x, y]); - - /* ── Tech context menu ────────────────────────────────── */ - if (type === 'tech') { - const hasMulti = selectedTechIds.length > 1 && selectedTechIds.includes(data.id); - return ( -
e.stopPropagation()}> - {hasMulti ? ( -
{selectedTechIds.length} techs selected
- ) : ( -
Tech #{data.id} — {data.name}
- )} -
-
{hasMulti ? 'Set All Status' : 'Set Status'}
- - - -
- ); - } - - /* ── Job context menu ─────────────────────────────────── */ - if (type === 'job') { - const isAssigned = data.status === 'assigned'; - const isInProgress = data.status === 'in_progress'; - const isCompleted = data.status === 'completed'; - const isCancelled = data.status === 'cancelled'; - const hasMultiSelect = selectedJobIds.length > 1 && selectedJobIds.includes(data.id); - const multiAssignedIds = hasMultiSelect ? selectedJobIds : []; - - return ( -
e.stopPropagation()}> - {/* Header */} - {hasMultiSelect ? ( -
{selectedJobIds.length} jobs selected
- ) : ( - <> -
Job #{data.id} — {data.customer_name}
-
- - {data.status.replace(/_/g, ' ')} - -
- - )} -
- - {/* Assign / Reassign submenu */} - {!isCompleted && !isCancelled && ( -
setSubmenu('assign')} - onMouseLeave={() => setSubmenu(null)} - > - {hasMultiSelect - ? `Assign ${selectedJobIds.length} Jobs To` - : (isAssigned || isInProgress ? 'Reassign To' : 'Assign To') - } - - - {submenu === 'assign' && ( -
- {(() => { - const requiredSkills = hasMultiSelect ? [] : (data.required_skills || []); - const available = technicians.filter((t) => t.status !== 'off_duty'); - - // Only filter by skills for single job assignment - const qualified = requiredSkills.length > 0 - ? available.filter((t) => requiredSkills.every((s) => t.skills?.includes(s))) - : available; - const unqualified = requiredSkills.length > 0 - ? available.filter((t) => !requiredSkills.every((s) => t.skills?.includes(s))) - : []; - - if (available.length === 0) return
No techs available
; - - return ( - <> - {qualified.length > 0 && requiredSkills.length > 0 && ( -
Qualified
- )} - {qualified.map((tech) => ( - - ))} - {unqualified.length > 0 && ( - <> -
-
Missing Skills
- - )} - {unqualified.map((tech) => { - const missing = requiredSkills.filter((s) => !tech.skills?.includes(s)); - return ( - - ); - })} - - ); - })()} -
- )} -
- )} - - {/* Unassign — single or batch */} - {hasMultiSelect ? ( - - ) : ( - (isAssigned || isInProgress) && ( - - ) - )} - - {/* Single-job actions only when not multi-selected */} - {!hasMultiSelect && ( - <> -
- {isAssigned && ( - - )} - {isInProgress && ( - - )} - {!isCompleted && !isCancelled && ( - - )} - {!isCompleted && !isCancelled && ( - <> -
- - - )} - - )} -
- ); - } - - return null; -} diff --git a/frontend/src/components/Dashboard.jsx b/frontend/src/components/Dashboard.jsx deleted file mode 100644 index a587b2a..0000000 --- a/frontend/src/components/Dashboard.jsx +++ /dev/null @@ -1,813 +0,0 @@ -import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; -import { api } from '../api/client'; -import TechGrid from './TechGrid'; -import JobGrid from './JobGrid'; -import MapWindow from './MapWindow'; -import ContextMenu from './ContextMenu'; -import Toast from './Toast'; -import TechTimeline from './TechTimeline'; -import FilterWindow from './FilterWindow'; -import PersonnelWindow from './PersonnelWindow'; -import JobSearchWindow from './JobSearchWindow'; -import JobDetailPanel from './JobDetailPanel'; -import CalendarPicker from './CalendarPicker'; -import SimBar from './SimBar'; -import { useSimEvents } from '../hooks/useSimEvents'; - -/* ── Helpers ─────────────────────────────────────────────── */ -function fmtDate(d) { - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; -} -function fmtUTCDate(d) { - return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`; -} -function fmtDateDisplay(d) { - const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; - const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - return `${days[d.getDay()]} ${months[d.getMonth()]} ${d.getDate()}`; -} -function sameDay(a, b) { return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); } - -export default function Dashboard() { - /* ── Data ─────────────────────────────────────────────── */ - const [techs, setTechs] = useState([]); - const [jobs, setJobs] = useState([]); - const [summary, setSummary] = useState(null); - const [loading, setLoading] = useState(true); - - /* ── Day ──────────────────────────────────────────────── */ - const [viewDate, setViewDate] = useState(() => new Date()); - const [calOpen, setCalOpen] = useState(false); - const calAnchorRef = useRef(null); - const isToday = sameDay(viewDate, new Date()); - - /* ── UI ───────────────────────────────────────────────── */ - const [splitRatio, setSplitRatio] = useState(0.4); - const [dividerDrag, setDividerDrag] = useState(false); - const [mapOpen, setMapOpen] = useState(false); - const [timelineOpen, setTimelineOpen] = useState(false); - const [autoRouting, setAutoRouting] = useState(false); - const [refreshing, setRefreshing] = useState(false); - const splitRef = useRef(null); - const [timelineHeight, setTimelineHeight] = useState(180); - const [tlDrag, setTlDrag] = useState(false); - - /* ── v0.0.8 Windows ───────────────────────────────────── */ - const [filterOpen, setFilterOpen] = useState(false); - const [personnelOpen, setPersonnelOpen] = useState(false); - const [jobSearchOpen, setJobSearchOpen] = useState(false); - const [detailJob, setDetailJob] = useState(null); - - /* ── v0.0.8 Display Filter state ─────────────────────── */ - const [displayFilter, setDisplayFilter] = useState(null); - // displayFilter shape: { timeSlots: [], jobTypes: [], routeCriteria: [], techIds: [] } or null - - /* ── v0.0.8 Override Warning ──────────────────────────── */ - const [overrideWarning, setOverrideWarning] = useState(null); - // shape: { jobIds, techId, techName, issues: [{label, pass}] } - - /* ── Autoroute confirmation ───────────────────────────── */ - const [autoRouteConfirm, setAutoRouteConfirm] = useState(false); - - /* ── Context menu ─────────────────────────────────────── */ - const [ctxMenu, setCtxMenu] = useState(null); - - /* ── Selection ────────────────────────────────────────── */ - const [selJobs, setSelJobs] = useState([]); - const [selTechs, setSelTechs] = useState([]); - - /* ── Drag ─────────────────────────────────────────────── */ - const [dragJob, setDragJob] = useState(null); - const techPaneRef = useRef(null); - const techGridRef = useRef(null); - const jobGridRef = useRef(null); - const focusedGridRef = useRef('jobs'); // 'techs' | 'jobs' - const dragJobRef = useRef(null); - const dragGhostRef = useRef(null); - const selJobsRef = useRef(selJobs); - useEffect(() => { dragJobRef.current = dragJob; }, [dragJob]); - useEffect(() => { selJobsRef.current = selJobs; }, [selJobs]); - - /* ── Demo mode ───────────────────────────────────────── */ - const [isDemo, setIsDemo] = useState(false); - useEffect(() => { - api.simStatus().then((r) => { - if (r.data.is_demo) { setIsDemo(true); setViewDate(new Date()); } - }).catch(() => {}); - }, []); - - /* ── Simulation state ────────────────────────────────── */ - const [simElapsed, setSimElapsed] = useState(null); // elapsed minutes since 08:00 virtual - const [overrunMap, setOverrunMap] = useState(() => new Map()); - const [demoLocked, setDemoLocked] = useState(false); - - /* ── Real clock ───────────────────────────────────────── */ - const [realClock, setRealClock] = useState(() => new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })); - useEffect(() => { - const i = setInterval(() => setRealClock(new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })), 10000); - return () => clearInterval(i); - }, []); - // Refs so handleSimEvent never goes stale without re-registering the WS listener - const loadDataRef = useRef(null); - const toastRef = useRef(null); - const techsRef = useRef([]); - const fJobsRef = useRef([]); - - /* ── Filters (dashboard bar) ─────────────────────────── */ - const [jobFilter, setJobFilter] = useState(null); - const [techFilter, setTechFilter] = useState(null); - - /* ── Toasts ───────────────────────────────────────────── */ - const [toasts, setToasts] = useState([]); - const tid = useRef(0); - const toast = useCallback((msg, type = 'info') => { - const id = ++tid.current; - setToasts((p) => [...p, { id, msg, type }]); - setTimeout(() => setToasts((p) => p.filter((t) => t.id !== id)), 3000); - }, []); - - /* ── Data loading ─────────────────────────────────────── */ - const loadData = useCallback(async (showRefresh = false) => { - if (showRefresh) setRefreshing(true); - // Demo seeds use UTC date; use UTC to match regardless of local timezone - const d = isDemo ? fmtUTCDate(new Date()) : fmtDate(viewDate); - try { - const [tr, jr, sr] = await Promise.all([ - api.getTechnicians(), - api.getJobs({ scheduled_date: d }), - api.getJobsSummary({ target_date: d }), - ]); - setTechs(tr.data); - setJobs(jr.data); - setSummary(sr.data); - } catch (e) { - console.error('Load error:', e); - toast('Failed to load data', 'error'); - } finally { - setLoading(false); - setRefreshing(false); - } - }, [viewDate, toast, isDemo]); - - useEffect(() => { setLoading(true); loadData(); }, [viewDate]); // eslint-disable-line - useEffect(() => { const i = setInterval(() => loadData(), 30000); return () => clearInterval(i); }, [loadData]); - - // Keep refs current so the stable WS handler always calls latest versions - useEffect(() => { loadDataRef.current = loadData; }, [loadData]); - useEffect(() => { toastRef.current = toast; }, [toast]); - useEffect(() => { techsRef.current = techs; }, [techs]); - - /* ── Sim event handler (stable ref — never re-registers WS) ── */ - const handleSimEvent = useCallback((ev) => { - if (ev.event_type === 'clock_tick') { - setSimElapsed(ev.details?.elapsed_minutes ?? null); - } else if (ev.event_type === 'job_assigned' || ev.event_type === 'job_started') { - loadDataRef.current?.(); - } else if (ev.event_type === 'job_completed') { - setOverrunMap((prev) => { const next = new Map(prev); next.delete(ev.job_id); return next; }); - loadDataRef.current?.(); - } else if (ev.event_type === 'overrun_warning') { - setOverrunMap((prev) => { const next = new Map(prev); next.set(ev.job_id, ev.details?.severity ?? 'yellow'); return next; }); - } else if (ev.event_type === 'scripted_beat') { - toastRef.current?.(ev.details?.description ?? 'Scripted event fired', 'info'); - loadDataRef.current?.(); - } else if (ev.event_type === 'day_complete') { - toastRef.current?.('Demo day complete — all jobs finished', 'success'); - loadDataRef.current?.(); - } - }, []); // stable — reads latest via refs - - useSimEvents(handleSimEvent); - - /* ── Close context menu ───────────────────────────────── */ - useEffect(() => { const c = () => setCtxMenu(null); document.addEventListener('click', c); return () => document.removeEventListener('click', c); }, []); - - /* ── Keyboard shortcuts ───────────────────────────────── */ - useEffect(() => { - const h = (e) => { - if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return; - // Don't process shortcuts if inside a floating window - if (e.target.closest('.fw-window') || e.target.closest('.fw-filter') || e.target.closest('.fw-personnel') || e.target.closest('.fw-job-search') || e.target.closest('.fw-job-detail')) return; - if (e.key === 'Escape') { setCtxMenu(null); setMapOpen(false); setDetailJob(null); setFilterOpen(false); setPersonnelOpen(false); setJobSearchOpen(false); } - if (e.key === 'a' && (e.ctrlKey || e.metaKey)) { - e.preventDefault(); - if (focusedGridRef.current === 'techs') { - setSelTechs((techsRef.current || []).map((t) => t.id)); - techGridRef.current?.selectAll(); - } else { - setSelJobs((fJobsRef.current || []).map((j) => j.id)); - jobGridRef.current?.selectAll(); - } - return; - } - if (e.key === 'r' && !e.ctrlKey && !e.metaKey) loadData(true); - if (e.key === 'm' && !e.ctrlKey && !e.metaKey) setMapOpen((p) => !p); - if (e.key === 't' && !e.ctrlKey && !e.metaKey) setTimelineOpen((p) => !p); - if (e.key === 'f' && !e.ctrlKey && !e.metaKey) setFilterOpen((p) => !p); - if (e.key === 'p' && !e.ctrlKey && !e.metaKey) setPersonnelOpen((p) => !p); - if (e.key === 'j' && !e.ctrlKey && !e.metaKey) setJobSearchOpen((p) => !p); - if (e.key === 'a' && !e.ctrlKey && !e.metaKey) setAutoRouteConfirm(true); - }; - document.addEventListener('keydown', h); - return () => document.removeEventListener('keydown', h); - }, [loadData]); - - /* ── Day nav ──────────────────────────────────────────── */ - const goDay = useCallback((n) => setViewDate((p) => { const d = new Date(p); d.setDate(d.getDate() + n); return d; }), []); - - /* ── Drag system ─────────────────────────────────────── */ - useEffect(() => { - if (!dragJob) return; - const ghost = dragGhostRef.current; - - const updateGhost = (clientX, clientY) => { - if (!ghost) return; - const currentJob = dragJobRef.current; - const currentSel = selJobsRef.current; - ghost.style.left = `${clientX + 12}px`; - ghost.style.top = `${clientY - 10}px`; - ghost.style.display = 'block'; - ghost.textContent = currentSel.length > 1 && currentJob && currentSel.includes(currentJob.id) - ? `${currentSel.length} jobs` - : currentJob ? `Job #${currentJob.job_number || currentJob.id} — ${currentJob.customer_name}` : ''; - }; - - const dropAt = (clientX, clientY) => { - const job = dragJobRef.current; - if (job && techGridRef.current) { - const pane = techPaneRef.current; - const el = document.elementFromPoint(clientX, clientY); - if (pane?.contains(el)) { - const techId = techGridRef.current.getTechIdAtPoint(clientX, clientY); - if (techId != null) { - const currentSel = selJobsRef.current; - const ids = currentSel.length > 0 && currentSel.includes(job.id) ? currentSel : [job.id]; - doAssignWithCheck(ids, techId); - } - } - } - if (ghost) ghost.style.display = 'none'; - setDragJob(null); - document.body.style.userSelect = ''; - document.body.style.cursor = ''; - }; - - // Mouse handlers - const onMove = (e) => updateGhost(e.clientX, e.clientY); - const onUp = (e) => dropAt(e.clientX, e.clientY); - - // Touch handlers - const onTouchMove = (e) => { - e.preventDefault(); - const t = e.touches[0]; - updateGhost(t.clientX, t.clientY); - }; - const onTouchEnd = (e) => { - const t = e.changedTouches[0]; - dropAt(t.clientX, t.clientY); - }; - - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); - document.addEventListener('touchmove', onTouchMove, { passive: false }); - document.addEventListener('touchend', onTouchEnd); - return () => { - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); - document.removeEventListener('touchmove', onTouchMove); - document.removeEventListener('touchend', onTouchEnd); - if (ghost) ghost.style.display = 'none'; - document.body.style.userSelect = ''; - document.body.style.cursor = ''; - }; - }, [dragJob]); // eslint-disable-line - - /* ── Assign with CanDo override check ────────────────── */ - // Block any assignment path while the demo is playing — drag-drop, context - // menu, override modal all funnel through doAssignWithCheck. - const demoLockedRef = useRef(false); - useEffect(() => { demoLockedRef.current = demoLocked; }, [demoLocked]); - - const doAssignWithCheck = useCallback(async (jobIds, techId) => { - if (demoLockedRef.current) { - toastRef.current?.('Stop the demo to assign jobs', 'warning'); - return; - } - const tech = techs.find((t) => t.id === techId); - if (!tech) return; - - // For single job, check CanDo and warn if issues - if (jobIds.length === 1) { - const job = jobs.find((j) => j.id === jobIds[0]); - if (job) { - const issues = []; - // Skill check - const missingSkills = (job.required_skills || []).filter((s) => !tech.skills?.includes(s)); - issues.push({ label: `Skill${missingSkills.length > 0 ? ` (missing: ${missingSkills.join(', ')})` : ''}`, pass: missingSkills.length === 0 }); - // Route check - const routeMatch = !job.route_criteria || (tech.assigned_routes || []).includes(job.route_criteria); - issues.push({ label: `Route${!routeMatch ? ` (job: ${job.route_criteria}, tech: ${(tech.assigned_routes || []).join(', ') || 'none'})` : ''}`, pass: routeMatch }); - - const hasIssue = issues.some((i) => !i.pass); - if (hasIssue) { - setOverrideWarning({ jobIds, techId, techName: tech.name, issues }); - return; - } - } - } - - await doBatchAssign(jobIds, techId); - }, [techs, jobs]); // eslint-disable-line - - /* ── Batch assign (single API call) ───────────────────── */ - const doBatchAssign = useCallback(async (jobIds, techId) => { - const tech = techs.find((t) => t.id === techId); - if (!tech) return; - try { - const res = await api.batchAssign(jobIds, techId); - const n = res.data.assigned ?? 0; - toast(`${n} job${n !== 1 ? 's' : ''} → ${tech.name}`, n > 0 ? 'success' : 'warning'); - await loadData(true); - } catch (e) { - toast('Assignment failed', 'error'); - } - }, [techs, loadData, toast]); - - /* ── Override confirm ─────────────────────────────────── */ - const handleOverrideConfirm = useCallback(async () => { - if (!overrideWarning) return; - const { jobIds, techId } = overrideWarning; - setOverrideWarning(null); - await doBatchAssign(jobIds, techId); - }, [overrideWarning, doBatchAssign]); - - /* ── Batch unassign ───────────────────────────────────── */ - const doBatchUnassign = useCallback(async (jobIds) => { - try { - const res = await api.batchUnassign(jobIds); - const n = res.data.unassigned ?? 0; - toast(`${n} job${n !== 1 ? 's' : ''} unassigned`, n > 0 ? 'success' : 'warning'); - setSelJobs([]); - await loadData(true); - } catch (e) { - toast('Unassign failed', 'error'); - } - }, [loadData, toast]); - - /* ── Auto-route ───────────────────────────────────────── */ - const handleAutoRoute = useCallback(async () => { - setAutoRouting(true); - try { - const res = await api.autoRoute({ target_date: fmtDate(viewDate) }); - const a = res.data.jobs_assigned ?? 0; - const u = res.data.jobs_unassigned ?? 0; - toast(`Routed ${a} job${a !== 1 ? 's' : ''}${u > 0 ? ` · ${u} unassigned` : ''}`, a > 0 ? 'success' : 'warning'); - await loadData(true); - } catch { toast('Auto-route failed', 'error'); } - finally { setAutoRouting(false); } - }, [loadData, toast, viewDate]); - - /* ── Job actions ───────────────────────────────────────── */ - const handleJobAction = useCallback(async (action, job) => { - setCtxMenu(null); - const labels = { start: 'Started', complete: 'Completed', cancel: 'Cancelled', unassign: 'Unassigned', hold: 'On hold' }; - try { - if (action === 'start') await api.startJob(job.id); - else if (action === 'complete') await api.completeJob(job.id); - else if (action === 'cancel') await api.cancelJob(job.id); - else if (action === 'unassign') await api.unassignJob(job.id); - else if (action === 'hold') await api.updateJobStatus(job.id, 'on_hold'); - else if (action === 'batch_unassign') { await doBatchUnassign(selJobs); return; } - else return; - toast(`Job #${job.id} — ${labels[action]}`, 'success'); - await loadData(true); - } catch { toast(`Failed to ${action} job #${job.id}`, 'error'); } - }, [loadData, toast, doBatchUnassign, selJobs]); - - /* ── Tech actions ──────────────────────────────────────── */ - const handleTechAction = useCallback(async (action, tech) => { - setCtxMenu(null); - const map = { set_available: 'available', set_on_break: 'on_break', set_off_duty: 'off_duty' }; - const status = map[action]; - if (!status) return; - - const techIds = selTechs.length > 1 && selTechs.includes(tech.id) ? selTechs : [tech.id]; - let successCount = 0; - for (const tid of techIds) { - try { - await api.updateTechStatus(tid, status); - successCount++; - } catch (e) { - console.error(`Failed to update tech ${tid}:`, e); - } - } - if (successCount > 0) { - const label = status.replace(/_/g, ' '); - toast( - techIds.length > 1 - ? `${successCount} tech${successCount !== 1 ? 's' : ''} → ${label}` - : `${tech.name} → ${label}`, - 'success' - ); - await loadData(true); - } else { - toast('Failed to update status', 'error'); - } - }, [loadData, toast, selTechs]); - - /* ── Context menu assign (with check) ────────────────── */ - const handleAssignToTech = useCallback(async (jobId, techId) => { - setCtxMenu(null); - const ids = selJobs.length > 0 && selJobs.includes(jobId) ? selJobs : [jobId]; - await doAssignWithCheck(ids, techId); - }, [selJobs, doAssignWithCheck]); - - /* ── Selection (independent per grid) ─────────────────── */ - const handleJobClick = useCallback((id, e, displayedIds) => { - setSelJobs((prev) => { - if (e.metaKey || e.ctrlKey) return prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]; - if (e.shiftKey && prev.length > 0 && displayedIds) { - const lastSelected = prev[prev.length - 1]; - const a = displayedIds.indexOf(lastSelected); - const b = displayedIds.indexOf(id); - if (a === -1 || b === -1) return [id]; - const [start, end] = a < b ? [a, b] : [b, a]; - return [...new Set([...prev, ...displayedIds.slice(start, end + 1)])]; - } - return prev.length === 1 && prev[0] === id ? [] : [id]; - }); - }, []); - - const handleTechClick = useCallback((id, e, displayedIds) => { - setSelTechs((prev) => { - if (e.metaKey || e.ctrlKey) return prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]; - if (e.shiftKey && prev.length > 0 && displayedIds) { - const lastSelected = prev[prev.length - 1]; - const a = displayedIds.indexOf(lastSelected); - const b = displayedIds.indexOf(id); - if (a === -1 || b === -1) return [id]; - const [start, end] = a < b ? [a, b] : [b, a]; - return [...new Set([...prev, ...displayedIds.slice(start, end + 1)])]; - } - return prev.length === 1 && prev[0] === id ? [] : [id]; - }); - }, []); - - /* ── Double-click job for detail ─────────────────────── */ - const handleJobDoubleClick = useCallback((job) => { - setDetailJob(job); - }, []); - - /* ── Personnel locate ─────────────────────────────────── */ - const handleLocateTech = useCallback((techId) => { - setSelTechs([techId]); - setPersonnelOpen(false); - toast(`Located tech #${techId}`, 'info'); - }, [toast]); - - /* ── Filter toggles (dashboard bar) ───────────────────── */ - const toggleJF = useCallback((s) => { setJobFilter((p) => p === s ? null : s); setTechFilter(null); }, []); - const toggleTF = useCallback((s) => { setTechFilter((p) => p === s ? null : s); setJobFilter(null); }, []); - - /* ── Display filter apply ─────────────────────────────── */ - const handleDisplayFilterApply = useCallback((filter) => { - setDisplayFilter(filter); - if (filter) toast('Filter applied', 'info'); - else toast('Filter cleared', 'info'); - }, [toast]); - - /* ── Divider ─────────────────────────────────────────── */ - useEffect(() => { - if (!dividerDrag) return; - const onMove = (e) => { - if (!splitRef.current) return; - const r = splitRef.current.getBoundingClientRect(); - setSplitRatio(Math.min(Math.max((e.clientY - r.top) / r.height, 0.1), 0.85)); - }; - const onUp = () => setDividerDrag(false); - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); - document.body.style.cursor = 'row-resize'; - document.body.style.userSelect = 'none'; - return () => { - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - }; - }, [dividerDrag]); - - /* ── Timeline divider ─────────────────────────────────── */ - useEffect(() => { - if (!tlDrag) return; - const onMove = (e) => { - if (!splitRef.current) return; - const r = splitRef.current.getBoundingClientRect(); - const bottomY = r.bottom - e.clientY; - setTimelineHeight(Math.min(Math.max(bottomY, 100), 500)); - }; - const onUp = () => setTlDrag(false); - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); - document.body.style.cursor = 'row-resize'; - document.body.style.userSelect = 'none'; - return () => { - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - }; - }, [tlDrag]); - - /* ── Computed: apply display filter + dashboard filter ── */ - const active = useMemo(() => techs.filter((t) => ['available', 'on_job', 'en_route'].includes(t.status)).length, [techs]); - const offDuty = useMemo(() => techs.filter((t) => t.status === 'off_duty').length, [techs]); - - const fJobs = useMemo(() => { - let result = jobs; - if (displayFilter) { - result = result.filter((j) => { - const slot = j.time_slot_start && j.time_slot_end ? `${j.time_slot_start}–${j.time_slot_end}` : null; - if (slot && displayFilter.timeSlots.length > 0 && !displayFilter.timeSlots.includes(slot)) return false; - if (j.job_type && displayFilter.jobTypes.length > 0 && !displayFilter.jobTypes.includes(j.job_type)) return false; - if (j.route_criteria && displayFilter.routeCriteria.length > 0 && !displayFilter.routeCriteria.includes(j.route_criteria)) return false; - return true; - }); - } - if (jobFilter) result = result.filter((j) => j.status === jobFilter); - return result; - }, [jobs, displayFilter, jobFilter]); - - useEffect(() => { fJobsRef.current = fJobs; }, [fJobs]); - - const fTechs = useMemo(() => { - let result = techs; - if (displayFilter?.techIds?.length > 0) result = result.filter((t) => displayFilter.techIds.includes(t.id)); - if (techFilter === 'active') result = result.filter((t) => ['available', 'on_job', 'en_route'].includes(t.status)); - else if (techFilter === 'off_duty') result = result.filter((t) => t.status === 'off_duty'); - return result; - }, [techs, displayFilter, techFilter]); - - const timelineTechs = useMemo(() => selTechs.length > 0 ? techs.filter((t) => selTechs.includes(t.id)) : [], [selTechs, techs]); - - /* ── Loading ──────────────────────────────────────────── */ - if (loading && techs.length === 0) return
Loading FieldOpt...
; - - const s = summary ?? {}; - const pending = s.pending ?? 0, assigned = s.assigned ?? 0, inProg = s.in_progress ?? 0; - const completed = s.completed ?? 0, onHold = s.on_hold ?? 0; - - return ( -
- {/* ══ Header ══ */} -
-
FieldOptv{__APP_VERSION__}
- {realClock} -
-
- {!isDemo && } - - {!isDemo && } - {!isDemo && calOpen && ( - setViewDate(d)} - onClose={() => setCalOpen(false)} - anchorRef={calAnchorRef} - /> - )} -
-
- - - -
- - - - -
-
- - {/* ══ Sim Bar (demo mode only) ══ */} - - - {/* ══ Dashboard Bar ══ */} -
- toggleJF('pending')} count={pending} color="danger" label="Unassigned" /> - toggleJF('assigned')} count={assigned} color="info" label="Assigned" /> - toggleJF('in_progress')} count={inProg} color="info" label="In Progress" /> - toggleJF('completed')} count={completed} color="success" label="Completed" /> - toggleJF('on_hold')} count={onHold} color="warning" label="On Hold" /> -
0Failed
-
- toggleTF('active')} count={active} color="success" label="Techs Active" /> - toggleTF('off_duty')} count={offDuty} color="muted" label="Off Duty" /> -
{techs.length}Total
- {(jobFilter || techFilter || displayFilter) && ( - <> -
- - - )} -
- - {/* ══ Split Panes ══ */} -
- {/* Tech pane */} -
{ focusedGridRef.current = 'techs'; }} onTouchStart={() => { focusedGridRef.current = 'techs'; }}> -
- Technicians - {fTechs.length}{(techFilter || displayFilter) ? ` / ${techs.length}` : ''} - {selTechs.length > 0 && {selTechs.length} selected} -
-
- { e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY, type: 'tech', data: t }); }} - isDragTarget={!!dragJob} - /> -
-
- -
{ e.preventDefault(); setDividerDrag(true); }}> -
-
- - {/* Job pane */} -
{ focusedGridRef.current = 'jobs'; }} onTouchStart={() => { focusedGridRef.current = 'jobs'; }}> -
- Jobs - {fJobs.length}{(jobFilter || displayFilter) ? ` / ${jobs.length}` : ''} - {selJobs.length > 0 && {selJobs.length} selected} -
-
- { e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY, type: 'job', data: j }); }} - onDragStart={(job) => { setDragJob(job); }} - onRowDoubleClicked={handleJobDoubleClick} - overrunMap={overrunMap} - simElapsed={simElapsed} - /> -
-
- - {/* Timeline pane */} - {timelineOpen && ( - <> -
{ e.preventDefault(); setTlDrag(true); }}> -
-
-
-
- Timeline - {timelineTechs.length > 0 && {timelineTechs.map((t) => t.name).join(', ')}} -
-
-
- - )} -
- - {/* ══ Drag Ghost — position written via ref to avoid re-renders ══ */} -
- - {/* ══ Map ══ */} - {mapOpen && setMapOpen(false)} />} - - {/* ══ Context Menu ══ */} - {ctxMenu && ( - - )} - - {/* ══ v0.0.8 Windows ══ */} - {filterOpen && ( - { handleDisplayFilterApply(f); setFilterOpen(false); }} - onClose={() => setFilterOpen(false)} - /> - )} - - {personnelOpen && ( - { e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY, type: 'tech', data: t }); }} - onTechDetail={(tech) => setDetailJob(tech)} - onClose={() => setPersonnelOpen(false)} - /> - )} - - {jobSearchOpen && ( - setJobSearchOpen(false)} - onJobDetail={(job) => setDetailJob(job)} - onDragStart={(job) => { setDragJob(job); }} - onContextMenu={(e, j) => { e.preventDefault(); setCtxMenu({ x: e.clientX, y: e.clientY, type: 'job', data: j }); }} - /> - )} - - {detailJob && ( - setDetailJob(null)} - /> - )} - - {/* ══ Autoroute Confirmation ══ */} - {autoRouteConfirm && ( -
setAutoRouteConfirm(false)}> -
e.stopPropagation()}> -
Auto-Route Confirmation
-
- Auto-route will assign {pending} pending job{pending !== 1 ? 's' : ''} to available technicians based on skill, route criteria, and distance. -
-
- - -
-
-
- )} - - {/* ══ Override Warning ══ */} - {overrideWarning && ( -
setOverrideWarning(null)}> -
e.stopPropagation()}> -
Assignment Warning
-
- Assigning {overrideWarning.jobIds.length === 1 ? `job #${overrideWarning.jobIds[0]}` : `${overrideWarning.jobIds.length} jobs`} to {overrideWarning.techName}: - {overrideWarning.issues.map((issue, i) => ( -
- - {issue.pass ? '✓' : '✕'} - - {issue.label} -
- ))} -
-
- - -
-
-
- )} - - {/* ══ Toasts ══ */} -
- {toasts.map((t) => )} -
-
- ); -} - -/* ── Dashboard Indicator ─────────────────────────────────── */ -function DI({ active, onClick, count, color, label }) { - return ( - - ); -} - -/* ── Icons ─────────────────────────────────────────────── */ -function MapIcon() { return ; } -function RefreshIcon({ spinning }) { return ; } -function BoltIcon() { return ; } -function TimelineIcon() { return ; } -function FilterIcon() { return ; } -function PersonnelIcon() { return ; } -function SearchIcon() { return ; } diff --git a/frontend/src/components/FilterWindow.jsx b/frontend/src/components/FilterWindow.jsx deleted file mode 100644 index f4aa7fe..0000000 --- a/frontend/src/components/FilterWindow.jsx +++ /dev/null @@ -1,176 +0,0 @@ -import { useState, useMemo, useCallback } from 'react'; -import FloatingWindow from './FloatingWindow'; - -/** - * FilterWindow — WFX-style sub-filter for the R&D. - * Filters both grids by time slot, job type, route criteria, and technician. - * - * Props: - * jobs — full unfiltered jobs array (to derive available options) - * technicians — full unfiltered techs array - * activeFilter — current filter state { timeSlots, jobTypes, routeCriteria, techIds } - * onApply — callback with new filter object - * onClose — close handler - */ -export default function FilterWindow({ jobs, technicians, activeFilter, onApply, onClose }) { - // Derive available options from loaded data - const options = useMemo(() => { - const timeSlots = new Set(); - const jobTypes = new Set(); - const routeCriteria = new Set(); - - jobs.forEach((j) => { - if (j.time_slot_start && j.time_slot_end) { - timeSlots.add(`${j.time_slot_start}–${j.time_slot_end}`); - } - if (j.job_type) jobTypes.add(j.job_type); - if (j.route_criteria) routeCriteria.add(j.route_criteria); - }); - - return { - timeSlots: [...timeSlots].sort(), - jobTypes: [...jobTypes].sort(), - routeCriteria: [...routeCriteria].sort(), - techs: technicians.map((t) => ({ id: t.id, name: t.name, employeeId: t.employee_id })), - }; - }, [jobs, technicians]); - - // Local selection state — initialize from activeFilter or select all - const [selTimeSlots, setSelTimeSlots] = useState( - activeFilter?.timeSlots ?? [...options.timeSlots] - ); - const [selJobTypes, setSelJobTypes] = useState( - activeFilter?.jobTypes ?? [...options.jobTypes] - ); - const [selRouteCriteria, setSelRouteCriteria] = useState( - activeFilter?.routeCriteria ?? [...options.routeCriteria] - ); - const [selTechIds, setSelTechIds] = useState( - activeFilter?.techIds ?? options.techs.map((t) => t.id) - ); - - /* ── Toggle helpers ───────────────────────────────────── */ - const toggle = useCallback((set, setter, item) => { - setter((prev) => prev.includes(item) ? prev.filter((x) => x !== item) : [...prev, item]); - }, []); - - const selectAll = useCallback((allItems, setter) => setter([...allItems]), []); - const selectNone = useCallback((setter) => setter([]), []); - - /* ── Apply / Reset ────────────────────────────────────── */ - const handleApply = useCallback(() => { - onApply({ - timeSlots: selTimeSlots, - jobTypes: selJobTypes, - routeCriteria: selRouteCriteria, - techIds: selTechIds, - }); - }, [selTimeSlots, selJobTypes, selRouteCriteria, selTechIds, onApply]); - - const handleReset = useCallback(() => { - setSelTimeSlots([...options.timeSlots]); - setSelJobTypes([...options.jobTypes]); - setSelRouteCriteria([...options.routeCriteria]); - setSelTechIds(options.techs.map((t) => t.id)); - onApply(null); // null = no filter active - }, [options, onApply]); - - return ( - -
- {/* Time Slots */} - toggle(selTimeSlots, setSelTimeSlots, item)} - onSelectAll={() => selectAll(options.timeSlots, setSelTimeSlots)} - onSelectNone={() => selectNone(setSelTimeSlots)} - /> - - {/* Job Types */} - toggle(selJobTypes, setSelJobTypes, item)} - onSelectAll={() => selectAll(options.jobTypes, setSelJobTypes)} - onSelectNone={() => selectNone(setSelJobTypes)} - formatItem={(s) => s.replace(/_/g, ' ')} - /> - - {/* Route Criteria */} - toggle(selRouteCriteria, setSelRouteCriteria, item)} - onSelectAll={() => selectAll(options.routeCriteria, setSelRouteCriteria)} - onSelectNone={() => selectNone(setSelRouteCriteria)} - /> - - {/* Technicians */} - t.id)} - selected={selTechIds} - onToggle={(item) => toggle(selTechIds, setSelTechIds, item)} - onSelectAll={() => selectAll(options.techs.map((t) => t.id), setSelTechIds)} - onSelectNone={() => selectNone(setSelTechIds)} - formatItem={(id) => { - const t = options.techs.find((x) => x.id === id); - return t ? `${t.employeeId || t.id} — ${t.name}` : String(id); - }} - /> -
- - {/* Action buttons */} -
- -
- - -
- - ); -} - - -/* ── Reusable multi-select list column ────────────────── */ -function FilterList({ label, items, selected, onToggle, onSelectAll, onSelectNone, formatItem }) { - const fmt = formatItem || ((x) => String(x)); - - return ( -
-
- {label} - {selected.length}/{items.length} -
-
- {items.map((item) => ( - - ))} -
-
- - -
-
- ); -} diff --git a/frontend/src/components/FloatingWindow.jsx b/frontend/src/components/FloatingWindow.jsx deleted file mode 100644 index 7059f7a..0000000 --- a/frontend/src/components/FloatingWindow.jsx +++ /dev/null @@ -1,114 +0,0 @@ -import { useState, useRef, useCallback, useEffect } from 'react'; - -/** - * FloatingWindow — reusable draggable/resizable floating panel. - * Used by FilterWindow, PersonnelWindow, JobSearchWindow, JobDetailPanel. - * - * Props: - * title — window title text - * onClose — close handler - * defaultPos — { x, y } initial position - * defaultSize — { w, h } initial size - * minSize — { w, h } minimum dimensions - * children — window body content - * className — optional extra class on the wrapper - * zIndex — optional z-index override - * resizable — whether corner resize is enabled (default true) - */ -export default function FloatingWindow({ - title, - onClose, - defaultPos = { x: 120, y: 80 }, - defaultSize = { w: 500, h: 400 }, - minSize = { w: 300, h: 200 }, - children, - className = '', - zIndex = 1500, - resizable = true, -}) { - const [pos, setPos] = useState(defaultPos); - const [size, setSize] = useState(defaultSize); - const dragRef = useRef(false); - const offsetRef = useRef({ x: 0, y: 0 }); - - /* ── Titlebar drag ────────────────────────────────────── */ - const handleTitleMouseDown = useCallback((e) => { - if (e.target.closest('.fw-close')) return; // don't drag on close button - e.preventDefault(); - offsetRef.current = { x: e.clientX - pos.x, y: e.clientY - pos.y }; - dragRef.current = true; - - const onMove = (ev) => { - if (!dragRef.current) return; - setPos({ - x: Math.max(0, ev.clientX - offsetRef.current.x), - y: Math.max(0, ev.clientY - offsetRef.current.y), - }); - }; - const onUp = () => { - dragRef.current = false; - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - }; - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); - document.body.style.cursor = 'move'; - document.body.style.userSelect = 'none'; - }, [pos]); - - /* ── Corner resize ────────────────────────────────────── */ - const handleResizeMouseDown = useCallback((e) => { - e.preventDefault(); - e.stopPropagation(); - const startX = e.clientX; - const startY = e.clientY; - const startW = size.w; - const startH = size.h; - - const onMove = (ev) => { - setSize({ - w: Math.max(minSize.w, startW + (ev.clientX - startX)), - h: Math.max(minSize.h, startH + (ev.clientY - startY)), - }); - }; - const onUp = () => { - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - }; - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); - document.body.style.cursor = 'nwse-resize'; - document.body.style.userSelect = 'none'; - }, [size, minSize]); - - /* ── Escape to close ──────────────────────────────────── */ - useEffect(() => { - const h = (e) => { - if (e.key === 'Escape') onClose?.(); - }; - document.addEventListener('keydown', h); - return () => document.removeEventListener('keydown', h); - }, [onClose]); - - return ( -
-
- {title} - -
-
- {children} -
- {resizable && ( -
- )} -
- ); -} diff --git a/frontend/src/components/JobDetailPanel.jsx b/frontend/src/components/JobDetailPanel.jsx deleted file mode 100644 index ee86513..0000000 --- a/frontend/src/components/JobDetailPanel.jsx +++ /dev/null @@ -1,125 +0,0 @@ -import FloatingWindow from './FloatingWindow'; - -/** - * JobDetailPanel — displays full job information. - * Opened by double-clicking a job in the main grid or job search. - * - * Props: - * job — the full job object - * onClose — close handler - */ -export default function JobDetailPanel({ job, onClose }) { - if (!job) return null; - - const fmtDt = (iso) => { - if (!iso) return '—'; - const d = new Date(iso); - return `${(d.getMonth() + 1).toString().padStart(2, '0')}/${d.getDate().toString().padStart(2, '0')}/${d.getFullYear()} ${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`; - }; - - return ( - -
- {/* Status + Type header */} -
- - {job.status.replace(/_/g, ' ')} - - {job.job_type?.replace(/_/g, ' ')} - Pri {job.priority} -
- - {/* Customer */} -
-
Customer
-
Name{job.customer_name}
- {job.customer_phone &&
Phone{job.customer_phone}
} - {job.customer_email &&
Email{job.customer_email}
} -
- - {/* Location */} -
-
Service Location
-
Address{job.service_address}
-
- City / Zip - {[job.service_city, job.service_zip].filter(Boolean).join(' ') || '—'} -
-
Route{job.route_criteria || '—'}
-
- Lat / Lon - {job.latitude?.toFixed(4)}, {job.longitude?.toFixed(4)} -
-
- - {/* Schedule */} -
-
Schedule
-
Date{fmtDt(job.scheduled_date)?.split(' ')[0]}
-
- Time Slot - - {job.time_slot_start && job.time_slot_end ? `${job.time_slot_start}–${job.time_slot_end}` : '—'} - -
-
Duration{job.estimated_duration} min
-
- - {/* Assignment */} -
-
Assignment
-
- Tech - {job.assigned_tech_name || 'Unassigned'} -
-
- - {/* Skills */} -
-
Required Skills
-
- {job.required_skills?.length > 0 - ? job.required_skills.map((s) => {s}) - : None - } -
-
- - {/* Description / Notes */} - {job.description && ( -
-
Description
-
{job.description}
-
- )} - {job.special_instructions && ( -
-
Special Instructions
-
{job.special_instructions}
-
- )} - {job.notes && ( -
-
Notes
-
{job.notes}
-
- )} - - {/* Timestamps */} -
-
Created{fmtDt(job.created_at)}
- {job.started_at &&
Started{fmtDt(job.started_at)}
} - {job.completed_at &&
Completed{fmtDt(job.completed_at)}
} -
-
-
- ); -} diff --git a/frontend/src/components/JobGrid.jsx b/frontend/src/components/JobGrid.jsx deleted file mode 100644 index 7a5a932..0000000 --- a/frontend/src/components/JobGrid.jsx +++ /dev/null @@ -1,274 +0,0 @@ -import { useMemo, useRef, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react'; -import { AgGridReact } from 'ag-grid-react'; -import { AllCommunityModule, ModuleRegistry } from 'ag-grid-community'; - -ModuleRegistry.registerModules([AllCommunityModule]); - -function StatusCellRenderer({ value }) { - if (!value) return null; - return {value.replace(/_/g, ' ')}; -} - -function PriorityCellRenderer({ value }) { - if (!value) return null; - return {value}; -} - -function SkillsCellRenderer({ value }) { - if (!value || value.length === 0) return ; - return ( - - {value.map((skill) => {skill})} - - ); -} - -function TimeSlotCellRenderer({ data }) { - if (!data?.time_slot_start || !data?.time_slot_end) return ; - return ( - - {data.time_slot_start}–{data.time_slot_end} - - ); -} - -function JobTypeCellRenderer({ value }) { - if (!value) return null; - return {value.replace(/_/g, ' ')}; -} - -function DurationCellRenderer({ value }) { - if (!value) return null; - const hrs = Math.floor(value / 60); - const mins = value % 60; - return ( - - {hrs > 0 ? `${hrs}h${mins > 0 ? ` ${mins}m` : ''}` : `${mins}m`} - - ); -} - -function DateCellRenderer({ value }) { - if (!value) return ; - const d = new Date(value); - return ( - - {(d.getMonth() + 1).toString().padStart(2, '0')}/{d.getDate().toString().padStart(2, '0')} - - ); -} - -const JobGrid = forwardRef(function JobGrid({ jobs, selectedIds = [], onRowClicked, onContextMenu, onDragStart, onRowDoubleClicked, overrunMap, simElapsed }, ref) { - const gridRef = useRef(null); - - useImperativeHandle(ref, () => ({ - selectAll: () => { gridRef.current?.api?.selectAll(); }, - }), []); - - const columnDefs = useMemo(() => [ - { - headerName: 'Job ID', width: 85, pinned: 'left', sort: 'asc', - valueGetter: (p) => p.data?.job_number || String(p.data?.id), - comparator: (a, b) => { - const na = parseInt(a, 10), nb = parseInt(b, 10); - if (!isNaN(na) && !isNaN(nb)) return na - nb; - return a.localeCompare(b); - }, - cellStyle: { fontFamily: 'var(--font-mono)', fontSize: 'var(--font-size-xs)' }, - }, - { field: 'job_type', headerName: 'Type', width: 100, cellRenderer: JobTypeCellRenderer }, - { field: 'status', headerName: 'Status', width: 110, cellRenderer: StatusCellRenderer }, - { - field: 'assigned_tech_name', headerName: 'Tech', width: 110, - cellStyle: (p) => ({ - fontSize: 'var(--font-size-xs)', - color: p.value ? 'var(--text-primary)' : 'var(--text-muted)', - }), - valueFormatter: (p) => p.value || '—', - }, - { field: 'priority', headerName: 'Pri', width: 50, cellRenderer: PriorityCellRenderer }, - { field: 'customer_name', headerName: 'Customer', minWidth: 140, flex: 1 }, - { - field: 'route_criteria', headerName: 'RteC', width: 90, - cellStyle: { fontFamily: 'var(--font-mono)', fontSize: 'var(--font-size-xs)', color: 'var(--text-secondary)' }, - valueFormatter: (p) => p.value || '—', - }, - { - field: 'service_address', headerName: 'Address', minWidth: 180, flex: 1.5, - cellStyle: { fontSize: 'var(--font-size-xs)', color: 'var(--text-secondary)' }, - }, - { - field: 'service_city', headerName: 'City', width: 100, - cellStyle: { fontSize: 'var(--font-size-xs)', color: 'var(--text-secondary)' }, - }, - { - field: 'service_zip', headerName: 'Zip', width: 70, - cellStyle: { fontFamily: 'var(--font-mono)', fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)' }, - }, - { field: 'scheduled_date', headerName: 'Date', width: 65, cellRenderer: DateCellRenderer }, - { - headerName: 'Time Slot', width: 105, cellRenderer: TimeSlotCellRenderer, - valueGetter: (p) => p.data?.time_slot_start ? `${p.data.time_slot_start}-${p.data.time_slot_end}` : '', - }, - { field: 'estimated_duration', headerName: 'Dur', width: 65, cellRenderer: DurationCellRenderer }, - { - field: 'required_skills', headerName: 'Skills', minWidth: 150, flex: 1, - cellRenderer: SkillsCellRenderer, - valueFormatter: (p) => p.value?.join(', ') ?? '', - }, - ], []); - - const defaultColDef = useMemo(() => ({ sortable: true, resizable: true, suppressMovable: false }), []); - - const rowSelection = useMemo(() => ({ - mode: 'multiRow', - checkboxes: false, - headerCheckbox: false, - enableClickSelection: false, - }), []); - - // Sync AG Grid selection — O(1) per selected ID via getRowNode hash lookup - useEffect(() => { - const gridApi = gridRef.current?.api; - if (!gridApi) return; - gridApi.deselectAll(); - selectedIds.forEach((id) => { - gridApi.getRowNode(String(id))?.setSelected(true, false, 'api'); - }); - }, [selectedIds, jobs]); - - const onCellContextMenu = useCallback((p) => { - if (p.event && p.data && onContextMenu) onContextMenu(p.event, p.data); - }, [onContextMenu]); - - const handleRowClicked = useCallback((p) => { - if (p.data && onRowClicked) { - const displayedIds = []; - gridRef.current?.api?.forEachNodeAfterFilterAndSort((node) => { - if (node.data) displayedIds.push(node.data.id); - }); - onRowClicked(p.data.id, p.event, displayedIds); - } - }, [onRowClicked]); - - // Drag initiation — mousedown with 8px threshold - const handleMouseDown = useCallback((e) => { - if (e.button !== 0) return; - if (e.target.closest('.ag-header')) return; - if (e.target.closest('.ag-horizontal-right-spacer')) return; - const rowEl = e.target.closest('.ag-row'); - if (!rowEl) return; - - const startX = e.clientX; - const startY = e.clientY; - let fired = false; - - const onMove = (ev) => { - if (fired) return; - if (Math.abs(ev.clientX - startX) + Math.abs(ev.clientY - startY) > 8) { - fired = true; - const idx = Number(rowEl.getAttribute('row-index')); - const node = gridRef.current?.api?.getDisplayedRowAtIndex(idx); - if (node?.data && onDragStart) { - document.body.style.userSelect = 'none'; - document.body.style.cursor = 'grabbing'; - onDragStart(node.data); - } - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); - } - }; - const onUp = () => { - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); - }; - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); - }, [onDragStart]); - - // Touch drag initiation — long-press 200ms threshold - const handleTouchStart = useCallback((e) => { - if (e.target.closest('.ag-header')) return; - const rowEl = e.target.closest('.ag-row'); - if (!rowEl) return; - - const t0 = e.touches[0]; - const startX = t0.clientX; - const startY = t0.clientY; - let timer = null; - let fired = false; - - const cancel = () => { - clearTimeout(timer); - rowEl.removeEventListener('touchmove', onTouchMove); - rowEl.removeEventListener('touchend', cancel); - }; - const onTouchMove = (ev) => { - const t = ev.touches[0]; - if (Math.abs(t.clientX - startX) + Math.abs(t.clientY - startY) > 10) cancel(); - }; - - timer = setTimeout(() => { - if (fired) return; - fired = true; - const idx = Number(rowEl.getAttribute('row-index')); - const node = gridRef.current?.api?.getDisplayedRowAtIndex(idx); - if (node?.data && onDragStart) { - if (navigator.vibrate) navigator.vibrate(30); - onDragStart(node.data); - } - cancel(); - }, 200); - - rowEl.addEventListener('touchmove', onTouchMove, { passive: true }); - rowEl.addEventListener('touchend', cancel, { once: true }); - }, [onDragStart]); - - const handleDoubleClick = useCallback((p) => { - if (p.data && onRowDoubleClicked) onRowDoubleClicked(p.data); - }, [onRowDoubleClicked]); - - return ( -
- String(p.data.id)} - rowSelection={rowSelection} - selectionColumnDef={null} - animateRows={false} - headerHeight={28} - rowHeight={26} - suppressCellFocus={true} - rowClassRules={{ - 'row--overrun-yellow': (p) => overrunMap?.get(p.data?.id) === 'yellow', - 'row--overrun-red': (p) => overrunMap?.get(p.data?.id) === 'red', - 'row--overdue': (p) => { - if (simElapsed == null) return false; - const status = p.data?.status; - if (status === 'completed' || status === 'cancelled') return false; - const slotEnd = p.data?.time_slot_end; - if (!slotEnd) return false; - const [eh, em] = slotEnd.split(':').map(Number); - const slotEndMin = eh * 60 + em; - const nowMin = 8 * 60 + simElapsed; - return nowMin > slotEndMin; - }, - }} - onCellContextMenu={onCellContextMenu} - onRowClicked={handleRowClicked} - onRowDoubleClicked={handleDoubleClick} - preventDefaultOnContextMenu={true} - /> -
- ); -}); - -export default JobGrid; diff --git a/frontend/src/components/JobSearchWindow.jsx b/frontend/src/components/JobSearchWindow.jsx deleted file mode 100644 index 29ae600..0000000 --- a/frontend/src/components/JobSearchWindow.jsx +++ /dev/null @@ -1,306 +0,0 @@ -import { useState, useCallback, useRef, useMemo } from 'react'; -import { AgGridReact } from 'ag-grid-react'; -import { AllCommunityModule, ModuleRegistry } from 'ag-grid-community'; -import FloatingWindow from './FloatingWindow'; -import { api } from '../api/client'; - -ModuleRegistry.registerModules([AllCommunityModule]); - -/** - * JobSearchWindow — WFX-style Job Search. - * Opens with search criteria form. User fills criteria, hits Search. - * Results display in a grid. Can modify criteria and re-search. - * Double-click a result to open job detail. - * Right-click for context actions (assign, status change). - * Drag from results onto a tech in the main R&D. - * - * Props: - * viewDate — current R&D view date (for drag date validation) - * onClose — close handler - * onJobDetail — callback(job) to open detail view - * onDragStart — callback(job) to initiate drag into main R&D - * onContextMenu — callback(event, job) to show context menu - */ - -function fmtDate(d) { - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; -} - -export default function JobSearchWindow({ viewDate, onClose, onJobDetail, onDragStart, onContextMenu }) { - /* ── Search criteria state ────────────────────────────── */ - const [dateFrom, setDateFrom] = useState(fmtDate(viewDate)); - const [dateTo, setDateTo] = useState(fmtDate(viewDate)); - const [jobId, setJobId] = useState(''); - const [techId, setTechId] = useState(''); - const [customerName, setCustomerName] = useState(''); - const [status, setStatus] = useState(''); - const [jobType, setJobType] = useState(''); - const [routeCriteria, setRouteCriteria] = useState(''); - - /* ── Results state ────────────────────────────────────── */ - const [results, setResults] = useState(null); // null = not yet searched - const [searching, setSearching] = useState(false); - const [resultCount, setResultCount] = useState(0); - const gridRef = useRef(null); - - /* ── Search ───────────────────────────────────────────── */ - const handleSearch = useCallback(async () => { - setSearching(true); - try { - const params = {}; - if (dateFrom) params.date_from = dateFrom; - if (dateTo) params.date_to = dateTo; - if (jobId.trim()) params.job_id = Number(jobId); - if (techId.trim()) params.tech_id = Number(techId); - if (customerName.trim()) params.customer_name = customerName.trim(); - if (status) params.status = status; - if (jobType) params.job_type = jobType; - if (routeCriteria.trim()) params.route_criteria = routeCriteria.trim(); - - const res = await api.searchJobs(params); - setResults(res.data); - setResultCount(res.data.length); - } catch (e) { - console.error('Job search failed:', e); - setResults([]); - setResultCount(0); - } finally { - setSearching(false); - } - }, [dateFrom, dateTo, jobId, techId, customerName, status, jobType, routeCriteria]); - - /* ── Clear ────────────────────────────────────────────── */ - const handleClear = useCallback(() => { - setDateFrom(fmtDate(viewDate)); - setDateTo(fmtDate(viewDate)); - setJobId(''); - setTechId(''); - setCustomerName(''); - setStatus(''); - setJobType(''); - setRouteCriteria(''); - setResults(null); - setResultCount(0); - }, [viewDate]); - - /* ── Double-click for detail ──────────────────────────── */ - const handleRowDoubleClicked = useCallback((p) => { - if (p.data && onJobDetail) onJobDetail(p.data); - }, [onJobDetail]); - - /* ── Drag initiation from search results ─────────────── */ - const handleMouseDown = useCallback((e) => { - if (e.button !== 0) return; - if (e.target.closest('.ag-header')) return; - const rowEl = e.target.closest('.ag-row'); - if (!rowEl) return; - - const startX = e.clientX; - const startY = e.clientY; - let fired = false; - - const onMove = (ev) => { - if (fired) return; - if (Math.abs(ev.clientX - startX) + Math.abs(ev.clientY - startY) > 8) { - fired = true; - const idx = Number(rowEl.getAttribute('row-index')); - const node = gridRef.current?.api?.getDisplayedRowAtIndex(idx); - if (node?.data && onDragStart) { - document.body.style.userSelect = 'none'; - document.body.style.cursor = 'grabbing'; - onDragStart(node.data); - } - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); - } - }; - const onUp = () => { - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); - }; - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); - }, [onDragStart]); - - /* ── Column definitions (matches main job grid) ──────── */ - const columnDefs = useMemo(() => [ - { - headerName: 'Job ID', width: 70, - valueGetter: (p) => p.data?.job_number || String(p.data?.id), - cellStyle: { fontFamily: 'var(--font-mono)', fontSize: 'var(--font-size-xs)' }, - }, - { - field: 'job_type', headerName: 'Type', width: 90, - valueFormatter: (p) => p.value?.replace(/_/g, ' ') || '', - cellStyle: { textTransform: 'capitalize', fontSize: 'var(--font-size-xs)' }, - }, - { - field: 'status', headerName: 'Status', width: 90, - cellStyle: (p) => ({ - fontSize: 'var(--font-size-xs)', - fontWeight: 500, - color: p.value === 'pending' ? 'var(--color-warning)' - : p.value === 'completed' ? 'var(--color-success)' - : p.value === 'cancelled' ? 'var(--color-danger)' - : p.value === 'in_progress' ? 'var(--color-purple)' - : 'var(--text-primary)', - }), - valueFormatter: (p) => p.value?.replace(/_/g, ' ') || '', - }, - { - field: 'assigned_tech_name', headerName: 'Tech', width: 100, - valueFormatter: (p) => p.value || '—', - cellStyle: { fontSize: 'var(--font-size-xs)', color: 'var(--text-secondary)' }, - }, - { - field: 'priority', headerName: 'Pri', width: 45, - cellStyle: (p) => ({ - fontFamily: 'var(--font-mono)', fontSize: 'var(--font-size-xs)', - color: p.value === 1 ? 'var(--color-danger)' : p.value === 2 ? 'var(--color-warning)' : 'var(--text-muted)', - }), - }, - { field: 'customer_name', headerName: 'Customer', minWidth: 120, flex: 1 }, - { - field: 'route_criteria', headerName: 'RteC', width: 85, - cellStyle: { fontFamily: 'var(--font-mono)', fontSize: 'var(--font-size-xs)', color: 'var(--text-secondary)' }, - valueFormatter: (p) => p.value || '—', - }, - { - field: 'service_address', headerName: 'Address', minWidth: 140, flex: 1, - cellStyle: { fontSize: 'var(--font-size-xs)', color: 'var(--text-secondary)' }, - }, - { - field: 'service_city', headerName: 'City', width: 85, - cellStyle: { fontSize: 'var(--font-size-xs)', color: 'var(--text-secondary)' }, - }, - { - field: 'service_zip', headerName: 'Zip', width: 60, - cellStyle: { fontFamily: 'var(--font-mono)', fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)' }, - }, - { - headerName: 'Time Slot', width: 95, - valueGetter: (p) => p.data?.time_slot_start ? `${p.data.time_slot_start}–${p.data.time_slot_end}` : '', - cellStyle: { fontFamily: 'var(--font-mono)', fontSize: 'var(--font-size-xs)' }, - }, - { - field: 'estimated_duration', headerName: 'Dur', width: 55, - cellStyle: { fontFamily: 'var(--font-mono)', fontSize: 'var(--font-size-xs)', color: 'var(--text-secondary)' }, - valueFormatter: (p) => { - if (!p.value) return ''; - const hrs = Math.floor(p.value / 60); - const mins = p.value % 60; - return hrs > 0 ? `${hrs}h${mins > 0 ? ` ${mins}m` : ''}` : `${mins}m`; - }, - }, - { - field: 'required_skills', headerName: 'Skills', minWidth: 120, flex: 1, - valueFormatter: (p) => p.value?.join(', ') ?? '', - cellStyle: { fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)' }, - }, - ], []); - - const defaultColDef = useMemo(() => ({ sortable: true, resizable: true }), []); - - /* ── Right-click on search results ────────────────────── */ - const onCellContextMenu = useCallback((p) => { - if (p.event && p.data && onContextMenu) onContextMenu(p.event, p.data); - }, [onContextMenu]); - - /* ── Enter key to search ──────────────────────────────── */ - const handleKeyDown = useCallback((e) => { - if (e.key === 'Enter') handleSearch(); - }, [handleSearch]); - - return ( - - {/* Search criteria form */} -
-
- - setDateFrom(e.target.value)} /> - - setDateTo(e.target.value)} /> - - setJobId(e.target.value)} placeholder="#" /> -
-
- - setCustomerName(e.target.value)} placeholder="Name..." /> - - setTechId(e.target.value)} placeholder="#" /> - - setRouteCriteria(e.target.value)} placeholder="MN-..." /> -
-
- - - - -
- - -
-
- - {/* Results */} - {results === null ? ( -
- Enter search criteria and click Search -
- ) : ( - <> -
- {resultCount} job{resultCount !== 1 ? 's' : ''} found - Double-click for details · Drag to assign -
-
- String(p.data.id)} - animateRows={false} - headerHeight={26} - rowHeight={24} - suppressCellFocus={true} - onRowDoubleClicked={handleRowDoubleClicked} - onCellContextMenu={onCellContextMenu} - preventDefaultOnContextMenu={true} - /> -
- - )} - - ); -} diff --git a/frontend/src/components/Map.jsx b/frontend/src/components/Map.jsx deleted file mode 100644 index e0746f1..0000000 --- a/frontend/src/components/Map.jsx +++ /dev/null @@ -1,116 +0,0 @@ -import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet'; -import { Icon } from 'leaflet'; -import 'leaflet/dist/leaflet.css'; -import { useEffect } from 'react'; - -// Fix for default marker icons in React-Leaflet -import icon from 'leaflet/dist/images/marker-icon.png'; -import iconShadow from 'leaflet/dist/images/marker-shadow.png'; -let DefaultIcon = Icon.Default; -DefaultIcon.prototype.options.iconUrl = icon; -DefaultIcon.prototype.options.shadowUrl = iconShadow; - -const techIcon = new Icon({ - iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-blue.png', - shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png', - iconSize: [25, 41], - iconAnchor: [12, 41], - popupAnchor: [1, -34], - shadowSize: [41, 41] -}); - -const jobIcon = new Icon({ - iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-red.png', - shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png', - iconSize: [25, 41], - iconAnchor: [12, 41], - popupAnchor: [1, -34], - shadowSize: [41, 41] -}); - -function AutoZoom({ technicians, jobs }) { - const map = useMap(); - - useEffect(() => { - if (technicians.length > 0 || jobs.length > 0) { - const bounds = []; - - technicians.forEach(tech => { - if (tech.current_latitude && tech.current_longitude) { - bounds.push([tech.current_latitude, tech.current_longitude]); - } - }); - - jobs.forEach(job => { - if (job.latitude && job.longitude) { - bounds.push([job.latitude, job.longitude]); - } - }); - - if (bounds.length > 0) { - map.fitBounds(bounds, { padding: [50, 50] }); - } - } - }, [technicians, jobs, map]); - - return null; -} - -export default function Map({ technicians = [], jobs = [] }) { - const center = [40.7128, -74.0060]; // NYC default - - return ( - - - - - - {/* Technician Markers */} - {technicians.map(tech => ( - tech.current_latitude && tech.current_longitude && ( - - -
-

{tech.name}

-

Status: {tech.status}

-

Skills: {tech.skills.join(', ')}

-
-
-
- ) - ))} - - {/* Job Markers */} - {jobs.map(job => ( - job.latitude && job.longitude && ( - - -
-

{job.customer_name}

-

{job.service_address}

-

Type: {job.job_type}

-

Status: {job.status}

-

Priority: {job.priority}

-
-
-
- ) - ))} -
- ); -} diff --git a/frontend/src/components/MapWindow.jsx b/frontend/src/components/MapWindow.jsx deleted file mode 100644 index 8628998..0000000 --- a/frontend/src/components/MapWindow.jsx +++ /dev/null @@ -1,327 +0,0 @@ -import { useState, useRef, useCallback, useEffect } from 'react'; -import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet'; -import { Icon } from 'leaflet'; -import 'leaflet/dist/leaflet.css'; - -/* Fix default marker icons */ -import iconUrl from 'leaflet/dist/images/marker-icon.png'; -import shadowUrl from 'leaflet/dist/images/marker-shadow.png'; -Icon.Default.prototype.options.iconUrl = iconUrl; -Icon.Default.prototype.options.shadowUrl = shadowUrl; - -const techIcon = new Icon({ - iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-blue.png', - shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png', - iconSize: [20, 33], - iconAnchor: [10, 33], - popupAnchor: [1, -28], - shadowSize: [33, 33], -}); - -const jobIcon = new Icon({ - iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-red.png', - shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png', - iconSize: [20, 33], - iconAnchor: [10, 33], - popupAnchor: [1, -28], - shadowSize: [33, 33], -}); - -const CHANNEL_NAME = 'fieldopt-map-sync'; - -function AutoZoom({ technicians, jobs }) { - const map = useMap(); - useEffect(() => { - const bounds = []; - technicians.forEach((t) => { - if (t.current_latitude && t.current_longitude) bounds.push([t.current_latitude, t.current_longitude]); - }); - jobs.forEach((j) => { - if (j.latitude && j.longitude) bounds.push([j.latitude, j.longitude]); - }); - if (bounds.length > 0) map.fitBounds(bounds, { padding: [30, 30] }); - }, [technicians, jobs, map]); - return null; -} - -/** - * MapWindow — opens as a real OS popup window via Blob URL. - * Falls back to in-page floating window if popup is blocked. - * Syncs tech/job data via BroadcastChannel. - */ -export default function MapWindow({ technicians = [], jobs = [], onClose, usePopup = true }) { - const [popupFailed, setPopupFailed] = useState(false); - const popupRef = useRef(null); - const channelRef = useRef(null); - const pollRef = useRef(null); - const blobUrlRef = useRef(null); - - /* ── Popup mode — Blob URL approach ───────────────────── */ - useEffect(() => { - if (!usePopup) { setPopupFailed(true); return; } - - // Build a self-contained HTML string with inline Leaflet init - const html = buildMapHTML(technicians, jobs); - const blob = new Blob([html], { type: 'text/html' }); - const url = URL.createObjectURL(blob); - blobUrlRef.current = url; - - const popup = window.open( - url, - 'fieldopt-map', - 'width=720,height=520,left=100,top=100,toolbar=no,menubar=no,location=no,status=no' - ); - - if (!popup || popup.closed) { - URL.revokeObjectURL(url); - setPopupFailed(true); - return; - } - - popupRef.current = popup; - - // BroadcastChannel for live data sync - const channel = new BroadcastChannel(CHANNEL_NAME); - channelRef.current = channel; - - // Poll to detect popup close - pollRef.current = setInterval(() => { - if (popup.closed) { - clearInterval(pollRef.current); - onClose?.(); - } - }, 500); - - return () => { - clearInterval(pollRef.current); - channel.close(); - if (blobUrlRef.current) URL.revokeObjectURL(blobUrlRef.current); - if (popup && !popup.closed) popup.close(); - }; - }, []); // eslint-disable-line - - /* ── Sync data to popup via BroadcastChannel ─────────── */ - useEffect(() => { - if (popupFailed || !channelRef.current) return; - channelRef.current.postMessage({ - type: 'map-update', - technicians, - jobs, - }); - }, [technicians, jobs, popupFailed]); - - /* ── If popup succeeded, render nothing in main window ── */ - if (!popupFailed) return null; - - /* ── Fallback: in-page floating window ────────────────── */ - return ; -} - - -/* ── Build self-contained HTML for the popup ─────────── */ -function buildMapHTML(technicians, jobs) { - const techs = technicians - .filter((t) => t.current_latitude && t.current_longitude) - .map((t) => ({ - lat: t.current_latitude, lng: t.current_longitude, - name: t.name, status: t.status, - routes: (t.assigned_routes || []).join(', ') || '\u2014', - skills: (t.skills || []).join(', ') || '\u2014', - })); - - const jobsArr = jobs - .filter((j) => j.latitude && j.longitude) - .map((j) => ({ - lat: j.latitude, lng: j.longitude, - name: j.customer_name, address: j.service_address, - jobType: j.job_type, status: j.status, - route: j.route_criteria || '\u2014', priority: j.priority, - })); - - return ` - - - -FieldOpt \u2014 Map - - - - - - -
- - - -`; -} - - -/* ── Fallback in-page floating map ────────────────────── */ -function InPageMap({ technicians, jobs, onClose }) { - const [pos, setPos] = useState({ x: 80, y: 80 }); - const [size, setSize] = useState({ w: 620, h: 460 }); - const dragRef = useRef(null); - const offsetRef = useRef({ x: 0, y: 0 }); - const mapContainerRef = useRef(null); - - const handleTitleMouseDown = useCallback((e) => { - e.preventDefault(); - offsetRef.current = { x: e.clientX - pos.x, y: e.clientY - pos.y }; - dragRef.current = true; - const onMove = (ev) => { - if (!dragRef.current) return; - setPos({ - x: Math.max(0, ev.clientX - offsetRef.current.x), - y: Math.max(0, ev.clientY - offsetRef.current.y), - }); - }; - const onUp = () => { - dragRef.current = false; - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - }; - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); - document.body.style.cursor = 'move'; - document.body.style.userSelect = 'none'; - }, [pos]); - - const handleResizeMouseDown = useCallback((e) => { - e.preventDefault(); - e.stopPropagation(); - const startX = e.clientX; - const startY = e.clientY; - const startW = size.w; - const startH = size.h; - const onMove = (ev) => { - setSize({ - w: Math.max(320, startW + (ev.clientX - startX)), - h: Math.max(240, startH + (ev.clientY - startY)), - }); - }; - const onUp = () => { - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - if (mapContainerRef.current) { - setTimeout(() => mapContainerRef.current?.invalidateSize?.(), 50); - } - }; - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); - document.body.style.cursor = 'nwse-resize'; - document.body.style.userSelect = 'none'; - }, [size]); - - const center = [40.7128, -74.006]; - - return ( -
-
- - Map — {technicians.length} techs · {jobs.length} jobs - - -
-
- - - - {technicians.map((tech) => - tech.current_latitude && tech.current_longitude ? ( - - - {tech.name}
- Status: {tech.status}
- Routes: {tech.assigned_routes?.join(', ') || '—'}
- Skills: {tech.skills?.join(', ') || '—'} -
-
- ) : null - )} - {jobs.map((job) => - job.latitude && job.longitude ? ( - - - {job.customer_name}
- {job.service_address}
- {job.job_type} — {job.status}
- Route: {job.route_criteria || '—'} · Pri: {job.priority} -
-
- ) : null - )} -
-
-
-
- ); -} diff --git a/frontend/src/components/PersonnelWindow.jsx b/frontend/src/components/PersonnelWindow.jsx deleted file mode 100644 index 04506db..0000000 --- a/frontend/src/components/PersonnelWindow.jsx +++ /dev/null @@ -1,122 +0,0 @@ -import { useState, useMemo, useCallback, useRef, useEffect } from 'react'; -import FloatingWindow from './FloatingWindow'; - -/** - * PersonnelWindow — WFX-style staff list. - * Shows ALL techs (including off-shift), searchable by name/ID. - * - * Props: - * technicians — full tech array from API - * onLocateTech — callback(techId) to scroll main grid to this tech and select - * onContextMenu — callback(event, tech) to show tech context menu - * onTechDetail — callback(tech) to open tech info panel - * onClose — close handler - */ -export default function PersonnelWindow({ technicians, onLocateTech, onContextMenu, onTechDetail, onClose }) { - const [search, setSearch] = useState(''); - const inputRef = useRef(null); - - // Focus input after a delay so the P keystroke doesn't land in the input - useEffect(() => { - const t = setTimeout(() => inputRef.current?.focus(), 80); - return () => clearTimeout(t); - }, []); - - const filtered = useMemo(() => { - if (!search.trim()) return technicians; - const q = search.toLowerCase().trim(); - return technicians.filter((t) => - t.name.toLowerCase().includes(q) || - (t.employee_id && t.employee_id.toLowerCase().includes(q)) || - String(t.id).includes(q) - ); - }, [technicians, search]); - - const handleLocate = useCallback((techId) => { - onLocateTech?.(techId); - }, [onLocateTech]); - - const handleRightClick = useCallback((e, tech) => { - e.preventDefault(); - onContextMenu?.(e, tech); - }, [onContextMenu]); - - const handleDoubleClick = useCallback((tech) => { - onTechDetail?.(tech); - }, [onTechDetail]); - - return ( - - {/* Search bar */} -
- setSearch(e.target.value)} - ref={inputRef} - /> - {search && ( - - )} - {filtered.length} / {technicians.length} -
- - {/* Column headers */} -
- ID - Name - Status - Routes - Skills - -
- - {/* Tech list */} -
- {filtered.map((tech) => ( -
handleRightClick(e, tech)} - onDoubleClick={() => handleDoubleClick(tech)} - > - {tech.employee_id || tech.id} - {tech.name} - - - {tech.status.replace(/_/g, ' ')} - - - - {tech.assigned_routes?.join(', ') || '—'} - - - {tech.skills?.join(', ') || '—'} - - -
- ))} - {filtered.length === 0 && ( -
- {search ? `No techs matching "${search}"` : 'No technicians loaded'} -
- )} -
-
- ); -} diff --git a/frontend/src/components/SimBar.jsx b/frontend/src/components/SimBar.jsx deleted file mode 100644 index df130ff..0000000 --- a/frontend/src/components/SimBar.jsx +++ /dev/null @@ -1,166 +0,0 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; -import { api } from '../api/client'; - -const SPEEDS = [10, 50, 100, 200, 500]; - -const BASE_HOUR = 8; // virtual day starts 08:00 - -function fmtClock(elapsedMinutesFloat) { - if (elapsedMinutesFloat == null) return '--:--:--'; - const totalSec = Math.max(0, Math.floor((BASE_HOUR * 60 + elapsedMinutesFloat) * 60)); - const h = Math.floor(totalSec / 3600) % 24; - const m = Math.floor(totalSec / 60) % 60; - const s = totalSec % 60; - const ampm = h < 12 ? 'AM' : 'PM'; - const h12 = h % 12 || 12; - return `${h12}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')} ${ampm}`; -} - -function elapsedFromIso(isoStr) { - if (!isoStr) return null; - try { - const d = new Date(isoStr); - return (d.getUTCHours() - BASE_HOUR) * 60 + d.getUTCMinutes() + d.getUTCSeconds() / 60; - } catch { return null; } -} - -export default function SimBar({ elapsedMinutes, onToast, onRunningChange }) { - const [status, setStatus] = useState(null); // null = loading - const [busy, setBusy] = useState(false); - const [tick, setTick] = useState(0); // re-render trigger for animation - - // Anchor: last known authoritative elapsed + the real-time moment we received it. - // Display interpolates forward from the anchor using current speed. - const anchorRef = useRef(null); // { elapsed: number, receivedAt: number } - - const fetchStatus = useCallback(async () => { - try { - const r = await api.simStatus(); - setStatus(r.data); - // Re-anchor from polled status when we don't have a live WS event recently. - const fromStatus = elapsedFromIso(r.data?.virtual_time); - if (fromStatus != null && r.data?.loop_running) { - anchorRef.current = { elapsed: fromStatus, receivedAt: Date.now() }; - } - } catch { - setStatus(null); - } - }, []); - - useEffect(() => { - fetchStatus(); - const i = setInterval(fetchStatus, 5000); - return () => clearInterval(i); - }, [fetchStatus]); - - // New WS clock_tick → re-anchor. - useEffect(() => { - if (elapsedMinutes != null) { - anchorRef.current = { elapsed: elapsedMinutes, receivedAt: Date.now() }; - } - }, [elapsedMinutes]); - - // Local animation: while running and not paused, re-render every 100ms so - // the clock visibly rolls. Backend is source of truth; we just interpolate - // forward from the last anchor using current speed. - const running = !!status?.loop_running && !status?.is_paused; - useEffect(() => { - if (!running) return; - const id = setInterval(() => setTick((t) => t + 1), 100); - return () => clearInterval(id); - }, [running]); - - // Bubble running state up so Dashboard can lock the UI during demo playback. - useEffect(() => { - onRunningChange?.(running); - }, [running, onRunningChange]); - - // Compute display elapsed (in fractional minutes). - let displayElapsed = null; - if (anchorRef.current) { - if (running) { - const realSecSinceAnchor = (Date.now() - anchorRef.current.receivedAt) / 1000; - const speed = status?.speed || 1; - displayElapsed = anchorRef.current.elapsed + (realSecSinceAnchor * speed) / 60; - } else { - // Paused or stopped — freeze at last anchor. - displayElapsed = anchorRef.current.elapsed; - } - } - - if (!status?.is_demo) return null; - - const paused = status.loop_running && status.is_paused; - const stopped = !status.loop_running; - - const act = async (fn, label) => { - if (busy) return; - setBusy(true); - try { - await fn(); - await fetchStatus(); - } catch (e) { - onToast?.(`${label} failed: ${e?.response?.data?.detail || e.message}`, 'error'); - } finally { - setBusy(false); - } - }; - - const handleStart = () => act(async () => { - anchorRef.current = null; // fresh demo, drop stale anchor - await api.simStart(status.speed || 500); - }, 'Start'); - const handlePause = () => act(api.simPause, 'Pause'); - const handleResume = () => act(api.simResume, 'Resume'); - const handleStop = () => act(api.simStop, 'Stop'); - const handleSpeed = (e) => act(() => api.simSetSpeed(Number(e.target.value)), 'Speed'); - - return ( -
- DEMO - - - {running ? '● LIVE' : paused ? '⏸ PAUSED' : '◼ STOPPED'} - - - {fmtClock(displayElapsed)} - -
- {stopped && ( - - )} - {running && ( - - )} - {paused && ( - - )} - {!stopped && ( - - )} -
- -
- Speed - -
-
- ); -} diff --git a/frontend/src/components/TechGrid.jsx b/frontend/src/components/TechGrid.jsx deleted file mode 100644 index a2113ad..0000000 --- a/frontend/src/components/TechGrid.jsx +++ /dev/null @@ -1,163 +0,0 @@ -import { useMemo, useRef, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react'; -import { AgGridReact } from 'ag-grid-react'; -import { AllCommunityModule, ModuleRegistry } from 'ag-grid-community'; - -ModuleRegistry.registerModules([AllCommunityModule]); - -function StatusCellRenderer({ value }) { - if (!value) return null; - return {value.replace(/_/g, ' ')}; -} - -function SkillsCellRenderer({ value }) { - if (!value || value.length === 0) return ; - return ( - - {value.map((skill) => {skill})} - - ); -} - -function ShiftCellRenderer({ data }) { - if (!data?.shift_start || !data?.shift_end) return ; - return ( - - {data.shift_start}–{data.shift_end} - - ); -} - -function JobCountCellRenderer({ data }) { - if (!data) return null; - const assigned = data.assigned_jobs ?? 0; - const completed = data.completed_jobs ?? 0; - return ( - - {assigned}:{completed} - - ); -} - -const TechGrid = forwardRef(function TechGrid({ technicians, selectedIds = [], onRowClicked, onContextMenu, isDragTarget }, ref) { - const gridRef = useRef(null); - const containerRef = useRef(null); - - // Expose a point-to-tech-id lookup so Dashboard can resolve drop targets without - // stamping data-tech-id on every scroll frame. - useImperativeHandle(ref, () => ({ - getTechIdAtPoint: (x, y) => { - const el = document.elementFromPoint(x, y); - const row = el?.closest('.ag-row'); - if (!row) return null; - const idx = Number(row.getAttribute('row-index')); - const node = gridRef.current?.api?.getDisplayedRowAtIndex(idx); - return node?.data?.id ?? null; - }, - selectAll: () => { - gridRef.current?.api?.selectAll(); - }, - }), []); - - const columnDefs = useMemo(() => [ - { - headerName: 'Tech ID', width: 85, pinned: 'left', sort: 'asc', - valueGetter: (p) => p.data?.employee_id || String(p.data?.id), - comparator: (a, b) => { - // Numeric prefix sort: "MD001" → split letters/digits; pure numbers → numeric - const na = parseInt(a, 10), nb = parseInt(b, 10); - if (!isNaN(na) && !isNaN(nb)) return na - nb; - return a.localeCompare(b); - }, - cellStyle: { fontFamily: 'var(--font-mono)', fontSize: 'var(--font-size-xs)' }, - }, - { field: 'name', headerName: 'Name', minWidth: 150, flex: 1, pinned: 'left' }, - { field: 'status', headerName: 'Status', width: 110, cellRenderer: StatusCellRenderer }, - { - headerName: 'Shift', width: 110, cellRenderer: ShiftCellRenderer, - valueGetter: (p) => p.data?.shift_start ? `${p.data.shift_start}-${p.data.shift_end}` : '', - }, - { - headerName: 'Jobs A:C', width: 85, cellRenderer: JobCountCellRenderer, - valueGetter: (p) => (p.data?.assigned_jobs ?? 0) + (p.data?.completed_jobs ?? 0), - }, - { - field: 'assigned_routes', headerName: 'Routes', width: 120, - cellStyle: { fontFamily: 'var(--font-mono)', fontSize: '10px', color: 'var(--text-muted)' }, - valueFormatter: (p) => p.value?.join(', ') ?? '—', - }, - { - field: 'max_jobs_per_day', headerName: 'Max', width: 55, - cellStyle: { fontFamily: 'var(--font-mono)', fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)' }, - }, - { - field: 'skills', headerName: 'Skills', minWidth: 180, flex: 1, - cellRenderer: SkillsCellRenderer, - valueFormatter: (p) => p.value?.join(', ') ?? '', - }, - { - field: 'phone', headerName: 'Phone', width: 120, - cellStyle: { fontSize: 'var(--font-size-xs)', color: 'var(--text-secondary)' }, - }, - ], []); - - const defaultColDef = useMemo(() => ({ sortable: true, resizable: true, suppressMovable: false }), []); - - const rowSelection = useMemo(() => ({ - mode: 'multiRow', - checkboxes: false, - headerCheckbox: false, - enableClickSelection: false, - }), []); - - // Sync AG Grid selection — O(1) per selected ID via getRowNode hash lookup - useEffect(() => { - const gridApi = gridRef.current?.api; - if (!gridApi) return; - gridApi.deselectAll(); - selectedIds.forEach((id) => { - gridApi.getRowNode(String(id))?.setSelected(true, false, 'api'); - }); - }, [selectedIds, technicians]); - - const onCellContextMenu = useCallback((p) => { - if (p.event && p.data && onContextMenu) onContextMenu(p.event, p.data); - }, [onContextMenu]); - - const handleRowClicked = useCallback((p) => { - if (p.data && onRowClicked) { - // Get the current displayed order from AG Grid (respects sorting) - const displayedIds = []; - gridRef.current?.api?.forEachNodeAfterFilterAndSort((node) => { - if (node.data) displayedIds.push(node.data.id); - }); - onRowClicked(p.data.id, p.event, displayedIds); - } - }, [onRowClicked]); - - return ( -
- String(p.data.id)} - rowSelection={rowSelection} - selectionColumnDef={null} - animateRows={false} - headerHeight={28} - rowHeight={26} - suppressCellFocus={true} - onCellContextMenu={onCellContextMenu} - onRowClicked={handleRowClicked} - preventDefaultOnContextMenu={true} - /> -
- ); -}); - -export default TechGrid; diff --git a/frontend/src/components/TechTimeline.jsx b/frontend/src/components/TechTimeline.jsx deleted file mode 100644 index 30c710c..0000000 --- a/frontend/src/components/TechTimeline.jsx +++ /dev/null @@ -1,166 +0,0 @@ -import { useMemo } from 'react'; - -const HOURS = Array.from({ length: 13 }, (_, i) => i + 6); // 6 AM to 6 PM -const HOUR_WIDTH = 80; -const JOB_HEIGHT = 24; -const JOB_GAP = 2; -const ROW_PADDING = 6; - -function parseTime(timeStr) { - if (!timeStr) return null; - const [h, m] = timeStr.split(':').map(Number); - return h + m / 60; -} - -function etaToHour(iso) { - if (!iso) return null; - const d = new Date(iso); - if (isNaN(d.getTime())) return null; - // Demo virtual day is UTC-based (08:00 start). Use UTC getters so the timeline - // matches the seeded virtual clock regardless of viewer timezone. - return d.getUTCHours() + d.getUTCMinutes() / 60 + d.getUTCSeconds() / 3600; -} - -function fmtSlot(start, end) { - if (!start || !end) return ''; - return `${start}–${end}`; -} - -const STATUS_COLORS = { - pending: 'var(--color-warning)', - assigned: 'var(--color-info)', - in_progress: 'var(--color-purple)', - completed: 'var(--color-success)', - cancelled: 'var(--color-danger)', - on_hold: 'var(--color-on-hold)', -}; - -// Assign vertical lanes to overlapping jobs -function assignLanes(jobs) { - const sorted = [...jobs].sort((a, b) => a.startHour - b.startHour); - const lanes = []; // each lane is the endHour of the last job in that lane - - return sorted.map((job) => { - const endHour = job.startHour + job.durationHours; - // Find first lane where this job doesn't overlap - let lane = lanes.findIndex((laneEnd) => job.startHour >= laneEnd); - if (lane === -1) { - lane = lanes.length; - lanes.push(endHour); - } else { - lanes[lane] = endHour; - } - return { ...job, lane, totalLanes: 0 }; // totalLanes set after - }).map((job) => ({ ...job, totalLanes: lanes.length })); -} - -export default function TechTimeline({ technicians, jobs }) { - const timelineData = useMemo(() => { - return technicians.map((tech) => { - const techJobs = jobs - .filter((j) => j.assigned_tech_id === tech.id) - .map((job) => { - // Prefer actual ETA + sampled duration so the block reflects when - // the tech is really there. Fall back to the customer time slot - // for jobs that haven't been picked up by the dispatcher yet. - const etaHour = etaToHour(job.estimated_arrival); - const startHour = etaHour ?? parseTime(job.time_slot_start) ?? 8; - const minutes = job.actual_duration_minutes ?? job.estimated_duration ?? 60; - const durationHours = Math.max(minutes / 60, 0.25); // floor 15min so block is visible - return { - ...job, - startHour, - durationHours, - }; - }); - - const lanedJobs = assignLanes(techJobs); - const maxLanes = lanedJobs.length > 0 ? Math.max(...lanedJobs.map((j) => j.totalLanes)) : 1; - const rowHeight = ROW_PADDING * 2 + maxLanes * (JOB_HEIGHT + JOB_GAP); - - return { - tech, - jobs: lanedJobs, - maxLanes, - rowHeight: Math.max(rowHeight, 36), - shiftStart: parseTime(tech.shift_start) ?? 8, - shiftEnd: parseTime(tech.shift_end) ?? 17, - }; - }); - }, [technicians, jobs]); - - const totalWidth = HOURS.length * HOUR_WIDTH; - - if (technicians.length === 0) { - return ( -
- Select a technician to view their timeline -
- ); - } - - return ( -
- {/* Hour headers */} -
-
Tech
- {HOURS.map((hour) => ( -
- {hour === 12 ? '12 PM' : hour > 12 ? `${hour - 12} PM` : `${hour} AM`} -
- ))} -
- - {/* Tech rows */} -
- {timelineData.map(({ tech, jobs: techJobs, rowHeight, shiftStart, shiftEnd }) => ( -
-
- {tech.name} -
- -
- {HOURS.map((hour) => ( -
- ))} - - {/* Shift background */} -
- - {/* Job blocks — stacked by lane */} - {techJobs.map((job) => ( -
- - {job.customer_name?.split(' ')[0]} - - - {fmtSlot(job.time_slot_start, job.time_slot_end)} - -
- ))} -
-
- ))} -
-
- ); -} diff --git a/frontend/src/components/Toast.jsx b/frontend/src/components/Toast.jsx deleted file mode 100644 index f8c4731..0000000 --- a/frontend/src/components/Toast.jsx +++ /dev/null @@ -1,24 +0,0 @@ -import { useEffect, useState } from 'react'; - -export default function Toast({ message, type = 'info' }) { - const [visible, setVisible] = useState(false); - - useEffect(() => { - // Trigger enter animation - requestAnimationFrame(() => setVisible(true)); - const timer = setTimeout(() => setVisible(false), 2600); - return () => clearTimeout(timer); - }, []); - - return ( -
- - {type === 'success' && '✓'} - {type === 'error' && '✕'} - {type === 'warning' && '!'} - {type === 'info' && 'i'} - - {message} -
- ); -} diff --git a/frontend/src/hooks/useSimEvents.js b/frontend/src/hooks/useSimEvents.js deleted file mode 100644 index da91a51..0000000 --- a/frontend/src/hooks/useSimEvents.js +++ /dev/null @@ -1,47 +0,0 @@ -import { useEffect, useRef } from 'react'; - -/** - * Connects to the simulation WebSocket and calls onEvent for each DispatchEvent. - * Reconnects automatically with exponential backoff (max 30s). - * No-ops when onEvent is null/undefined. - */ -export function useSimEvents(onEvent) { - const onEventRef = useRef(onEvent); - useEffect(() => { onEventRef.current = onEvent; }, [onEvent]); - - useEffect(() => { - let ws = null; - let attempt = 0; - let stopped = false; - - const connect = () => { - if (stopped) return; - const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; - ws = new WebSocket(`${proto}//${location.host}/api/v1/simulation/ws`); - - ws.onopen = () => { attempt = 0; }; - - ws.onmessage = (e) => { - try { - const events = JSON.parse(e.data); - if (Array.isArray(events) && onEventRef.current) { - events.forEach(onEventRef.current); - } - } catch { /* ignore malformed */ } - }; - - ws.onclose = () => { - if (!stopped) { - const delay = Math.min(1000 * 2 ** attempt++, 30000); - setTimeout(connect, delay); - } - }; - }; - - connect(); - return () => { - stopped = true; - ws?.close(); - }; - }, []); // stable — reconnect logic handles server restarts -} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx deleted file mode 100644 index cd80839..0000000 --- a/frontend/src/main.jsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import App from './App.jsx'; -import './styles/index.css'; - -ReactDOM.createRoot(document.getElementById('root')).render( - - - , -); diff --git a/frontend/vite.config.js b/frontend/vite.config.js deleted file mode 100644 index e7a9ef6..0000000 --- a/frontend/vite.config.js +++ /dev/null @@ -1,21 +0,0 @@ -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; -import { readFileSync } from 'fs'; - -const pkg = JSON.parse(readFileSync('./package.json', 'utf8')); - -export default defineConfig({ - define: { - __APP_VERSION__: JSON.stringify(pkg.version), - }, - plugins: [react()], - server: { - proxy: { - '/api': { - target: 'http://localhost:8000', - changeOrigin: true, - ws: true, - }, - }, - }, -}); diff --git a/requirements.txt b/requirements.txt index 3eaea38..555ac3c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,3 +13,6 @@ python-dotenv==1.0.0 alembic==1.15.2 numpy>=1.26.0 scipy>=1.11.0 + +# Server-rendered UI (Path B spike) +jinja2==3.1.4