+
+ Filters apply live to the Jobs grid. Multi-select with checkboxes.
+
+
+
+
+ Status
+
+ All
+ None
+
+
+
+ {% for s in statuses %}
+ {{ s }}
+ {% endfor %}
+
+
+
+
+
+ Job type
+
+ All
+ None
+
+
+
+ {% for t in job_types %}
+ {{ t }}
+ {% endfor %}
+
+
+
+
+
+ Route
+
+ All
+ None
+
+
+
+ {% for r in routes %}
+ {{ r }}
+ {% endfor %}
+
+
+
+
+
+
+ Reset all
+ Clear (show all)
+
+
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 @@
+
+
+
+
+
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 %}
{% 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 @@
+
+
+ Refresh interval
+
+ 1 second
+ 3 seconds
+ 5 seconds
+ 10 seconds
+ Off (manual)
+
+
+
+
+ Display density
+
+ Normal
+ Compact
+ Comfortable
+
+
+
+
+
+
Sim mode
+
{{ sim.mode|upper }}{% if sim.is_demo %} · DEMO build{% endif %}
+
+
+
+ Quick actions
+ Refresh now
+ Clear selections
+
+
+
+
Keyboard shortcuts
+
+
⌘/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 @@
+
+
+
+
+
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 %}
+
+ JOB ETA STATUS CUST
+
+ {% for a in assignments %}
+
+ {{ 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 }}
+
+ {% endfor %}
+
+
+ {% 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 @@
+
+
+
+ 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
+ ''
+ || 'TECH ID NAME STATUS '
+ || 'SHIFT JOBS A:C ROUTES '
+ || ' '
+ || coalesce(
+ string_agg(row_html, '' order by name),
+ '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)), '') || ' '
+ || ' ' 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,
+ ''
+ || '' || 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 || ' '
+ || ' ' 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
+ ''
+ || 'JOB ID TYPE STATUS TECH '
+ || 'PRI CUSTOMER RTEC ADDRESS SLOT '
+ || ' '
+ || coalesce(
+ string_agg(row_html, '' order by ord_slot asc nulls first, ord_id),
+ '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 || ' '
+ || '
'
+ || ''
+ || 'ID NAME STATUS SHIFT SKILLS ROUTES '
+ || ' '
+ || coalesce((select string_agg(row_html, '' order by name) from rows), '')
+ || '
'
+ 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
+ ''
+ || ''
+ -- 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
+ '
'
+ || 'JOB ETA STATUS CUST '
+ || asg.rows || '
'
+ 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
+ ''
+ || ''
+ -- 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 '
+ || 'All '
+ || 'None
'
+ || '
'
+ || (select string_agg(
+ ' ' || v || ' ',
+ '' 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 '
+ || 'All '
+ || 'None
'
+ || '
'
+ || (select string_agg(
+ ' ' || v || ' ',
+ '' 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 '
+ || 'All '
+ || 'None
'
+ || '
'
+ || coalesce((select string_agg(
+ ' ' || api.html_escape(rc) || ' ',
+ '' 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
+ || '
'
+ -- Footer buttons
+ || '
'
+ || 'Reset all '
+ || 'Clear (show all) '
+ || '
'
+ || '
';
+$$;
+
+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 agg.cnt > 0 then
+ '
JOB ID DATE STATUS TYPE '
+ || 'PRI CUSTOMER RTEC ADDRESS '
+ || agg.rows_html || '
'
+ 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$Override CanDo check? Job #$html$
+ || api.html_escape(num) || $html$ ($html$ || api.html_escape(j.customer_name)
+ || $html$) → $html$ || api.html_escape(t.name) || $html$
$html$
+ || ''
+ || case when skill_pass then '✓' else '✗' end || ' ' || api.html_escape(skill_label) || ' '
+ || ''
+ || case when route_pass then '✓' else '✗' end || ' ' || api.html_escape(route_label) || ' '
+ || $html$ Cancel
$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
+ '
▶ Resume '
+ else
+ '
❚❚ Pause '
+ end
+ || '
■ Stop '
+ end
+ || '
Speed '
+ || ''
+ || (select string_agg('' || v || '× ', '' order by ord)
+ from unnest(array[10,100,250,500,1000,2000]) with ordinality o(v, ord))
+ || '
'
+ || '
'
+ 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
+ ''
+ || '
Refresh interval '
+ || ''
+ || '1 second 3 seconds '
+ || '5 seconds 10 seconds '
+ || 'Off (manual)
'
+ || '
Display density '
+ || ''
+ || 'Normal Compact '
+ || 'Comfortable
'
+ || ''
+ || '
Sim mode '
+ || '
' || upper((select mode from api.sim_state limit 1))
+ || case when api.is_demo() then ' · DEMO build' else '' end || '
'
+ || '
Quick actions '
+ || 'Refresh now '
+ || 'Clear selections
'
+ || '
Keyboard shortcuts '
+ || '
'
+ || '
⌘/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
-
-
-
-
- loadData(true)}
- disabled={refreshing}
- className="bg-white/10 hover:bg-white/20 backdrop-blur-sm text-white font-medium py-2 px-4 rounded-lg flex items-center gap-2 transition-all disabled:opacity-50"
- >
-
- Refresh
-
-
-
-
- {autoRouting ? 'Auto-Routing...' : `Auto-Route ${pendingJobs.length} Jobs`}
-
-
-
-
-
-
- {/* 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 ? (
-
- ) : (
- 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 (
- { onChange(d); onClose(); }}
- >
- {d.getDate()}
-
- );
- })}
-
-
- { onChange(new Date()); onClose(); }}>Today
-
-
,
- 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'}
-
onTechAction('set_available', data)}>
- Available
-
-
onTechAction('set_on_break', data)}>
- On Break
-
-
onTechAction('set_off_duty', data)}>
- Off Duty
-
-
- );
- }
-
- /* ── 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) => (
-
onAssignToTech(data.id, tech.id)}>
- {requiredSkills.length > 0 && ✓ }
- {tech.id}
- {tech.name}
-
-
- ))}
- {unqualified.length > 0 && (
- <>
-
-
Missing Skills
- >
- )}
- {unqualified.map((tech) => {
- const missing = requiredSkills.filter((s) => !tech.skills?.includes(s));
- return (
-
onAssignToTech(data.id, tech.id)}
- title={`Missing: ${missing.join(', ')}`}
- >
- ✕
- {tech.id}
- {tech.name}
-
- );
- })}
- >
- );
- })()}
-
- )}
-
- )}
-
- {/* Unassign — single or batch */}
- {hasMultiSelect ? (
-
onJobAction('batch_unassign', data)}>
- Unassign {selectedJobIds.length} Jobs
-
- ) : (
- (isAssigned || isInProgress) && (
-
onJobAction('unassign', data)}>
- Unassign
-
- )
- )}
-
- {/* Single-job actions only when not multi-selected */}
- {!hasMultiSelect && (
- <>
-
- {isAssigned && (
-
onJobAction('start', data)}>Start Job
- )}
- {isInProgress && (
-
onJobAction('complete', data)}>Complete Job
- )}
- {!isCompleted && !isCancelled && (
-
onJobAction('hold', data)}>Place On Hold
- )}
- {!isCompleted && !isCancelled && (
- <>
-
-
onJobAction('cancel', data)}>
- Cancel Job
-
- >
- )}
- >
- )}
-
- );
- }
-
- 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 ;
-
- 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 ══ */}
-
-
- {/* ══ 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" />
- 0 Failed
-
- 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) && (
- <>
-
- { setJobFilter(null); setTechFilter(null); setDisplayFilter(null); }}>
- ✕ Clear{displayFilter ? ' All' : ''}
-
- >
- )}
-
-
- {/* ══ 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.
-
-
- setAutoRouteConfirm(false)}>Cancel
- { setAutoRouteConfirm(false); handleAutoRoute(); }}>
- Route {pending} Jobs
-
-
-
-
- )}
-
- {/* ══ 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}
-
- ))}
-
-
- setOverrideWarning(null)}>Cancel
- Assign Anyway
-
-
-
- )}
-
- {/* ══ Toasts ══ */}
-
- {toasts.map((t) => )}
-
-
- );
-}
-
-/* ── Dashboard Indicator ─────────────────────────────────── */
-function DI({ active, onClick, count, color, label }) {
- return (
-
- {count}
- {label}
-
- );
-}
-
-/* ── 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 */}
-
-
Reset
-
-
Cancel
-
OK
-
-
- );
-}
-
-
-/* ── 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) => (
- onToggle(item)}
- >
-
- {selected.includes(item) ? '✓' : ''}
-
- {fmt(item)}
-
- ))}
-
-
- All
- None
-
-
- );
-}
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 && (
-
- )}
-
- {/* 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 */}
-
-
- {/* 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 && (
- setSearch('')}>✕
- )}
- {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(', ') || '—'}
-
- handleLocate(tech.id)}
- title="Locate in grid"
- >
- ⊕
-
-
- ))}
- {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 && (
-
- ▶ Start Demo
-
- )}
- {running && (
-
- ⏸ Pause
-
- )}
- {paused && (
-
- ▶ Resume
-
- )}
- {!stopped && (
-
- ◼ Stop
-
- )}
-
-
-
- Speed
-
- {SPEEDS.map((s) => (
- {s}×
- ))}
-
-
-
- );
-}
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