umangchaudhry commited on
Commit
0d04b76
·
verified ·
1 Parent(s): e309745

Upload 31 files

Browse files
Dockerfile ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ ENV PYTHONUNBUFFERED=1 \
4
+ PYTHONDONTWRITEBYTECODE=1 \
5
+ PIP_NO_CACHE_DIR=1 \
6
+ PORT=7860
7
+
8
+ # HuggingFace Spaces requires writeable HOME; create a non-root user.
9
+ RUN useradd -m -u 1000 user
10
+ WORKDIR /app
11
+
12
+ # Install dependencies first for better Docker layer caching.
13
+ COPY --chown=user:user requirements.txt /app/requirements.txt
14
+ RUN pip install --upgrade pip && pip install -r /app/requirements.txt
15
+
16
+ # Copy the rest of the application.
17
+ COPY --chown=user:user . /app
18
+
19
+ USER user
20
+ EXPOSE 7860
21
+
22
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,12 +1,64 @@
1
  ---
2
- title: Scheduler
3
- emoji: 😻
4
- colorFrom: yellow
5
- colorTo: green
6
  sdk: docker
 
7
  pinned: false
8
- license: mit
9
- short_description: Medical Scheduler for SOS Care Now Chicago
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: SOS Care Now Scheduling
3
+ emoji: 🩺
4
+ colorFrom: blue
5
+ colorTo: indigo
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
 
 
9
  ---
10
 
11
+ # SOS Care Now — Medical Scheduling Agent (HTML Edition)
12
+
13
+ A modern, FastAPI + HTML/JS rewrite of the Streamlit scheduling dashboard. Designed for
14
+ deployment on HuggingFace Spaces via Docker.
15
+
16
+ ## Architecture
17
+
18
+ - **Backend:** FastAPI (Python) — reuses the existing `core/`, `agent/`, and `utils/` modules.
19
+ - **Frontend:** Single-page HTML app with Tailwind CSS, Alpine.js, and FullCalendar.js.
20
+ - **Data:** Google Sheets (via `gspread`), Google Calendar, Google Maps, Gmail.
21
+ - **Scheduling logic:** LangGraph agent (unchanged from the Streamlit version).
22
+
23
+ ## Local development
24
+
25
+ ```bash
26
+ pip install -r requirements.txt
27
+ # Set required env vars (see below), then:
28
+ uvicorn main:app --host 0.0.0.0 --port 7860 --reload
29
+ ```
30
+
31
+ Open <http://localhost:7860>.
32
+
33
+ ## HuggingFace Spaces deployment
34
+
35
+ Push this folder as the root of a Docker-mode Space. The `Dockerfile` handles the rest.
36
+ The Space needs the secrets listed below. Port `7860` is reserved by HF.
37
+
38
+ ## Required environment variables (Secrets in HF)
39
+
40
+ | Variable | Description |
41
+ |----------|-------------|
42
+ | `OPENAI_API_KEY` | OpenAI API key for GPT |
43
+ | `GOOGLE_CALENDAR_CREDENTIALS` | Base64-encoded service-account JSON |
44
+ | `GOOGLE_MAPS_API_KEY` | Maps Distance Matrix + Geocoding |
45
+ | `GMAIL_ADDRESS` | From-address for notifications |
46
+ | `GMAIL_CLIENT_ID` | OAuth client ID (gmail.send scope) |
47
+ | `GMAIL_CLIENT_SECRET` | OAuth client secret |
48
+ | `GMAIL_REFRESH_TOKEN` | OAuth refresh token |
49
+ | `ADMIN_USERNAME` | Admin login username |
50
+ | `ADMIN_PASSWORD` | Admin login password |
51
+ | `SHARED_CALENDAR_ID` | Shared Google Calendar ID |
52
+ | `GOOGLE_SHEET_ID` | Spreadsheet ID backing all CRUD operations |
53
+
54
+ ## What's new vs. the Streamlit version
55
+
56
+ - Modern multi-view single-page UI (sidebar navigation, modals, toasts).
57
+ - **On-call toggle** — clickable switch on each on-call date for both admin and provider views.
58
+ - FullCalendar with provider color coding and event detail modals.
59
+ - Mobile-friendly responsive layout.
60
+ - Session-cookie auth instead of Streamlit `session_state`.
61
+
62
+ All existing features from the Streamlit dashboard are preserved: provider CRUD,
63
+ patient CRUD, days off / standing days, on-call (24-hour window 07:00→07:00),
64
+ LangGraph scheduling agent, audit log, provider portal.
agent/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """agent package."""
agent/graph.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LangGraph Graph Definition — compiles the scheduling workflow into an executable graph.
3
+ """
4
+ from langgraph.graph import StateGraph, END
5
+ from langgraph.checkpoint.memory import MemorySaver
6
+ from agent.state import SchedulingState
7
+ from agent.nodes import (
8
+ validate_inputs,
9
+ check_availability,
10
+ calculate_travel,
11
+ rank_and_present,
12
+ book_appointment,
13
+ send_notifications,
14
+ no_match,
15
+ )
16
+
17
+
18
+ def should_continue_after_availability(state: SchedulingState) -> str:
19
+ """Routing function after availability check.
20
+
21
+ Returns 'calculate_travel' if providers are available, 'no_match' otherwise.
22
+ """
23
+ if state.get("available_providers"):
24
+ return "calculate_travel"
25
+ return "no_match"
26
+
27
+
28
+ def build_scheduling_graph():
29
+ """Build and compile the scheduling LangGraph.
30
+
31
+ Graph flow:
32
+ validate_inputs → check_availability → [conditional]
33
+ → calculate_travel → rank_and_present → (INTERRUPT for user selection)
34
+ → book_appointment → send_notifications → END
35
+ or:
36
+ → no_match → END
37
+
38
+ The graph uses interrupt_before=["book_appointment"] to pause for human-in-the-loop
39
+ confirmation. The Streamlit UI should:
40
+ 1. Run the graph until it hits the interrupt
41
+ 2. Display ranked_matches to the user
42
+ 3. Get user selection
43
+ 4. Update state with user_selection
44
+ 5. Resume the graph
45
+
46
+ Returns:
47
+ Compiled LangGraph
48
+ """
49
+ workflow = StateGraph(SchedulingState)
50
+
51
+ # Add nodes
52
+ workflow.add_node("validate_inputs", validate_inputs)
53
+ workflow.add_node("check_availability", check_availability)
54
+ workflow.add_node("calculate_travel", calculate_travel)
55
+ workflow.add_node("rank_and_present", rank_and_present)
56
+ workflow.add_node("book_appointment", book_appointment)
57
+ workflow.add_node("send_notifications", send_notifications)
58
+ workflow.add_node("no_match", no_match)
59
+
60
+ # Set entry point
61
+ workflow.set_entry_point("validate_inputs")
62
+
63
+ # Add edges
64
+ workflow.add_edge("validate_inputs", "check_availability")
65
+ workflow.add_conditional_edges(
66
+ "check_availability",
67
+ should_continue_after_availability,
68
+ {
69
+ "calculate_travel": "calculate_travel",
70
+ "no_match": "no_match",
71
+ }
72
+ )
73
+ workflow.add_edge("calculate_travel", "rank_and_present")
74
+ workflow.add_edge("rank_and_present", "book_appointment")
75
+ workflow.add_edge("book_appointment", "send_notifications")
76
+ workflow.add_edge("send_notifications", END)
77
+ workflow.add_edge("no_match", END)
78
+
79
+ # Compile with interrupt for human-in-the-loop
80
+ # The graph pauses before book_appointment so the UI can show options and get user selection
81
+ graph = workflow.compile(checkpointer=MemorySaver(), interrupt_before=["book_appointment"])
82
+
83
+ return graph
agent/nodes.py ADDED
@@ -0,0 +1,392 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LangGraph Node Functions — each function represents one step in the scheduling graph.
3
+
4
+ Each node takes the current SchedulingState and returns a partial state update (dict).
5
+ """
6
+ from agent.state import SchedulingState
7
+ from core import data_manager, scheduling_engine, google_services
8
+ from core import settings_manager as settings
9
+ from datetime import datetime, timedelta
10
+ from zoneinfo import ZoneInfo
11
+
12
+
13
+ # Lazy-initialize the LLM to avoid import-time failures if the API key isn't set yet.
14
+ _llm = None
15
+
16
+ def _get_llm():
17
+ global _llm
18
+ if _llm is None:
19
+ from langchain_openai import ChatOpenAI
20
+ _llm = ChatOpenAI(model=settings.get_openai_model())
21
+ return _llm
22
+
23
+
24
+ def validate_inputs(state: SchedulingState) -> dict:
25
+ """Validate all inputs from the dashboard before proceeding.
26
+
27
+ Checks:
28
+ - Patient exists
29
+ - Visit level is valid
30
+ - Address is not empty
31
+ - Preferred dates are at least 24 hours in the future
32
+ - Time window is valid (start < end, within reason)
33
+
34
+ Returns:
35
+ {"error": "message"} if validation fails, otherwise empty dict {}
36
+ """
37
+ # Patient must exist
38
+ patient = data_manager.get_patient_by_id(state["patient_id"])
39
+ if not patient:
40
+ return {"error": f"Patient '{state['patient_id']}' not found."}
41
+
42
+ # Visit level must be in the configured set
43
+ if state["visit_level"] not in settings.get_visit_levels():
44
+ return {"error": f"Invalid visit level: {state['visit_level']}"}
45
+
46
+ # Visit address must not be blank
47
+ if not state.get("visit_address", "").strip():
48
+ return {"error": "Visit address is required."}
49
+
50
+ # At least one preferred date must be provided
51
+ if not state.get("preferred_dates"):
52
+ return {"error": "At least one preferred date is required."}
53
+
54
+ # Every preferred date must be at least the configured lead time from now.
55
+ # Lead time always applies, even for on-call providers.
56
+ # Compare in the configured timezone so a UTC server doesn't mis-evaluate local times.
57
+ lead_hours = settings.get_min_lead_time_hours()
58
+ now_local = datetime.now(ZoneInfo(settings.get_timezone())).replace(tzinfo=None)
59
+ min_allowed_dt = now_local + timedelta(hours=lead_hours)
60
+ window_start = state.get("preferred_time_window", {}).get("start", "08:00")
61
+ for date_str in state["preferred_dates"]:
62
+ try:
63
+ proposed_dt = datetime.strptime(f"{date_str} {window_start}", "%Y-%m-%d %H:%M")
64
+ except ValueError:
65
+ return {"error": f"Invalid date format: '{date_str}'. Expected YYYY-MM-DD."}
66
+ if proposed_dt < min_allowed_dt:
67
+ return {"error": f"Date {date_str} at {window_start} does not meet the {lead_hours}-hour lead time requirement."}
68
+
69
+ # Time window must have start before end
70
+ window = state.get("preferred_time_window", {})
71
+ w_start = window.get("start", "")
72
+ w_end = window.get("end", "")
73
+ if w_start and w_end:
74
+ if scheduling_engine.parse_time(w_start) >= scheduling_engine.parse_time(w_end):
75
+ return {"error": "Time window start must be before end."}
76
+
77
+ return {}
78
+
79
+
80
+ def check_availability(state: SchedulingState) -> dict:
81
+ """Check which providers are available for the requested visit.
82
+
83
+ For each preferred date/time combo:
84
+ 1. Get all active providers
85
+ 2. Get each provider's existing appointments for that date
86
+ 3. Run scheduling_engine.is_provider_available for each
87
+
88
+ Returns:
89
+ {"available_providers": [...]} — list of provider dicts with an added 'available_slots' field
90
+ If none available: {"available_providers": [], "error": "No providers available..."}
91
+ """
92
+ # Skip if a previous node already set an error
93
+ if state.get("error"):
94
+ return {"available_providers": []}
95
+
96
+ all_providers = data_manager.get_all_providers(active_only=True)
97
+ visit_level = state["visit_level"]
98
+ visit_duration = scheduling_engine.get_visit_duration(visit_level)
99
+ window_start = state["preferred_time_window"]["start"]
100
+ window_end = state["preferred_time_window"]["end"]
101
+
102
+ available = []
103
+ for date in state["preferred_dates"]:
104
+ # Build a per-provider appointment index for this date (one read per provider)
105
+ appointments_by_provider = {
106
+ p["id"]: data_manager.get_appointments_for_provider(p["id"], date)
107
+ for p in all_providers
108
+ }
109
+
110
+ for provider in all_providers:
111
+ existing = appointments_by_provider[provider["id"]]
112
+ # Iterate through the time window in 30-minute increments
113
+ current_time = window_start
114
+ while scheduling_engine.parse_time(current_time) < scheduling_engine.parse_time(window_end):
115
+ if scheduling_engine.is_provider_available(
116
+ provider, date, current_time, visit_level, existing
117
+ ):
118
+ end_time = scheduling_engine.add_minutes(current_time, visit_duration)
119
+ enriched = dict(provider)
120
+ enriched["available_date"] = date
121
+ enriched["available_start_time"] = current_time
122
+ enriched["available_end_time"] = end_time
123
+ enriched["is_oncall"] = scheduling_engine.is_oncall_for_slot(
124
+ provider, date, current_time, end_time
125
+ )
126
+ available.append(enriched)
127
+ current_time = scheduling_engine.add_minutes(current_time, 30)
128
+
129
+ if not available:
130
+ return {
131
+ "available_providers": [],
132
+ "error": (
133
+ "No providers are available for the requested dates and time. "
134
+ "Try different dates or a different time window."
135
+ ),
136
+ }
137
+
138
+ return {"available_providers": available}
139
+
140
+
141
+ def calculate_travel(state: SchedulingState) -> dict:
142
+ """Calculate travel times for all available providers to the patient's address.
143
+
144
+ For each available provider:
145
+ 1. Determine their "origin" — the address of their previous appointment that day,
146
+ or their home address if it's their first visit
147
+ 2. Call google_services.calculate_travel_time(origin, visit_address)
148
+ 3. Also check if provider has other visits nearby (for area-affinity boost)
149
+
150
+ Returns:
151
+ {"travel_times": [...]} — provider dicts enriched with 'travel_time_minutes' and 'has_nearby_visits'
152
+ """
153
+ visit_address = state["visit_address"]
154
+ enriched_providers = []
155
+
156
+ for provider in state["available_providers"]:
157
+ available_date = provider["available_date"]
158
+ available_start = provider["available_start_time"]
159
+ arrival_by = datetime.strptime(f"{available_date} {available_start}", "%Y-%m-%d %H:%M")
160
+
161
+ # Fetch existing appointments for this provider on the proposed date
162
+ existing = data_manager.get_appointments_for_provider(provider["id"], available_date)
163
+
164
+ # Origin = home address, or the last appointment address that ends before proposed start
165
+ origin = provider["address"]
166
+ for appt in sorted(existing, key=lambda a: a["end_time"]):
167
+ if appt["end_time"] <= available_start:
168
+ origin = appt["visit_address"]
169
+
170
+ # Travel time from origin to patient visit address (traffic-aware two-pass)
171
+ travel_result = google_services.calculate_travel_time(origin, visit_address, arrival_by)
172
+ travel_time = travel_result["travel_minutes"]
173
+ if travel_time < 0:
174
+ travel_time = 0 # Graceful fallback if the Maps API fails
175
+ recommended_departure = travel_result["recommended_departure"]
176
+
177
+ # Area affinity: does the provider have any visits close to the patient that day?
178
+ has_nearby = False
179
+ if existing:
180
+ appt_addresses = [a["visit_address"] for a in existing]
181
+ nearby_results = google_services.calculate_travel_times_batch(
182
+ visit_address, appt_addresses, arrival_by
183
+ )
184
+ # "Nearby" = within 15 minutes of the proposed visit address
185
+ has_nearby = any(r["travel_minutes"] >= 0 and r["travel_minutes"] <= 15 for r in nearby_results)
186
+
187
+ enriched = dict(provider)
188
+ enriched["travel_time_minutes"] = travel_time
189
+ enriched["has_nearby_visits"] = has_nearby
190
+ enriched["recommended_departure_time"] = recommended_departure.strftime("%H:%M")
191
+ enriched_providers.append(enriched)
192
+
193
+ return {"travel_times": enriched_providers}
194
+
195
+
196
+ def rank_and_present(state: SchedulingState) -> dict:
197
+ """Rank available providers by travel time and area affinity, then format for display.
198
+
199
+ Uses scheduling_engine.rank_providers_by_travel.
200
+
201
+ Returns:
202
+ {"ranked_matches": [...]} — sorted list of match dicts with fields:
203
+ provider_id, provider_name, provider_email, date, start_time, end_time,
204
+ travel_time_minutes, has_nearby_visits, summary
205
+ """
206
+ ranked = scheduling_engine.rank_providers_by_travel(state["travel_times"])
207
+ level_label = settings.get_visit_levels()[state["visit_level"]]["label"]
208
+
209
+ matches = []
210
+ for provider in ranked:
211
+ is_oncall = provider.get("is_oncall", False)
212
+ nearby_note = ", nearby visits" if provider["has_nearby_visits"] else ""
213
+ departure = provider.get("recommended_departure_time", "")
214
+ departure_note = f", depart by {departure}" if departure else ""
215
+ oncall_note = "[ON CALL] " if is_oncall else ""
216
+ summary = (
217
+ f"{oncall_note}{provider['name']} — {level_label} on {provider['available_date']} "
218
+ f"at {provider['available_start_time']}–{provider['available_end_time']} "
219
+ f"({provider['travel_time_minutes']} min travel{nearby_note}{departure_note})"
220
+ )
221
+ matches.append({
222
+ "provider_id": provider["id"],
223
+ "provider_name": provider["name"],
224
+ "provider_email": provider.get("email", ""),
225
+ "date": provider["available_date"],
226
+ "start_time": provider["available_start_time"],
227
+ "end_time": provider["available_end_time"],
228
+ "travel_time_minutes": provider["travel_time_minutes"],
229
+ "has_nearby_visits": provider["has_nearby_visits"],
230
+ "is_oncall": is_oncall,
231
+ "recommended_departure_time": departure,
232
+ "summary": summary,
233
+ })
234
+
235
+ return {"ranked_matches": matches}
236
+
237
+
238
+ def book_appointment(state: SchedulingState) -> dict:
239
+ """Book the selected appointment.
240
+
241
+ Steps:
242
+ 1. Final overlap check (in case something changed)
243
+ 2. Create appointment record via data_manager.add_appointment
244
+ 3. Create Google Calendar event via google_services.create_calendar_event
245
+ 4. Update appointment record with google_event_id
246
+ 5. Log action to audit log
247
+
248
+ Returns:
249
+ {"booked_appointment": {...}} or {"error": "Booking failed: ..."}
250
+ """
251
+ selection = state["user_selection"]
252
+ provider_id = selection["provider_id"]
253
+ date = selection["date"]
254
+ start_time = selection["start_time"]
255
+
256
+ # Final freshness check: re-fetch the provider and their current appointments
257
+ provider = data_manager.get_provider_by_id(provider_id)
258
+ if not provider:
259
+ return {"error": f"Booking failed: provider '{provider_id}' not found."}
260
+
261
+ existing = data_manager.get_appointments_for_provider(provider_id, date)
262
+ if not scheduling_engine.is_provider_available(
263
+ provider, date, start_time, state["visit_level"], existing
264
+ ):
265
+ return {"error": "Booking failed: the selected slot is no longer available."}
266
+
267
+ # Persist the appointment record
268
+ appointment_data = {
269
+ "patient_id": state["patient_id"],
270
+ "patient_name": state["patient_name"],
271
+ "provider_id": provider_id,
272
+ "provider_name": selection["provider_name"],
273
+ "date": date,
274
+ "start_time": start_time,
275
+ "end_time": selection["end_time"],
276
+ "visit_level": state["visit_level"],
277
+ "visit_address": state["visit_address"],
278
+ "recommended_departure_time": selection.get("recommended_departure_time", ""),
279
+ "status": "scheduled",
280
+ "google_event_id": None,
281
+ }
282
+ appointment = data_manager.add_appointment(appointment_data)
283
+
284
+ # Create Google Calendar event (non-critical — failure doesn't block booking)
285
+ try:
286
+ level_label = settings.get_visit_levels()[state["visit_level"]]["label"]
287
+ event_summary = f"[{level_label}] {state['patient_name']} — {selection['provider_name']}"
288
+ description = (
289
+ f"Patient: {state['patient_name']}\n"
290
+ f"Provider: {selection['provider_name']}\n"
291
+ f"Visit Level: {level_label}\n"
292
+ f"Address: {state['visit_address']}"
293
+ )
294
+ event_id = google_services.create_calendar_event(
295
+ summary=event_summary,
296
+ description=description,
297
+ location=state["visit_address"],
298
+ start_datetime=f"{date}T{start_time}:00",
299
+ end_datetime=f"{date}T{selection['end_time']}:00",
300
+ )
301
+ data_manager.update_appointment(appointment["id"], {"google_event_id": event_id})
302
+ appointment["google_event_id"] = event_id
303
+ except Exception:
304
+ pass
305
+
306
+ # Audit log
307
+ data_manager.log_action(
308
+ user="admin",
309
+ action="SCHEDULE",
310
+ details=(
311
+ f"Scheduled {settings.get_visit_levels()[state['visit_level']]['label']} visit for "
312
+ f"{state['patient_name']} with {selection['provider_name']} "
313
+ f"on {date} at {start_time}"
314
+ ),
315
+ appointment_id=appointment["id"],
316
+ )
317
+
318
+ return {"booked_appointment": appointment}
319
+
320
+
321
+ def send_notifications(state: SchedulingState) -> dict:
322
+ """Send confirmation emails to both the provider and patient.
323
+
324
+ Uses google_services.send_scheduling_notification.
325
+
326
+ Returns:
327
+ {"notifications_sent": True/False}
328
+ """
329
+ appointment = state.get("booked_appointment")
330
+ if not appointment:
331
+ return {"notifications_sent": False}
332
+
333
+ selection = state["user_selection"]
334
+ appointment_details = {
335
+ "patient_name": state["patient_name"],
336
+ "provider_name": selection["provider_name"],
337
+ "date": appointment["date"],
338
+ "start_time": appointment["start_time"],
339
+ "end_time": appointment["end_time"],
340
+ "visit_level": settings.get_visit_levels()[state["visit_level"]]["label"],
341
+ "address": state["visit_address"],
342
+ "recommended_departure_time": appointment.get("recommended_departure_time", ""),
343
+ }
344
+
345
+ try:
346
+ result = google_services.send_scheduling_notification(
347
+ provider_email=selection.get("provider_email", ""),
348
+ patient_email=state.get("patient_email", ""),
349
+ appointment_details=appointment_details,
350
+ )
351
+ sent = result.get("provider_sent", False) or result.get("patient_sent", False)
352
+ except Exception:
353
+ sent = False
354
+
355
+ return {"notifications_sent": sent}
356
+
357
+
358
+ def no_match(state: SchedulingState) -> dict:
359
+ """Handle the case where no providers are available.
360
+
361
+ Uses the LLM to generate a helpful message suggesting alternative dates/times.
362
+
363
+ Returns:
364
+ {"error": "No matching providers found. <suggestion>"}
365
+ """
366
+ existing_error = state.get("error", "No providers are available for the requested visit.")
367
+
368
+ try:
369
+ llm = _get_llm()
370
+ preferred_dates = state.get("preferred_dates", [])
371
+ visit_level = state.get("visit_level", "")
372
+ level_label = settings.get_visit_levels().get(visit_level, {}).get("label", str(visit_level))
373
+ window = state.get("preferred_time_window", {})
374
+
375
+ prompt = (
376
+ f"A home healthcare {level_label} visit could not be scheduled. "
377
+ f"Requested dates: {', '.join(preferred_dates)}. "
378
+ f"Requested time window: {window.get('start', '?')}–{window.get('end', '?')}. "
379
+ f"Provide 2–3 brief, practical suggestions the scheduler can try next "
380
+ f"(e.g., different dates, expanded time window, or different visit level if appropriate). "
381
+ f"Keep the response to 2 sentences maximum."
382
+ )
383
+
384
+ response = llm.invoke(prompt)
385
+ suggestion = response.content.strip()
386
+ except Exception:
387
+ suggestion = (
388
+ "Try selecting different dates, expanding the time window, "
389
+ "or contacting the scheduling team for assistance."
390
+ )
391
+
392
+ return {"error": f"{existing_error} {suggestion}"}
agent/state.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LangGraph State Schema — defines the typed state that flows through the scheduling graph.
3
+ """
4
+ from typing import TypedDict, Optional
5
+
6
+
7
+ class SchedulingState(TypedDict):
8
+ """State object that passes through all nodes in the scheduling graph.
9
+
10
+ Fields populated by the dashboard (input):
11
+ - patient_id, visit_level, visit_address, preferred_dates, preferred_time_window
12
+
13
+ Fields populated by agent nodes (intermediate):
14
+ - available_providers, travel_times, ranked_matches
15
+
16
+ Fields requiring human-in-the-loop (interaction):
17
+ - user_selection
18
+
19
+ Fields populated at completion (output):
20
+ - booked_appointment, notifications_sent, error
21
+ """
22
+ # --- Inputs from dashboard ---
23
+ patient_id: str
24
+ patient_name: str
25
+ patient_email: str
26
+ visit_level: int | str
27
+ visit_address: str
28
+ visit_lat: float
29
+ visit_lng: float
30
+ preferred_dates: list[str] # ["2026-03-20", "2026-03-21"]
31
+ preferred_time_window: dict # {"start": "09:00", "end": "12:00"}
32
+
33
+ # --- Intermediate results ---
34
+ available_providers: list[dict] # Providers passing availability check
35
+ travel_times: list[dict] # Provider dicts enriched with travel_time_minutes
36
+ ranked_matches: list[dict] # Final ranked list presented to user
37
+
38
+ # --- Human-in-the-loop ---
39
+ user_selection: Optional[dict] # The provider+time combo the user picked
40
+
41
+ # --- Outputs ---
42
+ booked_appointment: Optional[dict] # The created appointment record
43
+ notifications_sent: bool
44
+ error: Optional[str] # Error message if something went wrong
api/__init__.py ADDED
File without changes
api/appointments.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Appointment routes — list, cancel, and complete."""
2
+ from datetime import datetime
3
+ from zoneinfo import ZoneInfo
4
+ from fastapi import APIRouter, Depends, HTTPException
5
+ from core import data_manager, google_services
6
+ from core import settings_manager as settings
7
+ from api.deps import get_current_session
8
+
9
+ router = APIRouter(prefix="/api/appointments", tags=["appointments"])
10
+
11
+
12
+ @router.get("")
13
+ def list_appointments(status: str | None = None, provider_id: str | None = None, session: dict = Depends(get_current_session)):
14
+ appts = data_manager.get_all_appointments(status=status)
15
+ if session.get("user_role") == "provider":
16
+ # Providers see only their own appointments
17
+ appts = [a for a in appts if a.get("provider_id") == session.get("provider_id")]
18
+ elif provider_id:
19
+ appts = [a for a in appts if a.get("provider_id") == provider_id]
20
+ return appts
21
+
22
+
23
+ @router.delete("/{appointment_id}")
24
+ def cancel_appointment(appointment_id: str, session: dict = Depends(get_current_session)):
25
+ if session.get("user_role") == "provider":
26
+ raise HTTPException(status_code=403, detail="Providers cannot cancel appointments")
27
+
28
+ appt = next((a for a in data_manager.get_all_appointments() if a.get("id") == appointment_id), None)
29
+ if not appt:
30
+ raise HTTPException(status_code=404, detail="Appointment not found")
31
+
32
+ cancelled = data_manager.cancel_appointment(appointment_id)
33
+ if not cancelled:
34
+ raise HTTPException(status_code=500, detail="Cancellation failed")
35
+
36
+ # Delete Google Calendar event (non-critical)
37
+ if appt.get("google_event_id"):
38
+ try:
39
+ google_services.delete_calendar_event(appt["google_event_id"])
40
+ except Exception:
41
+ pass
42
+
43
+ # Send cancellation notifications (non-critical)
44
+ try:
45
+ provider = data_manager.get_provider_by_id(appt["provider_id"])
46
+ patient = data_manager.get_patient_by_id(appt["patient_id"])
47
+ appointment_details = {
48
+ "patient_name": appt.get("patient_name", ""),
49
+ "provider_name": appt.get("provider_name", ""),
50
+ "date": appt.get("date", ""),
51
+ "start_time": appt.get("start_time", ""),
52
+ "end_time": appt.get("end_time", ""),
53
+ "address": appt.get("visit_address", ""),
54
+ }
55
+ google_services.send_cancellation_notification(
56
+ provider_email=provider.get("email", "") if provider else "",
57
+ patient_email=patient.get("email", "") if patient else "",
58
+ appointment_details=appointment_details,
59
+ )
60
+ except Exception:
61
+ pass
62
+
63
+ data_manager.log_action(
64
+ user=session["current_user"],
65
+ action="CANCEL",
66
+ details=(
67
+ f"Cancelled {appt.get('visit_level', '')} visit for "
68
+ f"{appt.get('patient_name', '')} with {appt.get('provider_name', '')} "
69
+ f"on {appt.get('date', '')} at {appt.get('start_time', '')}"
70
+ ),
71
+ appointment_id=appointment_id,
72
+ )
73
+ return {"ok": True}
74
+
75
+
76
+ @router.post("/{appointment_id}/complete")
77
+ def complete_appointment(appointment_id: str, session: dict = Depends(get_current_session)):
78
+ """Mark an appointment as completed. Provider-only; only allowed once the
79
+ appointment's start time has passed (compared in the platform timezone)."""
80
+ appt = next((a for a in data_manager.get_all_appointments() if a.get("id") == appointment_id), None)
81
+ if not appt:
82
+ raise HTTPException(status_code=404, detail="Appointment not found")
83
+
84
+ if session.get("user_role") == "provider" and session.get("provider_id") != appt.get("provider_id"):
85
+ raise HTTPException(status_code=403, detail="Can only complete your own appointments")
86
+
87
+ if appt.get("status") != "scheduled":
88
+ raise HTTPException(status_code=400, detail=f"Cannot complete appointment with status '{appt.get('status')}'")
89
+
90
+ # Compare in the platform timezone so a UTC server doesn't mis-evaluate.
91
+ try:
92
+ start_dt = datetime.strptime(f"{appt['date']} {appt['start_time']}", "%Y-%m-%d %H:%M")
93
+ except (KeyError, ValueError):
94
+ raise HTTPException(status_code=500, detail="Appointment has invalid date/time")
95
+ now_local = datetime.now(ZoneInfo(settings.get_timezone())).replace(tzinfo=None)
96
+ if now_local < start_dt:
97
+ raise HTTPException(status_code=400, detail="Appointment has not started yet")
98
+
99
+ completed = data_manager.complete_appointment(appointment_id)
100
+ if not completed:
101
+ raise HTTPException(status_code=500, detail="Failed to mark complete")
102
+
103
+ data_manager.log_action(
104
+ user=session["current_user"],
105
+ action="COMPLETE",
106
+ details=(
107
+ f"Completed {appt.get('visit_level', '')} visit for "
108
+ f"{appt.get('patient_name', '')} with {appt.get('provider_name', '')} "
109
+ f"on {appt.get('date', '')} at {appt.get('start_time', '')}"
110
+ ),
111
+ appointment_id=appointment_id,
112
+ )
113
+ return {"ok": True, "appointment": completed}
api/audit.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Audit log route."""
2
+ from fastapi import APIRouter, Depends
3
+ from core import data_manager
4
+ from api.deps import require_admin_session
5
+
6
+ router = APIRouter(prefix="/api/audit", tags=["audit"])
7
+
8
+
9
+ @router.get("")
10
+ def list_audit(
11
+ search: str | None = None,
12
+ action: str | None = None,
13
+ limit: int = 500,
14
+ session: dict = Depends(require_admin_session),
15
+ ):
16
+ log = data_manager.get_audit_log(limit=limit)
17
+ if action and action != "All":
18
+ log = [e for e in log if e.get("action") == action]
19
+ if search:
20
+ s = search.lower()
21
+ log = [
22
+ e for e in log
23
+ if s in str(e.get("details", "")).lower()
24
+ or s in str(e.get("action", "")).lower()
25
+ or s in str(e.get("user", "")).lower()
26
+ or s in str(e.get("appointment_id", "")).lower()
27
+ ]
28
+ return log
29
+
30
+
31
+ @router.get("/actions")
32
+ def action_types(session: dict = Depends(require_admin_session)):
33
+ log = data_manager.get_audit_log(limit=1000)
34
+ return sorted({e.get("action", "") for e in log if e.get("action")})
api/auth.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Auth routes: login (admin/provider), logout, me."""
2
+ import os
3
+ from fastapi import APIRouter, HTTPException, Response, Cookie
4
+ from pydantic import BaseModel
5
+ from utils import auth as auth_utils
6
+ from api.deps import SESSION_COOKIE
7
+
8
+ router = APIRouter(prefix="/api/auth", tags=["auth"])
9
+
10
+
11
+ # HuggingFace Spaces serves the app inside a cross-site iframe, so the session
12
+ # cookie needs SameSite=None; Secure to be sent. Locally over http://localhost,
13
+ # `secure=True` would prevent the cookie from being set at all — so we detect
14
+ # the environment and pick the right pair. HF sets SPACE_ID automatically.
15
+ _IN_HF_SPACE = bool(os.environ.get("SPACE_ID"))
16
+ _COOKIE_KW = (
17
+ {"samesite": "none", "secure": True} if _IN_HF_SPACE else {"samesite": "lax"}
18
+ )
19
+
20
+
21
+ class LoginRequest(BaseModel):
22
+ username: str
23
+ password: str
24
+
25
+
26
+ class ProviderLoginRequest(BaseModel):
27
+ name: str
28
+ password: str
29
+
30
+
31
+ @router.post("/login")
32
+ def login(req: LoginRequest, response: Response):
33
+ if not auth_utils.check_credentials(req.username, req.password):
34
+ raise HTTPException(status_code=401, detail="Invalid username or password")
35
+ token = auth_utils.create_session(user_role="admin", current_user=req.username)
36
+ response.set_cookie(key=SESSION_COOKIE, value=token, httponly=True, max_age=60 * 60 * 24 * 7, **_COOKIE_KW)
37
+ return {"user_role": "admin", "current_user": req.username}
38
+
39
+
40
+ @router.post("/provider-login")
41
+ def provider_login(req: ProviderLoginRequest, response: Response):
42
+ provider = auth_utils.check_provider_credentials(req.name, req.password)
43
+ if not provider:
44
+ raise HTTPException(status_code=401, detail="Invalid provider name or password")
45
+ token = auth_utils.create_session(
46
+ user_role="provider",
47
+ current_user=provider["name"],
48
+ provider_id=provider["id"],
49
+ )
50
+ response.set_cookie(key=SESSION_COOKIE, value=token, httponly=True, max_age=60 * 60 * 24 * 7, **_COOKIE_KW)
51
+ return {"user_role": "provider", "current_user": provider["name"], "provider_id": provider["id"]}
52
+
53
+
54
+ @router.post("/logout")
55
+ def logout(response: Response, session_token: str | None = Cookie(default=None, alias=SESSION_COOKIE)):
56
+ auth_utils.destroy_session(session_token)
57
+ response.delete_cookie(SESSION_COOKIE)
58
+ return {"ok": True}
59
+
60
+
61
+ @router.get("/me")
62
+ def me(session_token: str | None = Cookie(default=None, alias=SESSION_COOKIE)):
63
+ session = auth_utils.get_session(session_token)
64
+ if not session:
65
+ return {"authenticated": False}
66
+ return {"authenticated": True, **session}
67
+
68
+
69
+ @router.get("/provider-names")
70
+ def provider_names():
71
+ """Public list of provider names for the login dropdown."""
72
+ from core import data_manager
73
+ providers = data_manager.get_all_providers(active_only=False)
74
+ return [{"id": p["id"], "name": p["name"]} for p in providers]
api/calendar.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Calendar route — produces FullCalendar-compatible events from appointments + provider blocks."""
2
+ from datetime import datetime, timedelta
3
+ from fastapi import APIRouter, Depends
4
+ from core import data_manager
5
+ from core import settings_manager as settings
6
+ from core.scheduling_engine import add_minutes
7
+ from api.deps import get_current_session
8
+
9
+ router = APIRouter(prefix="/api/calendar", tags=["calendar"])
10
+
11
+
12
+ # Deterministic color palette assigned to providers in load order.
13
+ _PROVIDER_COLORS = [
14
+ "#2563eb", "#16a34a", "#dc2626", "#9333ea", "#ea580c",
15
+ "#0891b2", "#ca8a04", "#db2777", "#4f46e5", "#65a30d",
16
+ ]
17
+
18
+
19
+ def _provider_color_map(providers: list[dict]) -> dict[str, str]:
20
+ return {p["id"]: _PROVIDER_COLORS[i % len(_PROVIDER_COLORS)] for i, p in enumerate(providers)}
21
+
22
+
23
+ @router.get("/events")
24
+ def get_events(
25
+ start: str | None = None,
26
+ end: str | None = None,
27
+ provider_ids: str | None = None,
28
+ show_unavailable: bool = True,
29
+ show_travel: bool = True,
30
+ show_oncall: bool = True,
31
+ session: dict = Depends(get_current_session),
32
+ ):
33
+ providers = data_manager.get_all_providers(active_only=False)
34
+ color_map = _provider_color_map(providers)
35
+
36
+ selected_ids: set[str] | None = None
37
+ if session.get("user_role") == "provider":
38
+ selected_ids = {session.get("provider_id")}
39
+ elif provider_ids:
40
+ selected_ids = set(provider_ids.split(",")) if provider_ids else None
41
+
42
+ visible_providers = [p for p in providers if (selected_ids is None or p["id"] in selected_ids)]
43
+
44
+ events = []
45
+ is_provider_view = session.get("user_role") == "provider"
46
+
47
+ # Appointments — include both scheduled and completed; render completed in grey.
48
+ appointments = [a for a in data_manager.get_all_appointments() if a.get("status") in ("scheduled", "completed")]
49
+ for a in appointments:
50
+ pid = a.get("provider_id")
51
+ if selected_ids is not None and pid not in selected_ids:
52
+ continue
53
+ status = a.get("status", "scheduled")
54
+ is_completed = status == "completed"
55
+ color = "#94a3b8" if is_completed else color_map.get(pid, "#2563eb")
56
+ if is_provider_view:
57
+ title = f"Visit — {a.get('visit_level', '')}"
58
+ else:
59
+ title = f"{a.get('patient_name', '')} — {a.get('provider_name', '')}"
60
+ if is_completed:
61
+ title = f"✓ {title}"
62
+ events.append({
63
+ "id": f"appt_{a['id']}",
64
+ "title": title,
65
+ "start": f"{a['date']}T{a['start_time']}:00",
66
+ "end": f"{a['date']}T{a['end_time']}:00",
67
+ "backgroundColor": color,
68
+ "borderColor": color,
69
+ "classNames": ["event-completed"] if is_completed else [],
70
+ "extendedProps": {
71
+ "type": "appointment",
72
+ "status": status,
73
+ "appointment_id": a["id"],
74
+ "patient_name": a.get("patient_name", ""),
75
+ "provider_name": a.get("provider_name", ""),
76
+ "provider_id": pid,
77
+ "visit_level": str(a.get("visit_level", "")),
78
+ "visit_address": a.get("visit_address", ""),
79
+ "recommended_departure_time": a.get("recommended_departure_time", ""),
80
+ "start_time": a.get("start_time", ""),
81
+ "end_time": a.get("end_time", ""),
82
+ "date": a.get("date", ""),
83
+ "completed_at": a.get("completed_at", ""),
84
+ },
85
+ })
86
+ # Travel block (30 min before, if departure time exists) — skip for completed visits.
87
+ if show_travel and not is_completed:
88
+ departure = a.get("recommended_departure_time")
89
+ if departure:
90
+ events.append({
91
+ "id": f"travel_{a['id']}",
92
+ "title": f"Travel to {a.get('patient_name', 'visit')}",
93
+ "start": f"{a['date']}T{departure}:00",
94
+ "end": f"{a['date']}T{a['start_time']}:00",
95
+ "backgroundColor": color,
96
+ "borderColor": color,
97
+ "classNames": ["event-travel"],
98
+ "extendedProps": {
99
+ "type": "travel",
100
+ "provider_id": pid,
101
+ "provider_name": a.get("provider_name", ""),
102
+ },
103
+ })
104
+
105
+ # Days-off blocks
106
+ if show_unavailable:
107
+ for p in visible_providers:
108
+ for off in p.get("days_off", []) or []:
109
+ if isinstance(off, str):
110
+ off = {"date": off, "all_day": True, "title": "Day Off"}
111
+ d = off.get("date")
112
+ if not d:
113
+ continue
114
+ title = off.get("title", "Unavailable")
115
+ if off.get("all_day", True):
116
+ start_iso = f"{d}T00:00:00"
117
+ end_iso = f"{d}T23:59:59"
118
+ else:
119
+ start_iso = f"{d}T{off.get('start_time','00:00')}:00"
120
+ end_iso = f"{d}T{off.get('end_time','23:59')}:00"
121
+ events.append({
122
+ "id": f"off_{p['id']}_{d}_{title}",
123
+ "title": f"{p['name']} — {title}",
124
+ "start": start_iso,
125
+ "end": end_iso,
126
+ "backgroundColor": "#fecaca",
127
+ "borderColor": "#dc2626",
128
+ "textColor": "#7f1d1d",
129
+ "extendedProps": {
130
+ "type": "days_off",
131
+ "provider_id": p["id"],
132
+ "provider_name": p["name"],
133
+ "title": title,
134
+ },
135
+ })
136
+ # Recurring weekdays — render as background events for whole date range
137
+ recurring = p.get("recurring_days_off", []) or []
138
+ if recurring and start and end:
139
+ weekday_to_num = {"Monday":0,"Tuesday":1,"Wednesday":2,"Thursday":3,"Friday":4,"Saturday":5,"Sunday":6}
140
+ try:
141
+ s_dt = datetime.strptime(start[:10], "%Y-%m-%d")
142
+ e_dt = datetime.strptime(end[:10], "%Y-%m-%d")
143
+ except ValueError:
144
+ s_dt = e_dt = None
145
+ if s_dt and e_dt:
146
+ cur = s_dt
147
+ while cur < e_dt:
148
+ if cur.strftime("%A") in recurring:
149
+ d_str = cur.strftime("%Y-%m-%d")
150
+ events.append({
151
+ "id": f"rec_{p['id']}_{d_str}",
152
+ "title": f"{p['name']} — Standing Day Off",
153
+ "start": f"{d_str}T00:00:00",
154
+ "end": f"{d_str}T23:59:59",
155
+ "display": "background",
156
+ "backgroundColor": "#e5e7eb",
157
+ "extendedProps": {"type": "recurring_off", "provider_id": p["id"]},
158
+ })
159
+ cur += timedelta(days=1)
160
+
161
+ # On-call dates highlight — 24h window from on_call_start_hour on date D
162
+ # to the same hour on D+1 (matches scheduling_engine.is_oncall_for_slot).
163
+ start_hour = settings.get_on_call_start_hour() # "HH:MM"
164
+ for d in (p.get("on_call", []) or []) if show_oncall else []:
165
+ try:
166
+ window_start = datetime.strptime(f"{d} {start_hour}", "%Y-%m-%d %H:%M")
167
+ except ValueError:
168
+ continue
169
+ window_end = window_start + timedelta(hours=24)
170
+ events.append({
171
+ "id": f"oncall_{p['id']}_{d}",
172
+ "title": f"{p['name']} — ON CALL",
173
+ "start": window_start.strftime("%Y-%m-%dT%H:%M:%S"),
174
+ "end": window_end.strftime("%Y-%m-%dT%H:%M:%S"),
175
+ "backgroundColor": "#10b981",
176
+ "borderColor": "#047857",
177
+ "textColor": "#ffffff",
178
+ "extendedProps": {"type": "on_call", "provider_id": p["id"]},
179
+ })
180
+
181
+ return {
182
+ "events": events,
183
+ "providers": [
184
+ {"id": p["id"], "name": p["name"], "color": color_map[p["id"]]}
185
+ for p in providers
186
+ ],
187
+ }
api/deps.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI dependencies — auth, current user."""
2
+ from fastapi import Cookie, HTTPException, status
3
+ from utils import auth as auth_utils
4
+
5
+ SESSION_COOKIE = "session_token"
6
+
7
+
8
+ def get_current_session(session_token: str | None = Cookie(default=None, alias=SESSION_COOKIE)) -> dict:
9
+ session = auth_utils.get_session(session_token)
10
+ if not session:
11
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
12
+ return session
13
+
14
+
15
+ def require_admin(session: dict = None) -> dict:
16
+ if session is None:
17
+ from fastapi import Depends
18
+ session = Depends(get_current_session)
19
+ if session.get("user_role") != "admin":
20
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin required")
21
+ return session
22
+
23
+
24
+ def require_admin_session(session_token: str | None = Cookie(default=None, alias=SESSION_COOKIE)) -> dict:
25
+ session = auth_utils.get_session(session_token)
26
+ if not session:
27
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
28
+ if session.get("user_role") != "admin":
29
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin required")
30
+ return session
api/patients.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Patient CRUD routes."""
2
+ from fastapi import APIRouter, Depends, HTTPException
3
+ from pydantic import BaseModel
4
+ from core import data_manager, google_services
5
+ from api.deps import get_current_session, require_admin_session
6
+
7
+ router = APIRouter(prefix="/api/patients", tags=["patients"])
8
+
9
+
10
+ class PatientCreate(BaseModel):
11
+ name: str
12
+ email: str = ""
13
+ address: str = ""
14
+ notes: str = ""
15
+
16
+
17
+ class PatientUpdate(BaseModel):
18
+ name: str | None = None
19
+ email: str | None = None
20
+ address: str | None = None
21
+ notes: str | None = None
22
+
23
+
24
+ def _geocode(address: str) -> tuple[float | None, float | None]:
25
+ try:
26
+ result = google_services.geocode_address(address)
27
+ if result:
28
+ return result[0], result[1]
29
+ except Exception:
30
+ pass
31
+ return None, None
32
+
33
+
34
+ @router.get("")
35
+ def list_patients(session: dict = Depends(get_current_session)):
36
+ return data_manager.get_all_patients()
37
+
38
+
39
+ @router.get("/{patient_id}")
40
+ def get_patient(patient_id: str, session: dict = Depends(get_current_session)):
41
+ p = data_manager.get_patient_by_id(patient_id)
42
+ if not p:
43
+ raise HTTPException(status_code=404, detail="Patient not found")
44
+ return p
45
+
46
+
47
+ @router.post("")
48
+ def create_patient(req: PatientCreate, session: dict = Depends(require_admin_session)):
49
+ if not req.name:
50
+ raise HTTPException(status_code=400, detail="Patient name is required")
51
+ lat, lng = _geocode(req.address) if req.address else (None, None)
52
+ patient_data = {
53
+ "name": req.name,
54
+ "email": req.email,
55
+ "address": req.address,
56
+ "notes": req.notes,
57
+ "lat": lat,
58
+ "lng": lng,
59
+ }
60
+ new_patient = data_manager.add_patient(patient_data)
61
+ data_manager.log_action(
62
+ session["current_user"], "ADD_PATIENT",
63
+ f"Added patient: {req.name} ({new_patient['id']})",
64
+ )
65
+ return new_patient
66
+
67
+
68
+ @router.put("/{patient_id}")
69
+ def update_patient(patient_id: str, req: PatientUpdate, session: dict = Depends(require_admin_session)):
70
+ existing = data_manager.get_patient_by_id(patient_id)
71
+ if not existing:
72
+ raise HTTPException(status_code=404, detail="Patient not found")
73
+ updates: dict = {}
74
+ if req.name is not None:
75
+ updates["name"] = req.name
76
+ if req.email is not None:
77
+ updates["email"] = req.email
78
+ if req.notes is not None:
79
+ updates["notes"] = req.notes
80
+ if req.address is not None:
81
+ updates["address"] = req.address
82
+ if req.address != existing.get("address"):
83
+ lat, lng = _geocode(req.address) if req.address else (None, None)
84
+ updates["lat"] = lat
85
+ updates["lng"] = lng
86
+ updated = data_manager.update_patient(patient_id, updates)
87
+ data_manager.log_action(
88
+ session["current_user"], "UPDATE_PATIENT",
89
+ f"Updated patient: {updated['name']} ({patient_id})",
90
+ )
91
+ return updated
92
+
93
+
94
+ @router.delete("/{patient_id}")
95
+ def delete_patient(patient_id: str, session: dict = Depends(require_admin_session)):
96
+ p = data_manager.get_patient_by_id(patient_id)
97
+ if not p:
98
+ raise HTTPException(status_code=404, detail="Patient not found")
99
+ ok = data_manager.delete_patient(patient_id)
100
+ if not ok:
101
+ raise HTTPException(status_code=500, detail="Delete failed")
102
+ data_manager.log_action(
103
+ session["current_user"], "DELETE_PATIENT",
104
+ f"Deleted patient: {p['name']} ({patient_id})",
105
+ )
106
+ return {"ok": True}
api/providers.py ADDED
@@ -0,0 +1,309 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Provider CRUD routes."""
2
+ from datetime import date as date_cls
3
+ from fastapi import APIRouter, Depends, HTTPException
4
+ from pydantic import BaseModel
5
+ from typing import Any
6
+ from core import data_manager
7
+ from core.scheduling_engine import normalize_time_str
8
+ from api.deps import get_current_session, require_admin_session
9
+
10
+ router = APIRouter(prefix="/api/providers", tags=["providers"])
11
+
12
+
13
+ # --- Pydantic models ---
14
+
15
+ class WorkHours(BaseModel):
16
+ start: str
17
+ end: str
18
+
19
+
20
+ class ProviderCreate(BaseModel):
21
+ name: str
22
+ email: str = ""
23
+ password: str = ""
24
+ address: str = ""
25
+ category: str = "provider" # provider | nurse
26
+ work_hours: WorkHours
27
+ lunch_break: WorkHours
28
+ service_levels: list[Any] = []
29
+
30
+
31
+ class ProviderUpdate(BaseModel):
32
+ name: str | None = None
33
+ email: str | None = None
34
+ password: str | None = None
35
+ address: str | None = None
36
+ category: str | None = None
37
+ work_hours: WorkHours | None = None
38
+ lunch_break: WorkHours | None = None
39
+ service_levels: list[Any] | None = None
40
+
41
+
42
+ class RecurringDaysOff(BaseModel):
43
+ recurring_days_off: list[str]
44
+
45
+
46
+ class DaysOffEntry(BaseModel):
47
+ date: str
48
+ all_day: bool = True
49
+ start_time: str | None = None
50
+ end_time: str | None = None
51
+ title: str = "Unavailable"
52
+
53
+
54
+ class OnCallEntry(BaseModel):
55
+ date: str
56
+
57
+
58
+ def _normalize_hours(work_hours: WorkHours, lunch: WorkHours) -> tuple[dict, dict]:
59
+ fields = {
60
+ "Work Hours Start": work_hours.start,
61
+ "Work Hours End": work_hours.end,
62
+ "Lunch Start": lunch.start,
63
+ "Lunch End": lunch.end,
64
+ }
65
+ out = {}
66
+ for label, value in fields.items():
67
+ n = normalize_time_str(value)
68
+ if n is None:
69
+ raise HTTPException(status_code=400, detail=f"{label} is not a valid time (HH:MM): {value!r}")
70
+ out[label] = n
71
+ return (
72
+ {"start": out["Work Hours Start"], "end": out["Work Hours End"]},
73
+ {"start": out["Lunch Start"], "end": out["Lunch End"]},
74
+ )
75
+
76
+
77
+ @router.get("")
78
+ def list_providers(active_only: bool = False, session: dict = Depends(get_current_session)):
79
+ return data_manager.get_all_providers(active_only=active_only)
80
+
81
+
82
+ @router.get("/{provider_id}")
83
+ def get_provider(provider_id: str, session: dict = Depends(get_current_session)):
84
+ p = data_manager.get_provider_by_id(provider_id)
85
+ if not p:
86
+ raise HTTPException(status_code=404, detail="Provider not found")
87
+ # Provider users can only access their own record
88
+ if session.get("user_role") == "provider" and session.get("provider_id") != provider_id:
89
+ raise HTTPException(status_code=403, detail="Forbidden")
90
+ return p
91
+
92
+
93
+ @router.post("")
94
+ def create_provider(req: ProviderCreate, session: dict = Depends(require_admin_session)):
95
+ if not req.name:
96
+ raise HTTPException(status_code=400, detail="Provider name is required")
97
+ wh, lb = _normalize_hours(req.work_hours, req.lunch_break)
98
+ provider_data = {
99
+ "name": req.name,
100
+ "email": req.email,
101
+ "password": req.password,
102
+ "address": req.address,
103
+ "category": req.category,
104
+ "work_hours": wh,
105
+ "lunch_break": lb,
106
+ "service_levels": req.service_levels,
107
+ }
108
+ new_prov = data_manager.add_provider(provider_data)
109
+ data_manager.log_action(
110
+ session["current_user"], "ADD_PROVIDER",
111
+ f"Added provider: {req.name} ({new_prov['id']})",
112
+ )
113
+ return new_prov
114
+
115
+
116
+ @router.put("/{provider_id}")
117
+ def update_provider(provider_id: str, req: ProviderUpdate, session: dict = Depends(get_current_session)):
118
+ # Providers may only edit themselves
119
+ if session.get("user_role") == "provider" and session.get("provider_id") != provider_id:
120
+ raise HTTPException(status_code=403, detail="Forbidden")
121
+
122
+ existing = data_manager.get_provider_by_id(provider_id)
123
+ if not existing:
124
+ raise HTTPException(status_code=404, detail="Provider not found")
125
+
126
+ updates: dict = {}
127
+ if req.name is not None:
128
+ updates["name"] = req.name
129
+ if req.email is not None:
130
+ updates["email"] = req.email
131
+ if req.password is not None:
132
+ updates["password"] = req.password
133
+ if req.address is not None:
134
+ updates["address"] = req.address
135
+ if req.category is not None:
136
+ updates["category"] = req.category
137
+ if req.service_levels is not None:
138
+ updates["service_levels"] = req.service_levels
139
+ if req.work_hours is not None and req.lunch_break is not None:
140
+ wh, lb = _normalize_hours(req.work_hours, req.lunch_break)
141
+ updates["work_hours"] = wh
142
+ updates["lunch_break"] = lb
143
+ elif req.work_hours is not None:
144
+ # normalize alone
145
+ s = normalize_time_str(req.work_hours.start)
146
+ e = normalize_time_str(req.work_hours.end)
147
+ if not s or not e:
148
+ raise HTTPException(status_code=400, detail="Invalid work hours")
149
+ updates["work_hours"] = {"start": s, "end": e}
150
+ elif req.lunch_break is not None:
151
+ s = normalize_time_str(req.lunch_break.start)
152
+ e = normalize_time_str(req.lunch_break.end)
153
+ if not s or not e:
154
+ raise HTTPException(status_code=400, detail="Invalid lunch break")
155
+ updates["lunch_break"] = {"start": s, "end": e}
156
+
157
+ updated = data_manager.update_provider(provider_id, updates)
158
+ data_manager.log_action(
159
+ session["current_user"], "UPDATE_PROVIDER",
160
+ f"Updated provider: {updated['name']} ({provider_id})",
161
+ )
162
+ return updated
163
+
164
+
165
+ @router.delete("/{provider_id}")
166
+ def delete_provider(provider_id: str, session: dict = Depends(require_admin_session)):
167
+ p = data_manager.get_provider_by_id(provider_id)
168
+ if not p:
169
+ raise HTTPException(status_code=404, detail="Provider not found")
170
+ ok = data_manager.delete_provider(provider_id)
171
+ if not ok:
172
+ raise HTTPException(status_code=500, detail="Delete failed")
173
+ data_manager.log_action(
174
+ session["current_user"], "DELETE_PROVIDER",
175
+ f"Deleted provider: {p['name']} ({provider_id})",
176
+ )
177
+ return {"ok": True}
178
+
179
+
180
+ # --- Recurring days off ---
181
+
182
+ @router.put("/{provider_id}/recurring-days-off")
183
+ def update_recurring_days_off(provider_id: str, req: RecurringDaysOff, session: dict = Depends(get_current_session)):
184
+ if session.get("user_role") == "provider" and session.get("provider_id") != provider_id:
185
+ raise HTTPException(status_code=403, detail="Forbidden")
186
+ p = data_manager.get_provider_by_id(provider_id)
187
+ if not p:
188
+ raise HTTPException(status_code=404, detail="Provider not found")
189
+ updated = data_manager.update_provider(provider_id, {"recurring_days_off": req.recurring_days_off})
190
+ data_manager.log_action(
191
+ session["current_user"], "UPDATE_PROVIDER",
192
+ f"Updated standing days off for {p['name']}: {', '.join(req.recurring_days_off) or 'None'}",
193
+ )
194
+ return updated
195
+
196
+
197
+ # --- Days off (unavailable blocks) ---
198
+
199
+ @router.post("/{provider_id}/days-off")
200
+ def add_days_off(provider_id: str, req: DaysOffEntry, session: dict = Depends(get_current_session)):
201
+ if session.get("user_role") == "provider" and session.get("provider_id") != provider_id:
202
+ raise HTTPException(status_code=403, detail="Forbidden")
203
+ p = data_manager.get_provider_by_id(provider_id)
204
+ if not p:
205
+ raise HTTPException(status_code=404, detail="Provider not found")
206
+
207
+ is_all_day = req.all_day
208
+ new_entry = {
209
+ "date": req.date,
210
+ "all_day": is_all_day,
211
+ "start_time": None if is_all_day else req.start_time,
212
+ "end_time": None if is_all_day else req.end_time,
213
+ "title": req.title or "Unavailable",
214
+ }
215
+ current = p.get("days_off", []) or []
216
+ current.append(new_entry)
217
+ updated = data_manager.update_provider(provider_id, {"days_off": current})
218
+ detail = f"all day on {req.date}" if is_all_day else f"{req.start_time}–{req.end_time} on {req.date}"
219
+ data_manager.log_action(
220
+ session["current_user"], "UPDATE_PROVIDER",
221
+ f"Added unavailable block for {p['name']}: {req.title} ({detail})",
222
+ )
223
+ return updated
224
+
225
+
226
+ @router.delete("/{provider_id}/days-off/{index}")
227
+ def remove_days_off(provider_id: str, index: int, session: dict = Depends(get_current_session)):
228
+ if session.get("user_role") == "provider" and session.get("provider_id") != provider_id:
229
+ raise HTTPException(status_code=403, detail="Forbidden")
230
+ p = data_manager.get_provider_by_id(provider_id)
231
+ if not p:
232
+ raise HTTPException(status_code=404, detail="Provider not found")
233
+ current = p.get("days_off", []) or []
234
+ if index < 0 or index >= len(current):
235
+ raise HTTPException(status_code=400, detail="Invalid index")
236
+ removed = current.pop(index)
237
+ updated = data_manager.update_provider(provider_id, {"days_off": current})
238
+ label = removed if isinstance(removed, str) else f"{removed.get('date')} {removed.get('title','')}"
239
+ data_manager.log_action(
240
+ session["current_user"], "UPDATE_PROVIDER",
241
+ f"Removed unavailable block from {p['name']}: {label}",
242
+ )
243
+ return updated
244
+
245
+
246
+ # --- On-Call ---
247
+
248
+ @router.post("/{provider_id}/on-call")
249
+ def add_on_call(provider_id: str, req: OnCallEntry, session: dict = Depends(get_current_session)):
250
+ if session.get("user_role") == "provider" and session.get("provider_id") != provider_id:
251
+ raise HTTPException(status_code=403, detail="Forbidden")
252
+ p = data_manager.get_provider_by_id(provider_id)
253
+ if not p:
254
+ raise HTTPException(status_code=404, detail="Provider not found")
255
+ current = sorted(p.get("on_call", []) or [])
256
+ if req.date in current:
257
+ return {"ok": True, "duplicate": True, "provider": p}
258
+ current = sorted(current + [req.date])
259
+ updated = data_manager.update_provider(provider_id, {"on_call": current})
260
+ data_manager.log_action(
261
+ session["current_user"], "UPDATE_PROVIDER",
262
+ f"Added on-call day for {p['name']}: {req.date}",
263
+ )
264
+ return updated
265
+
266
+
267
+ @router.delete("/{provider_id}/on-call/{date}")
268
+ def remove_on_call(provider_id: str, date: str, session: dict = Depends(get_current_session)):
269
+ if session.get("user_role") == "provider" and session.get("provider_id") != provider_id:
270
+ raise HTTPException(status_code=403, detail="Forbidden")
271
+ p = data_manager.get_provider_by_id(provider_id)
272
+ if not p:
273
+ raise HTTPException(status_code=404, detail="Provider not found")
274
+ current = [d for d in (p.get("on_call", []) or []) if d != date]
275
+ updated = data_manager.update_provider(provider_id, {"on_call": current})
276
+ data_manager.log_action(
277
+ session["current_user"], "UPDATE_PROVIDER",
278
+ f"Removed on-call day for {p['name']}: {date}",
279
+ )
280
+ return updated
281
+
282
+
283
+ class OnCallToggle(BaseModel):
284
+ date: str
285
+ on_call: bool
286
+
287
+
288
+ @router.post("/{provider_id}/on-call/toggle")
289
+ def toggle_on_call(provider_id: str, req: OnCallToggle, session: dict = Depends(get_current_session)):
290
+ """Toggle on-call status for a specific date. Convenience endpoint for the UI switch."""
291
+ if session.get("user_role") == "provider" and session.get("provider_id") != provider_id:
292
+ raise HTTPException(status_code=403, detail="Forbidden")
293
+ p = data_manager.get_provider_by_id(provider_id)
294
+ if not p:
295
+ raise HTTPException(status_code=404, detail="Provider not found")
296
+ current = set(p.get("on_call", []) or [])
297
+ if req.on_call:
298
+ current.add(req.date)
299
+ action_word = "Enabled"
300
+ else:
301
+ current.discard(req.date)
302
+ action_word = "Disabled"
303
+ new_list = sorted(current)
304
+ updated = data_manager.update_provider(provider_id, {"on_call": new_list})
305
+ data_manager.log_action(
306
+ session["current_user"], "UPDATE_PROVIDER",
307
+ f"{action_word} on-call for {p['name']}: {req.date}",
308
+ )
309
+ return updated
api/schedule.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Schedule visit routes — find matches and book via LangGraph agent."""
2
+ import uuid
3
+ import threading
4
+ from fastapi import APIRouter, Depends, HTTPException
5
+ from pydantic import BaseModel
6
+ from typing import Any
7
+ from core import data_manager
8
+ from core import settings_manager as settings
9
+ from agent.graph import build_scheduling_graph
10
+ from api.deps import require_admin_session
11
+
12
+ router = APIRouter(prefix="/api/schedule", tags=["schedule"])
13
+
14
+ _graph = None
15
+ _graph_lock = threading.Lock()
16
+
17
+
18
+ def _get_graph():
19
+ global _graph
20
+ with _graph_lock:
21
+ if _graph is None:
22
+ _graph = build_scheduling_graph()
23
+ return _graph
24
+
25
+
26
+ # In-memory thread-id store for active scheduling sessions.
27
+ _threads: dict[str, dict] = {}
28
+
29
+
30
+ class FindMatchesRequest(BaseModel):
31
+ patient_id: str
32
+ visit_level: Any # int or "Nurse"
33
+ visit_address: str
34
+ preferred_dates: list[str]
35
+ preferred_time_window: dict # {"start": "09:00", "end": "12:00"}
36
+
37
+
38
+ class BookRequest(BaseModel):
39
+ thread_id: str
40
+ selection_index: int
41
+
42
+
43
+ @router.get("/visit-levels")
44
+ def visit_levels():
45
+ return [{"key": k, "label": v["label"], "duration_minutes": v["duration_minutes"]} for k, v in settings.get_visit_levels().items()]
46
+
47
+
48
+ @router.get("/config")
49
+ def schedule_config():
50
+ """Public scheduling config used by the date/time picker."""
51
+ return {
52
+ "min_lead_time_hours": settings.get_min_lead_time_hours(),
53
+ "timezone": settings.get_timezone(),
54
+ }
55
+
56
+
57
+ @router.post("/find-matches")
58
+ def find_matches(req: FindMatchesRequest, session: dict = Depends(require_admin_session)):
59
+ patient = data_manager.get_patient_by_id(req.patient_id)
60
+ if not patient:
61
+ raise HTTPException(status_code=404, detail="Patient not found")
62
+
63
+ # Convert visit_level to int if numeric string
64
+ vl: Any = req.visit_level
65
+ if isinstance(vl, str) and vl.isdigit():
66
+ vl = int(vl)
67
+
68
+ initial_state = {
69
+ "patient_id": req.patient_id,
70
+ "patient_name": patient["name"],
71
+ "patient_email": patient.get("email", ""),
72
+ "visit_level": vl,
73
+ "visit_address": req.visit_address,
74
+ "visit_lat": patient.get("lat"),
75
+ "visit_lng": patient.get("lng"),
76
+ "preferred_dates": req.preferred_dates,
77
+ "preferred_time_window": req.preferred_time_window,
78
+ "available_providers": [],
79
+ "travel_times": [],
80
+ "ranked_matches": [],
81
+ "user_selection": None,
82
+ "booked_appointment": None,
83
+ "notifications_sent": False,
84
+ "error": None,
85
+ }
86
+
87
+ thread_id = str(uuid.uuid4())
88
+ config = {"configurable": {"thread_id": thread_id}}
89
+ result = _get_graph().invoke(initial_state, config)
90
+
91
+ _threads[thread_id] = {"config": config, "ranked_matches": result.get("ranked_matches", [])}
92
+
93
+ if result.get("error"):
94
+ return {"thread_id": thread_id, "error": result["error"], "ranked_matches": []}
95
+ return {
96
+ "thread_id": thread_id,
97
+ "ranked_matches": result.get("ranked_matches", []),
98
+ "error": None,
99
+ }
100
+
101
+
102
+ @router.post("/book")
103
+ def book(req: BookRequest, session: dict = Depends(require_admin_session)):
104
+ record = _threads.get(req.thread_id)
105
+ if not record:
106
+ raise HTTPException(status_code=400, detail="Invalid or expired thread_id")
107
+ matches = record["ranked_matches"]
108
+ if req.selection_index < 0 or req.selection_index >= len(matches):
109
+ raise HTTPException(status_code=400, detail="Invalid selection_index")
110
+ match = matches[req.selection_index]
111
+ user_selection = {
112
+ "provider_id": match["provider_id"],
113
+ "provider_name": match["provider_name"],
114
+ "provider_email": match.get("provider_email", ""),
115
+ "date": match["date"],
116
+ "start_time": match["start_time"],
117
+ "end_time": match["end_time"],
118
+ "recommended_departure_time": match.get("recommended_departure_time", ""),
119
+ }
120
+ graph = _get_graph()
121
+ config = record["config"]
122
+ graph.update_state(config, {"user_selection": user_selection})
123
+ result = graph.invoke(None, config)
124
+
125
+ # Clean up thread
126
+ _threads.pop(req.thread_id, None)
127
+
128
+ if result.get("error"):
129
+ return {"error": result["error"]}
130
+ return {
131
+ "booked_appointment": result.get("booked_appointment"),
132
+ "notifications_sent": result.get("notifications_sent", False),
133
+ }
api/settings.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Settings routes — admin-editable runtime configuration."""
2
+ from fastapi import APIRouter, Depends, HTTPException
3
+ from pydantic import BaseModel
4
+ from typing import Any
5
+
6
+ from core import data_manager
7
+ from core import settings_manager as settings
8
+ from core.scheduling_engine import normalize_time_str
9
+ from api.deps import require_admin_session
10
+
11
+ router = APIRouter(prefix="/api/settings", tags=["settings"])
12
+
13
+
14
+ class SettingsUpdate(BaseModel):
15
+ min_lead_time_hours: int | None = None
16
+ buffer_minutes: int | None = None
17
+ visit_levels: dict | None = None
18
+ on_call_start_hour: str | None = None
19
+ region_name: str | None = None
20
+ timezone: str | None = None
21
+ service_area: str | None = None
22
+ openai_model: str | None = None
23
+
24
+
25
+ def _audit(user: str, detail: str) -> None:
26
+ data_manager.log_action(user, "UPDATE_SETTING", detail)
27
+
28
+
29
+ @router.get("")
30
+ def get_settings(session: dict = Depends(require_admin_session)):
31
+ """Return current effective values plus the per-key defaults."""
32
+ return {
33
+ "current": settings.get_all_settings(),
34
+ "defaults": settings.get_schema_defaults(),
35
+ }
36
+
37
+
38
+ @router.put("")
39
+ def update_settings(req: SettingsUpdate, session: dict = Depends(require_admin_session)):
40
+ current = settings.get_all_settings()
41
+ changes: list[str] = []
42
+
43
+ if req.min_lead_time_hours is not None and req.min_lead_time_hours != current["min_lead_time_hours"]:
44
+ if req.min_lead_time_hours < 0 or req.min_lead_time_hours > 168:
45
+ raise HTTPException(status_code=400, detail="min_lead_time_hours must be 0-168")
46
+ settings.set_setting("min_lead_time_hours", int(req.min_lead_time_hours))
47
+ changes.append(f"min_lead_time_hours: {current['min_lead_time_hours']} → {req.min_lead_time_hours}")
48
+
49
+ if req.buffer_minutes is not None and req.buffer_minutes != current["buffer_minutes"]:
50
+ if req.buffer_minutes < 0 or req.buffer_minutes > 120:
51
+ raise HTTPException(status_code=400, detail="buffer_minutes must be 0-120")
52
+ settings.set_setting("buffer_minutes", int(req.buffer_minutes))
53
+ changes.append(f"buffer_minutes: {current['buffer_minutes']} → {req.buffer_minutes}")
54
+
55
+ if req.on_call_start_hour is not None and req.on_call_start_hour != current["on_call_start_hour"]:
56
+ normalized = normalize_time_str(req.on_call_start_hour)
57
+ if normalized is None:
58
+ raise HTTPException(status_code=400, detail="on_call_start_hour must be HH:MM (e.g. 07:00)")
59
+ settings.set_setting("on_call_start_hour", normalized)
60
+ changes.append(f"on_call_start_hour: {current['on_call_start_hour']} → {normalized}")
61
+
62
+ if req.visit_levels is not None and req.visit_levels != current["visit_levels"]:
63
+ # Validate shape: each value must have 'label' and integer 'duration_minutes'.
64
+ for k, v in req.visit_levels.items():
65
+ if not isinstance(v, dict) or "duration_minutes" not in v:
66
+ raise HTTPException(status_code=400, detail=f"visit_levels[{k}] missing duration_minutes")
67
+ try:
68
+ int(v["duration_minutes"])
69
+ except Exception:
70
+ raise HTTPException(status_code=400, detail=f"visit_levels[{k}].duration_minutes not an integer")
71
+ # Re-key: convert digit-string keys back to ints so the format matches the schema.
72
+ normalized_levels = {
73
+ (int(k) if isinstance(k, str) and k.isdigit() else k): {
74
+ "label": v.get("label", str(k)),
75
+ "duration_minutes": int(v["duration_minutes"]),
76
+ }
77
+ for k, v in req.visit_levels.items()
78
+ }
79
+ settings.set_setting("visit_levels", normalized_levels)
80
+ changes.append("visit_levels updated")
81
+
82
+ if req.region_name is not None and req.region_name != current["region_name"]:
83
+ settings.set_setting("region_name", req.region_name)
84
+ changes.append(f"region_name: {current['region_name']} → {req.region_name}")
85
+
86
+ if req.timezone is not None and req.timezone != current["timezone"]:
87
+ settings.set_setting("timezone", req.timezone)
88
+ changes.append(f"timezone: {current['timezone']} → {req.timezone}")
89
+
90
+ if req.service_area is not None and req.service_area != current["service_area"]:
91
+ settings.set_setting("service_area", req.service_area)
92
+ changes.append(f"service_area: {current['service_area']} → {req.service_area}")
93
+
94
+ if req.openai_model is not None and req.openai_model != current["openai_model"]:
95
+ settings.set_setting("openai_model", req.openai_model)
96
+ changes.append(f"openai_model: {current['openai_model']} → {req.openai_model}")
97
+
98
+ if changes:
99
+ _audit(session["current_user"], "; ".join(changes))
100
+
101
+ return {"changes": changes, "current": settings.get_all_settings()}
102
+
103
+
104
+ @router.post("/reset")
105
+ def reset_to_defaults(session: dict = Depends(require_admin_session)):
106
+ defaults = settings.get_schema_defaults()
107
+ for key, default in defaults.items():
108
+ settings.set_setting(key, default)
109
+ _audit(session["current_user"], "Reset all settings to defaults")
110
+ return {"current": settings.get_all_settings()}
config.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Configuration — constants, business rules, and region-specific settings.
3
+ Change REGION_NAME, TIMEZONE, and SERVICE_AREA when forking for other regions.
4
+ """
5
+
6
+ # Region configuration — change these when forking for FL
7
+ REGION_NAME = "Chicago"
8
+ TIMEZONE = "America/Chicago"
9
+ SERVICE_AREA = "Chicago, IL metro area"
10
+
11
+ # Visit level definitions
12
+ VISIT_LEVELS = {
13
+ 1: {"label": "Level 1", "duration_minutes": 45},
14
+ 2: {"label": "Level 2", "duration_minutes": 60},
15
+ 3: {"label": "Level 3", "duration_minutes": 90},
16
+ "Nurse": {"label": "Nurse Visit", "duration_minutes": 60},
17
+ }
18
+
19
+ BUFFER_MINUTES = 15 # Buffer between visits (on top of travel time)
20
+ MIN_LEAD_TIME_HOURS = 24 # Minimum scheduling lead time
21
+ DEFAULT_WORK_HOURS = {"start": "08:00", "end": "17:00"}
22
+ DEFAULT_LUNCH_BREAK = {"start": "12:00", "end": "13:00"}
23
+
24
+ # Legacy JSON paths (kept for seed_sheets.py migration script)
25
+ DATA_DIR = "data"
26
+ PROVIDERS_FILE = "data/providers.json"
27
+ PATIENTS_FILE = "data/patients.json"
28
+ APPOINTMENTS_FILE = "data/appointments.json"
29
+ AUDIT_LOG_FILE = "data/audit_log.json"
30
+
31
+ # OpenAI
32
+ OPENAI_MODEL = "gpt-5.2"
33
+
34
+ # Organization
35
+ ORG_NAME = "SOS Care Now"
core/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """core package."""
core/data_manager.py ADDED
@@ -0,0 +1,379 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data Manager — CRUD operations backed by Google Sheets.
3
+ Refactored for FastAPI: module-level caching instead of streamlit.session_state.
4
+ """
5
+ import json
6
+ import base64
7
+ import os
8
+ import time
9
+ import threading
10
+ from datetime import datetime
11
+
12
+ import gspread
13
+ from google.oauth2 import service_account
14
+
15
+
16
+ def _get_secret(key: str) -> str:
17
+ return os.environ.get(key)
18
+
19
+
20
+ # --- Module-level caching ---
21
+ # RLock (reentrant): some helpers acquire the lock while calling another helper
22
+ # that also acquires it (e.g. _get_worksheet -> _get_spreadsheet). A plain Lock
23
+ # would deadlock.
24
+ _lock = threading.RLock()
25
+ _state: dict = {
26
+ "spreadsheet": None,
27
+ "worksheets": {},
28
+ "headers": {},
29
+ "read_cache": {}, # name -> (timestamp, list[dict])
30
+ }
31
+ _READ_TTL_SECONDS = 60
32
+
33
+
34
+ def _get_spreadsheet() -> gspread.Spreadsheet:
35
+ with _lock:
36
+ if _state["spreadsheet"] is None:
37
+ creds_b64 = _get_secret("GOOGLE_CALENDAR_CREDENTIALS")
38
+ creds_json = json.loads(base64.b64decode(creds_b64).decode("utf-8"))
39
+ credentials = service_account.Credentials.from_service_account_info(
40
+ creds_json,
41
+ scopes=[
42
+ "https://www.googleapis.com/auth/spreadsheets",
43
+ "https://www.googleapis.com/auth/drive",
44
+ ],
45
+ )
46
+ client = gspread.authorize(credentials)
47
+ sheet_id = _get_secret("GOOGLE_SHEET_ID")
48
+ _state["spreadsheet"] = client.open_by_key(sheet_id)
49
+ return _state["spreadsheet"]
50
+
51
+
52
+ def _get_worksheet(name: str) -> gspread.Worksheet:
53
+ with _lock:
54
+ if name not in _state["worksheets"]:
55
+ _state["worksheets"][name] = _get_spreadsheet().worksheet(name)
56
+ return _state["worksheets"][name]
57
+
58
+
59
+ _JSON_FIELDS = {
60
+ "work_hours", "lunch_break", "days_off", "recurring_days_off", "service_levels",
61
+ "on_call",
62
+ }
63
+
64
+
65
+ def _serialize_row(data: dict) -> dict:
66
+ row = {}
67
+ for k, v in data.items():
68
+ if k in _JSON_FIELDS:
69
+ row[k] = json.dumps(v)
70
+ elif v is None:
71
+ row[k] = ""
72
+ elif isinstance(v, bool):
73
+ row[k] = str(v)
74
+ else:
75
+ row[k] = str(v)
76
+ return row
77
+
78
+
79
+ def _deserialize_row(row: dict) -> dict:
80
+ data = {}
81
+ for k, v in row.items():
82
+ if k in _JSON_FIELDS:
83
+ try:
84
+ data[k] = json.loads(v) if v else []
85
+ except (json.JSONDecodeError, TypeError):
86
+ data[k] = []
87
+ elif k in ("lat", "lng"):
88
+ if v == "" or v is None:
89
+ data[k] = None
90
+ else:
91
+ try:
92
+ data[k] = float(v)
93
+ except (ValueError, TypeError):
94
+ data[k] = None
95
+ elif k == "active":
96
+ data[k] = v.lower() == "true" if isinstance(v, str) else bool(v)
97
+ elif k == "visit_level":
98
+ try:
99
+ data[k] = int(v)
100
+ except (ValueError, TypeError):
101
+ data[k] = v
102
+ else:
103
+ data[k] = v
104
+ return data
105
+
106
+
107
+ def _read_all(worksheet_name: str) -> list[dict]:
108
+ with _lock:
109
+ cached = _state["read_cache"].get(worksheet_name)
110
+ if cached and (time.time() - cached[0]) < _READ_TTL_SECONDS:
111
+ return list(cached[1])
112
+ ws = _get_worksheet(worksheet_name)
113
+ records = ws.get_all_records()
114
+ data = [_deserialize_row(r) for r in records]
115
+ with _lock:
116
+ _state["read_cache"][worksheet_name] = (time.time(), data)
117
+ return list(data)
118
+
119
+
120
+ def _invalidate_cache(worksheet_name: str) -> None:
121
+ with _lock:
122
+ _state["read_cache"].pop(worksheet_name, None)
123
+
124
+
125
+ def _get_headers(worksheet_name: str) -> list[str]:
126
+ with _lock:
127
+ if worksheet_name in _state["headers"]:
128
+ return list(_state["headers"][worksheet_name])
129
+ ws = _get_worksheet(worksheet_name)
130
+ headers = ws.row_values(1)
131
+ with _lock:
132
+ _state["headers"][worksheet_name] = headers
133
+ return list(headers)
134
+
135
+
136
+ def _col_letter(n: int) -> str:
137
+ result = ""
138
+ while n > 0:
139
+ n, remainder = divmod(n - 1, 26)
140
+ result = chr(65 + remainder) + result
141
+ return result
142
+
143
+
144
+ def _ensure_columns(worksheet_name: str, required: list[str]) -> list[str]:
145
+ headers = _get_headers(worksheet_name)
146
+ missing = [c for c in required if c not in headers]
147
+ if not missing:
148
+ return headers
149
+ ws = _get_worksheet(worksheet_name)
150
+ new_headers = headers + missing
151
+ end_col = _col_letter(len(new_headers))
152
+ ws.update(values=[new_headers], range_name=f"A1:{end_col}1", value_input_option="RAW")
153
+ with _lock:
154
+ _state["headers"][worksheet_name] = new_headers
155
+ _invalidate_cache(worksheet_name)
156
+ return new_headers
157
+
158
+
159
+ def _find_row_index(worksheet_name: str, id_column: str, id_value: str) -> int | None:
160
+ records = _read_all(worksheet_name)
161
+ for i, record in enumerate(records):
162
+ if record.get(id_column) == id_value:
163
+ return i + 2
164
+ return None
165
+
166
+
167
+ def _append_row(worksheet_name: str, data: dict) -> None:
168
+ ws = _get_worksheet(worksheet_name)
169
+ headers = _get_headers(worksheet_name)
170
+ row_values = [_serialize_row(data).get(h, "") for h in headers]
171
+ ws.append_row(row_values, value_input_option="RAW")
172
+ _invalidate_cache(worksheet_name)
173
+
174
+
175
+ def _update_row(worksheet_name: str, row_index: int, data: dict) -> None:
176
+ ws = _get_worksheet(worksheet_name)
177
+ headers = _get_headers(worksheet_name)
178
+ serialized = _serialize_row(data)
179
+ row_values = [serialized.get(h, "") for h in headers]
180
+ end_col = _col_letter(len(headers))
181
+ ws.update(values=[row_values], range_name=f"A{row_index}:{end_col}{row_index}", value_input_option="RAW")
182
+ _invalidate_cache(worksheet_name)
183
+
184
+
185
+ def _generate_id(prefix: str, existing_ids: list[str]) -> str:
186
+ if not existing_ids:
187
+ return f"{prefix}_001"
188
+ nums = []
189
+ for eid in existing_ids:
190
+ parts = eid.split("_")
191
+ if len(parts) >= 2:
192
+ try:
193
+ nums.append(int(parts[-1]))
194
+ except ValueError:
195
+ pass
196
+ next_num = max(nums, default=0) + 1
197
+ return f"{prefix}_{next_num:03d}"
198
+
199
+
200
+ # --- Providers ---
201
+
202
+ def get_all_providers(active_only: bool = True) -> list[dict]:
203
+ providers = _read_all("Providers")
204
+ if active_only:
205
+ return [p for p in providers if p.get("active", True)]
206
+ return providers
207
+
208
+
209
+ def get_provider_by_id(provider_id: str) -> dict | None:
210
+ for p in _read_all("Providers"):
211
+ if p.get("id") == provider_id:
212
+ return p
213
+ return None
214
+
215
+
216
+ def get_provider_by_name(name: str) -> dict | None:
217
+ for p in _read_all("Providers"):
218
+ if p.get("name", "").lower() == name.lower():
219
+ return p
220
+ return None
221
+
222
+
223
+ def add_provider(provider_data: dict) -> dict:
224
+ _ensure_columns("Providers", ["on_call"])
225
+ providers = _read_all("Providers")
226
+ existing_ids = [p["id"] for p in providers if "id" in p]
227
+ provider_data["id"] = _generate_id("prov", existing_ids)
228
+ provider_data.setdefault("active", True)
229
+ provider_data.setdefault("days_off", [])
230
+ provider_data.setdefault("recurring_days_off", [])
231
+ provider_data.setdefault("on_call", [])
232
+ provider_data.setdefault("lat", None)
233
+ provider_data.setdefault("lng", None)
234
+ _append_row("Providers", provider_data)
235
+ return provider_data
236
+
237
+
238
+ def update_provider(provider_id: str, updates: dict) -> dict | None:
239
+ if "on_call" in updates:
240
+ _ensure_columns("Providers", ["on_call"])
241
+ row_idx = _find_row_index("Providers", "id", provider_id)
242
+ if row_idx is None:
243
+ return None
244
+ providers = _read_all("Providers")
245
+ for p in providers:
246
+ if p.get("id") == provider_id:
247
+ p.update(updates)
248
+ _update_row("Providers", row_idx, p)
249
+ return p
250
+ return None
251
+
252
+
253
+ def delete_provider(provider_id: str) -> bool:
254
+ row_idx = _find_row_index("Providers", "id", provider_id)
255
+ if row_idx is None:
256
+ return False
257
+ ws = _get_worksheet("Providers")
258
+ ws.delete_rows(row_idx)
259
+ _invalidate_cache("Providers")
260
+ return True
261
+
262
+
263
+ # --- Patients ---
264
+
265
+ def get_all_patients() -> list[dict]:
266
+ return _read_all("Patients")
267
+
268
+
269
+ def get_patient_by_id(patient_id: str) -> dict | None:
270
+ for p in _read_all("Patients"):
271
+ if p.get("id") == patient_id:
272
+ return p
273
+ return None
274
+
275
+
276
+ def add_patient(patient_data: dict) -> dict:
277
+ patients = _read_all("Patients")
278
+ existing_ids = [p["id"] for p in patients if "id" in p]
279
+ patient_data["id"] = _generate_id("pat", existing_ids)
280
+ patient_data.setdefault("lat", None)
281
+ patient_data.setdefault("lng", None)
282
+ patient_data.setdefault("notes", "")
283
+ _append_row("Patients", patient_data)
284
+ return patient_data
285
+
286
+
287
+ def update_patient(patient_id: str, updates: dict) -> dict | None:
288
+ row_idx = _find_row_index("Patients", "id", patient_id)
289
+ if row_idx is None:
290
+ return None
291
+ patients = _read_all("Patients")
292
+ for p in patients:
293
+ if p.get("id") == patient_id:
294
+ p.update(updates)
295
+ _update_row("Patients", row_idx, p)
296
+ return p
297
+ return None
298
+
299
+
300
+ def delete_patient(patient_id: str) -> bool:
301
+ row_idx = _find_row_index("Patients", "id", patient_id)
302
+ if row_idx is None:
303
+ return False
304
+ ws = _get_worksheet("Patients")
305
+ ws.delete_rows(row_idx)
306
+ _invalidate_cache("Patients")
307
+ return True
308
+
309
+
310
+ # --- Appointments ---
311
+
312
+ def get_all_appointments(status: str | None = None) -> list[dict]:
313
+ appointments = _read_all("Appointments")
314
+ if status:
315
+ return [a for a in appointments if a.get("status") == status]
316
+ return appointments
317
+
318
+
319
+ def get_appointments_for_provider(provider_id: str, date: str | None = None) -> list[dict]:
320
+ appointments = _read_all("Appointments")
321
+ result = [a for a in appointments if a.get("provider_id") == provider_id and a.get("status") == "scheduled"]
322
+ if date:
323
+ result = [a for a in result if a.get("date") == date]
324
+ return result
325
+
326
+
327
+ def get_appointments_for_patient(patient_id: str) -> list[dict]:
328
+ appointments = _read_all("Appointments")
329
+ return [a for a in appointments if a.get("patient_id") == patient_id and a.get("status") == "scheduled"]
330
+
331
+
332
+ def add_appointment(appointment_data: dict) -> dict:
333
+ appointments = _read_all("Appointments")
334
+ existing_ids = [a["id"] for a in appointments if "id" in a]
335
+ appointment_data["id"] = _generate_id("appt", existing_ids)
336
+ appointment_data["created_at"] = datetime.now().isoformat()
337
+ appointment_data.setdefault("status", "scheduled")
338
+ _append_row("Appointments", appointment_data)
339
+ return appointment_data
340
+
341
+
342
+ def update_appointment(appointment_id: str, updates: dict) -> dict | None:
343
+ row_idx = _find_row_index("Appointments", "id", appointment_id)
344
+ if row_idx is None:
345
+ return None
346
+ appointments = _read_all("Appointments")
347
+ for a in appointments:
348
+ if a.get("id") == appointment_id:
349
+ a.update(updates)
350
+ _update_row("Appointments", row_idx, a)
351
+ return a
352
+ return None
353
+
354
+
355
+ def cancel_appointment(appointment_id: str) -> dict | None:
356
+ return update_appointment(appointment_id, {"status": "cancelled", "cancelled_at": datetime.now().isoformat()})
357
+
358
+
359
+ def complete_appointment(appointment_id: str) -> dict | None:
360
+ return update_appointment(appointment_id, {"status": "completed", "completed_at": datetime.now().isoformat()})
361
+
362
+
363
+ # --- Audit Log ---
364
+
365
+ def log_action(user: str, action: str, details: str, appointment_id: str | None = None) -> None:
366
+ entry = {
367
+ "timestamp": datetime.now().isoformat(),
368
+ "user": user,
369
+ "action": action,
370
+ "details": details,
371
+ "appointment_id": appointment_id or "",
372
+ }
373
+ _append_row("AuditLog", entry)
374
+
375
+
376
+ def get_audit_log(limit: int = 100) -> list[dict]:
377
+ log = _read_all("AuditLog")
378
+ log.sort(key=lambda x: x.get("timestamp", ""), reverse=True)
379
+ return log[:limit]
core/google_services.py ADDED
@@ -0,0 +1,484 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Google Services — Calendar, Maps, and Gmail integrations.
3
+ """
4
+ import os
5
+ import base64
6
+ import json
7
+ import math
8
+ import uuid
9
+ from datetime import datetime, timedelta
10
+ from email.mime.text import MIMEText
11
+ from email.mime.multipart import MIMEMultipart
12
+ from email.mime.base import MIMEBase
13
+ from email import encoders
14
+
15
+ from google.oauth2 import service_account
16
+ from google.oauth2.credentials import Credentials
17
+ from googleapiclient.discovery import build
18
+ import googlemaps
19
+
20
+ import config
21
+ from core import settings_manager as settings
22
+
23
+
24
+ def get_secret(key: str) -> str:
25
+ """Retrieve a secret from environment variables."""
26
+ return os.environ.get(key)
27
+
28
+
29
+ # ============================================================
30
+ # Google Calendar
31
+ # ============================================================
32
+
33
+ def get_calendar_service():
34
+ """Initialize and return a Google Calendar API service client.
35
+
36
+ Uses GOOGLE_CALENDAR_CREDENTIALS from secrets (base64-encoded service account JSON).
37
+ """
38
+ credentials_b64 = get_secret("GOOGLE_CALENDAR_CREDENTIALS")
39
+ credentials_json = base64.b64decode(credentials_b64).decode("utf-8")
40
+ credentials_info = json.loads(credentials_json)
41
+
42
+ credentials = service_account.Credentials.from_service_account_info(
43
+ credentials_info,
44
+ scopes=["https://www.googleapis.com/auth/calendar"]
45
+ )
46
+
47
+ service = build("calendar", "v3", credentials=credentials)
48
+ return service
49
+
50
+ def create_calendar_event(summary: str, description: str, location: str, start_datetime: str, end_datetime: str) -> str:
51
+ """Create an event on the shared Google Calendar.
52
+
53
+ Args:
54
+ summary: Event title, e.g., "[Level 1] Robert Williams — John Moustoukas, MD"
55
+ description: Event details (addresses, contact info)
56
+ location: Visit address
57
+ start_datetime: ISO format datetime string
58
+ end_datetime: ISO format datetime string
59
+
60
+ Returns:
61
+ The created event's Google Calendar event ID
62
+ """
63
+ service = get_calendar_service()
64
+ calendar_id = get_secret("SHARED_CALENDAR_ID")
65
+
66
+ event = {
67
+ "summary": summary,
68
+ "description": description,
69
+ "location": location,
70
+ "start": {
71
+ "dateTime": start_datetime,
72
+ "timeZone": settings.get_timezone(),
73
+ },
74
+ "end": {
75
+ "dateTime": end_datetime,
76
+ "timeZone": settings.get_timezone(),
77
+ },
78
+ }
79
+
80
+ created_event = service.events().insert(calendarId=calendar_id, body=event).execute()
81
+ return created_event["id"]
82
+
83
+ def delete_calendar_event(event_id: str) -> bool:
84
+ """Delete an event from the shared Google Calendar.
85
+
86
+ Returns True if successful.
87
+ """
88
+ try:
89
+ service = get_calendar_service()
90
+ calendar_id = get_secret("SHARED_CALENDAR_ID")
91
+ service.events().delete(calendarId=calendar_id, eventId=event_id).execute()
92
+ return True
93
+ except Exception:
94
+ return False
95
+
96
+ def get_calendar_events(date: str) -> list[dict]:
97
+ """Fetch all events from the shared calendar for a given date (YYYY-MM-DD).
98
+
99
+ Returns list of event dicts with 'summary', 'start', 'end', 'id'.
100
+ """
101
+ service = get_calendar_service()
102
+ calendar_id = get_secret("SHARED_CALENDAR_ID")
103
+
104
+ # Create time bounds for the given date
105
+ date_obj = datetime.strptime(date, "%Y-%m-%d")
106
+ time_min = date_obj.isoformat()+"Z"# + "T00:00:00Z" claude messed up here
107
+ time_max = (date_obj + timedelta(days=1)).isoformat()+"Z"# + "T00:00:00Z"
108
+ print(time_min, time_max)
109
+
110
+ events_result = service.events().list(
111
+ calendarId=calendar_id,
112
+ timeMin=time_min,
113
+ timeMax=time_max,
114
+ singleEvents=True,
115
+ orderBy="startTime"
116
+ ).execute()
117
+
118
+ events = events_result.get("items", [])
119
+
120
+ return [
121
+ {
122
+ "id": event["id"],
123
+ "summary": event.get("summary", ""),
124
+ "start": event["start"].get("dateTime", event["start"].get("date")),
125
+ "end": event["end"].get("dateTime", event["end"].get("date")),
126
+ }
127
+ for event in events
128
+ ]
129
+
130
+
131
+ # ============================================================
132
+ # Google Maps
133
+ # ============================================================
134
+
135
+ def get_maps_client():
136
+ """Initialize and return a Google Maps client.
137
+
138
+ Uses GOOGLE_MAPS_API_KEY from secrets.
139
+ """
140
+ return googlemaps.Client(key=get_secret("GOOGLE_MAPS_API_KEY"))
141
+
142
+ def calculate_travel_time(origin_address: str, destination_address: str, arrival_by: datetime) -> dict:
143
+ """Calculate driving travel time between two addresses using traffic-aware two-pass estimation.
144
+
145
+ Pass 1: Get rough travel duration (no departure_time).
146
+ Pass 2: Estimate departure = arrival_by - rough duration, then re-query with departure_time
147
+ to get duration_in_traffic.
148
+ Final recommended departure = arrival_by - traffic-aware duration.
149
+
150
+ Args:
151
+ origin_address: Full address string
152
+ destination_address: Full address string
153
+ arrival_by: When the provider must arrive (appointment start datetime)
154
+
155
+ Returns:
156
+ Dict with 'travel_minutes' (int) and 'recommended_departure' (datetime).
157
+ Returns travel_minutes=0 if same address.
158
+ Returns travel_minutes=-1 if the API call fails.
159
+ """
160
+ if origin_address.strip().lower() == destination_address.strip().lower():
161
+ return {"travel_minutes": 0, "recommended_departure": arrival_by}
162
+
163
+ try:
164
+ client = get_maps_client()
165
+
166
+ # Pass 1: rough estimate (no traffic)
167
+ result = client.distance_matrix(
168
+ origins=[origin_address],
169
+ destinations=[destination_address],
170
+ mode="driving"
171
+ )
172
+ element = result["rows"][0]["elements"][0]
173
+ if element["status"] != "OK":
174
+ return {"travel_minutes": -1, "recommended_departure": arrival_by}
175
+
176
+ rough_seconds = element["duration"]["value"]
177
+ rough_minutes = math.ceil(rough_seconds / 60)
178
+ rough_departure = arrival_by - timedelta(minutes=rough_minutes)
179
+
180
+ # Pass 2: traffic-aware estimate using rough departure time
181
+ result = client.distance_matrix(
182
+ origins=[origin_address],
183
+ destinations=[destination_address],
184
+ mode="driving",
185
+ departure_time=rough_departure
186
+ )
187
+ element = result["rows"][0]["elements"][0]
188
+ if element["status"] != "OK":
189
+ return {"travel_minutes": rough_minutes, "recommended_departure": arrival_by - timedelta(minutes=rough_minutes)}
190
+
191
+ traffic_seconds = element.get("duration_in_traffic", element["duration"])["value"]
192
+ traffic_minutes = math.ceil(traffic_seconds / 60)
193
+ recommended_departure = arrival_by - timedelta(minutes=traffic_minutes)
194
+
195
+ return {"travel_minutes": traffic_minutes, "recommended_departure": recommended_departure}
196
+ except Exception:
197
+ return {"travel_minutes": -1, "recommended_departure": arrival_by}
198
+
199
+ def geocode_address(address: str) -> tuple[float, float] | None:
200
+ """Convert an address string to (latitude, longitude).
201
+
202
+ Returns None if geocoding fails.
203
+ """
204
+ try:
205
+ client = get_maps_client()
206
+ result = client.geocode(address)
207
+
208
+ if not result:
209
+ return None
210
+
211
+ location = result[0]["geometry"]["location"]
212
+ return (location["lat"], location["lng"])
213
+ except Exception:
214
+ return None
215
+
216
+ def calculate_travel_times_batch(origin: str, destinations: list[str], arrival_by: datetime) -> list[dict]:
217
+ """Calculate travel times from one origin to multiple destinations using traffic-aware two-pass estimation.
218
+
219
+ Pass 1: Get rough durations for all destinations (no departure_time).
220
+ Pass 2: Use the max rough duration to compute a single departure_time for the batch,
221
+ then re-query to get duration_in_traffic per destination.
222
+
223
+ Returns a list of dicts with 'travel_minutes' and 'recommended_departure', in the same order as destinations.
224
+ """
225
+ if not destinations:
226
+ return []
227
+
228
+ try:
229
+ client = get_maps_client()
230
+
231
+ # Pass 1: rough estimates (no traffic)
232
+ result = client.distance_matrix(
233
+ origins=[origin],
234
+ destinations=destinations,
235
+ mode="driving"
236
+ )
237
+
238
+ rough_seconds_list = []
239
+ for element in result["rows"][0]["elements"]:
240
+ if element["status"] == "OK":
241
+ rough_seconds_list.append(element["duration"]["value"])
242
+ else:
243
+ rough_seconds_list.append(-1)
244
+
245
+ # Use max rough duration to compute a single departure_time for the batch
246
+ valid_seconds = [s for s in rough_seconds_list if s >= 0]
247
+ if not valid_seconds:
248
+ return [{"travel_minutes": -1, "recommended_departure": arrival_by} for _ in destinations]
249
+
250
+ max_rough_minutes = math.ceil(max(valid_seconds) / 60)
251
+ rough_departure = arrival_by - timedelta(minutes=max_rough_minutes)
252
+
253
+ # Pass 2: traffic-aware estimates
254
+ result = client.distance_matrix(
255
+ origins=[origin],
256
+ destinations=destinations,
257
+ mode="driving",
258
+ departure_time=rough_departure
259
+ )
260
+
261
+ travel_results = []
262
+ for element in result["rows"][0]["elements"]:
263
+ if element["status"] == "OK":
264
+ traffic_seconds = element.get("duration_in_traffic", element["duration"])["value"]
265
+ traffic_minutes = math.ceil(traffic_seconds / 60)
266
+ travel_results.append({
267
+ "travel_minutes": traffic_minutes,
268
+ "recommended_departure": arrival_by - timedelta(minutes=traffic_minutes),
269
+ })
270
+ else:
271
+ travel_results.append({"travel_minutes": -1, "recommended_departure": arrival_by})
272
+
273
+ return travel_results
274
+ except Exception:
275
+ return [{"travel_minutes": -1, "recommended_departure": arrival_by} for _ in destinations]
276
+
277
+
278
+ # ============================================================
279
+ # Gmail
280
+ # ============================================================
281
+
282
+ def _build_ics(appointment_details: dict, method: str = "REQUEST") -> str:
283
+ """Build an .ics calendar string from appointment details.
284
+
285
+ Args:
286
+ appointment_details: Dict with date, start_time, end_time, patient_name,
287
+ provider_name, address.
288
+ method: VCALENDAR METHOD — "REQUEST" for new/updated, "CANCEL" for cancellations.
289
+ """
290
+ appt_date = appointment_details.get("date", "")
291
+ start_time = appointment_details.get("start_time", "00:00")
292
+ end_time = appointment_details.get("end_time", "00:00")
293
+ patient_name = appointment_details.get("patient_name", "")
294
+ provider_name = appointment_details.get("provider_name", "")
295
+ address = appointment_details.get("address", "")
296
+
297
+ dt_start = datetime.strptime(f"{appt_date} {start_time}", "%Y-%m-%d %H:%M")
298
+ dt_end = datetime.strptime(f"{appt_date} {end_time}", "%Y-%m-%d %H:%M")
299
+ now = datetime.utcnow()
300
+
301
+ uid = uuid.uuid5(uuid.NAMESPACE_URL, f"{appt_date}-{start_time}-{patient_name}-{provider_name}")
302
+
303
+ return (
304
+ "BEGIN:VCALENDAR\r\n"
305
+ "VERSION:2.0\r\n"
306
+ "PRODID:-//SOS Care Now//Scheduling//EN\r\n"
307
+ f"METHOD:{method}\r\n"
308
+ "BEGIN:VEVENT\r\n"
309
+ f"UID:{uid}\r\n"
310
+ f"DTSTAMP:{now:%Y%m%dT%H%M%SZ}\r\n"
311
+ f"DTSTART;TZID={settings.get_timezone()}:{dt_start:%Y%m%dT%H%M%S}\r\n"
312
+ f"DTEND;TZID={settings.get_timezone()}:{dt_end:%Y%m%dT%H%M%S}\r\n"
313
+ f"SUMMARY:{config.ORG_NAME} — {patient_name} with {provider_name}\r\n"
314
+ f"LOCATION:{address}\r\n"
315
+ f"STATUS:{'CANCELLED' if method == 'CANCEL' else 'CONFIRMED'}\r\n"
316
+ "END:VEVENT\r\n"
317
+ "END:VCALENDAR\r\n"
318
+ )
319
+
320
+
321
+ def get_gmail_service():
322
+ """Initialize and return a Gmail API service client.
323
+
324
+ Uses GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET, and GMAIL_REFRESH_TOKEN from
325
+ secrets. The refresh token is exchanged for a short-lived access token by
326
+ the underlying google-auth library on each request.
327
+ """
328
+ creds = Credentials.from_authorized_user_info(
329
+ {
330
+ "client_id": get_secret("GMAIL_CLIENT_ID"),
331
+ "client_secret": get_secret("GMAIL_CLIENT_SECRET"),
332
+ "refresh_token": get_secret("GMAIL_REFRESH_TOKEN"),
333
+ },
334
+ scopes=["https://www.googleapis.com/auth/gmail.send"],
335
+ )
336
+ return build("gmail", "v1", credentials=creds, cache_discovery=False)
337
+
338
+
339
+ def send_email(to_address: str, subject: str, body: str, ics_content: str | None = None) -> bool:
340
+ """Send an email via the Gmail API.
341
+
342
+ Uses GMAIL_ADDRESS as the From header and the OAuth credentials in
343
+ GMAIL_CLIENT_ID / GMAIL_CLIENT_SECRET / GMAIL_REFRESH_TOKEN.
344
+ If ics_content is provided, attaches it as a .ics calendar invite.
345
+
346
+ Returns True if sent successfully.
347
+ """
348
+ try:
349
+ gmail_address = get_secret("GMAIL_ADDRESS")
350
+
351
+ message = MIMEMultipart()
352
+ message["From"] = gmail_address
353
+ message["To"] = to_address
354
+ message["Subject"] = subject
355
+ message.attach(MIMEText(body, "plain"))
356
+
357
+ if ics_content:
358
+ ics_part = MIMEBase("text", "calendar", method="REQUEST", name="invite.ics")
359
+ ics_part.set_payload(ics_content.encode("utf-8"))
360
+ encoders.encode_base64(ics_part)
361
+ ics_part.add_header("Content-Disposition", "attachment", filename="invite.ics")
362
+ message.attach(ics_part)
363
+
364
+ raw = base64.urlsafe_b64encode(message.as_bytes()).decode("utf-8")
365
+ service = get_gmail_service()
366
+ service.users().messages().send(userId="me", body={"raw": raw}).execute()
367
+
368
+ return True
369
+ except Exception:
370
+ return False
371
+
372
+ def send_scheduling_notification(provider_email: str, patient_email: str, appointment_details: dict) -> dict:
373
+ """Send scheduling confirmation emails to both the provider and patient.
374
+
375
+ Args:
376
+ provider_email: Provider's email
377
+ patient_email: Patient's email
378
+ appointment_details: Dict with keys: patient_name, provider_name, date, start_time, end_time, visit_level, address
379
+
380
+ Returns:
381
+ Dict with 'provider_sent': bool, 'patient_sent': bool
382
+ """
383
+ patient_name = appointment_details.get("patient_name", "Patient")
384
+ provider_name = appointment_details.get("provider_name", "Provider")
385
+ date = appointment_details.get("date", "")
386
+ start_time = appointment_details.get("start_time", "")
387
+ end_time = appointment_details.get("end_time", "")
388
+ visit_level = appointment_details.get("visit_level", "")
389
+ address = appointment_details.get("address", "")
390
+ recommended_departure = appointment_details.get("recommended_departure_time", "")
391
+
392
+ # Email to provider
393
+ provider_subject = f"{config.ORG_NAME} - New Appointment Scheduled"
394
+ departure_line = f"\nRecommended Departure Time: {recommended_departure}" if recommended_departure else ""
395
+ provider_body = f"""Hello {provider_name},
396
+
397
+ A new appointment has been scheduled:
398
+
399
+ Patient: {patient_name}
400
+ Date: {date}
401
+ Time: {start_time} - {end_time}
402
+ Visit Level: {visit_level}
403
+ Address: {address}{departure_line}
404
+
405
+ Please ensure you arrive on time.
406
+
407
+ Best regards,
408
+ {config.ORG_NAME}
409
+ """
410
+
411
+ # Email to patient
412
+ patient_subject = f"{config.ORG_NAME} - Appointment Confirmation"
413
+ patient_body = f"""Hello {patient_name},
414
+
415
+ Your appointment has been confirmed:
416
+
417
+ Provider: {provider_name}
418
+ Date: {date}
419
+ Time: {start_time} - {end_time}
420
+
421
+ Your provider will visit you at:
422
+ {address}
423
+
424
+ If you need to reschedule or cancel, please contact us as soon as possible.
425
+
426
+ Best regards,
427
+ {config.ORG_NAME}
428
+ """
429
+
430
+ ics = _build_ics(appointment_details)
431
+ provider_sent = send_email(provider_email, provider_subject, provider_body, ics) if provider_email else False
432
+ patient_sent = send_email(patient_email, patient_subject, patient_body, ics) if patient_email else False
433
+
434
+ return {"provider_sent": provider_sent, "patient_sent": patient_sent}
435
+
436
+ def send_cancellation_notification(provider_email: str, patient_email: str, appointment_details: dict) -> dict:
437
+ """Send cancellation notification emails to both the provider and patient.
438
+
439
+ Returns:
440
+ Dict with 'provider_sent': bool, 'patient_sent': bool
441
+ """
442
+ patient_name = appointment_details.get("patient_name", "Patient")
443
+ provider_name = appointment_details.get("provider_name", "Provider")
444
+ date = appointment_details.get("date", "")
445
+ start_time = appointment_details.get("start_time", "")
446
+ end_time = appointment_details.get("end_time", "")
447
+
448
+ # Email to provider
449
+ provider_subject = f"{config.ORG_NAME} - Appointment Cancelled"
450
+ provider_body = f"""Hello {provider_name},
451
+
452
+ The following appointment has been cancelled:
453
+
454
+ Patient: {patient_name}
455
+ Date: {date}
456
+ Time: {start_time} - {end_time}
457
+
458
+ This time slot is now available for other appointments.
459
+
460
+ Best regards,
461
+ {config.ORG_NAME}
462
+ """
463
+
464
+ # Email to patient
465
+ patient_subject = f"{config.ORG_NAME} - Appointment Cancellation Confirmation"
466
+ patient_body = f"""Hello {patient_name},
467
+
468
+ Your appointment has been cancelled:
469
+
470
+ Provider: {provider_name}
471
+ Date: {date}
472
+ Time: {start_time} - {end_time}
473
+
474
+ If you would like to reschedule, please contact us.
475
+
476
+ Best regards,
477
+ {config.ORG_NAME}
478
+ """
479
+
480
+ ics = _build_ics(appointment_details, method="CANCEL")
481
+ provider_sent = send_email(provider_email, provider_subject, provider_body, ics) if provider_email else False
482
+ patient_sent = send_email(patient_email, patient_subject, patient_body, ics) if patient_email else False
483
+
484
+ return {"provider_sent": provider_sent, "patient_sent": patient_sent}
core/scheduling_engine.py ADDED
@@ -0,0 +1,249 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Scheduling Engine — Deterministic logic for availability checking,
3
+ overlap prevention, and provider ranking.
4
+
5
+ This module contains NO LLM calls. All logic is pure Python.
6
+ """
7
+ from datetime import datetime, timedelta
8
+ from zoneinfo import ZoneInfo
9
+ from config import DEFAULT_WORK_HOURS, DEFAULT_LUNCH_BREAK
10
+ from core import settings_manager as settings
11
+
12
+
13
+ # --- Time utility helpers ---
14
+
15
+ def parse_time(time_str: str) -> datetime:
16
+ """Parse a time string to a datetime (date portion ignored).
17
+
18
+ Accepts 'HH:MM', 'H:MM', or 'HHMM' (e.g. '0700').
19
+ """
20
+ s = str(time_str).strip()
21
+ for fmt in ("%H:%M", "%H%M"):
22
+ try:
23
+ return datetime.strptime(s, fmt)
24
+ except ValueError:
25
+ continue
26
+ raise ValueError(f"Unrecognized time format: {time_str!r}")
27
+
28
+
29
+ def normalize_time_str(time_str: str) -> str | None:
30
+ """Normalize a user-entered time string to 'HH:MM'. Returns None if invalid."""
31
+ if time_str is None:
32
+ return None
33
+ try:
34
+ return parse_time(time_str).strftime("%H:%M")
35
+ except ValueError:
36
+ return None
37
+
38
+ def times_overlap(start1: str, end1: str, start2: str, end2: str) -> bool:
39
+ """Check if two time ranges overlap. All args in 'HH:MM' format."""
40
+ s1, e1 = parse_time(start1), parse_time(end1)
41
+ s2, e2 = parse_time(start2), parse_time(end2)
42
+ return s1 < e2 and s2 < e1
43
+
44
+ def add_minutes(time_str: str, minutes: int) -> str:
45
+ """Add minutes to a time string. Returns 'HH:MM'."""
46
+ result = parse_time(time_str) + timedelta(minutes=minutes)
47
+ return result.strftime("%H:%M")
48
+
49
+
50
+ # --- Visit duration helpers ---
51
+
52
+ def get_visit_duration(visit_level: int | str) -> int:
53
+ """Return the duration in minutes for a given visit level."""
54
+ return settings.get_visit_levels()[visit_level]["duration_minutes"]
55
+
56
+ def get_total_block_duration(visit_level: int | str) -> int:
57
+ """Return visit duration + buffer in minutes."""
58
+ return get_visit_duration(visit_level) + settings.get_buffer_minutes()
59
+
60
+
61
+ # --- Availability checks ---
62
+
63
+ def is_within_work_hours(provider: dict, start_time: str, end_time: str) -> bool:
64
+ """Check if a proposed time slot falls within the provider's work hours.
65
+
66
+ Args:
67
+ provider: Provider dict with 'work_hours' field
68
+ start_time: "HH:MM" format
69
+ end_time: "HH:MM" format
70
+ """
71
+ work = provider.get("work_hours", DEFAULT_WORK_HOURS)
72
+ return (
73
+ parse_time(start_time) >= parse_time(work["start"])
74
+ and parse_time(end_time) <= parse_time(work["end"])
75
+ )
76
+
77
+ def conflicts_with_lunch(provider: dict, start_time: str, end_time: str) -> bool:
78
+ """Check if a proposed time slot overlaps with the provider's lunch break.
79
+
80
+ An empty or missing lunch break (e.g. {"start": "", "end": ""}) is treated
81
+ as "no lunch break" — returns False.
82
+ """
83
+ lunch = provider.get("lunch_break") or DEFAULT_LUNCH_BREAK
84
+ lunch_start = normalize_time_str(lunch.get("start"))
85
+ lunch_end = normalize_time_str(lunch.get("end"))
86
+ if not lunch_start or not lunch_end:
87
+ return False
88
+ return times_overlap(start_time, end_time, lunch_start, lunch_end)
89
+
90
+ def has_overlap(existing_appointments: list[dict], proposed_start: str, proposed_end: str, travel_time_to: int = 0, travel_time_from: int = 0) -> bool:
91
+ """Check if a proposed slot overlaps with any existing appointment, accounting for buffer and travel times.
92
+
93
+ For each existing appointment, the blocked window is:
94
+ [appt_start - travel_time_to, appt_end + BUFFER_MINUTES + travel_time_from]
95
+
96
+ Args:
97
+ existing_appointments: List of appointment dicts for the provider on that date
98
+ proposed_start: "HH:MM" format
99
+ proposed_end: "HH:MM" format
100
+ travel_time_to: Minutes of travel to the existing appointment
101
+ travel_time_from: Minutes of travel from the existing appointment
102
+ """
103
+ buffer = settings.get_buffer_minutes()
104
+ for appt in existing_appointments:
105
+ blocked_start = add_minutes(appt["start_time"], -travel_time_to)
106
+ blocked_end = add_minutes(appt["end_time"], buffer + travel_time_from)
107
+ if times_overlap(proposed_start, proposed_end, blocked_start, blocked_end):
108
+ return True
109
+ return False
110
+
111
+ def is_oncall_for_slot(provider: dict, date: str, start_time: str, end_time: str) -> bool:
112
+ """Return True if the slot [date+start_time, date+end_time] falls within one of
113
+ the provider's 24-hour on-call windows (07:00 of an on-call date → 07:00 the next day)."""
114
+ on_call_dates = provider.get("on_call", []) or []
115
+ if not on_call_dates:
116
+ return False
117
+ try:
118
+ slot_start = datetime.strptime(f"{date} {start_time}", "%Y-%m-%d %H:%M")
119
+ slot_end = datetime.strptime(f"{date} {end_time}", "%Y-%m-%d %H:%M")
120
+ except ValueError:
121
+ return False
122
+ on_call_start_hour = settings.get_on_call_start_hour()
123
+ for d in on_call_dates:
124
+ try:
125
+ window_start = datetime.strptime(f"{d} {on_call_start_hour}", "%Y-%m-%d %H:%M")
126
+ except ValueError:
127
+ continue
128
+ window_end = window_start + timedelta(hours=24)
129
+ if slot_start >= window_start and slot_end <= window_end:
130
+ return True
131
+ return False
132
+
133
+
134
+ def is_provider_available(provider: dict, date: str, start_time: str, visit_level: int | str, existing_appointments: list[dict]) -> bool:
135
+ """Master check: is this provider available for a visit at the given date/time?
136
+
137
+ Checks:
138
+ 1. Provider is active
139
+ 2. Date is not in provider's days_off
140
+ 3. Date's weekday is not in provider's recurring_days_off
141
+ 4. Provider supports the visit_level
142
+ 5. Slot is within work hours
143
+ 6. Slot doesn't conflict with lunch
144
+ 7. No overlap with existing appointments (including buffer)
145
+ 8. Meets 24-hour lead time requirement
146
+
147
+ Returns True if all checks pass.
148
+ """
149
+ # 1. Active
150
+ if not provider.get("active", True):
151
+ return False
152
+
153
+ duration = get_visit_duration(visit_level)
154
+ end_time = add_minutes(start_time, duration)
155
+
156
+ # On-call window supersedes days off, recurring days off, work hours,
157
+ # and lunch break — but NOT the lead-time rule, which always applies.
158
+ on_call = is_oncall_for_slot(provider, date, start_time, end_time)
159
+
160
+ if not on_call:
161
+ # 2. Specific date/time off
162
+ for off in provider.get("days_off", []):
163
+ if isinstance(off, str):
164
+ # Legacy format: plain date string = all day off
165
+ if date == off:
166
+ return False
167
+ elif isinstance(off, dict) and off.get("date") == date:
168
+ if off.get("all_day", True):
169
+ return False
170
+ # Specific time block off — check overlap
171
+ off_start = off.get("start_time")
172
+ off_end = off.get("end_time")
173
+ if off_start and off_end and times_overlap(start_time, end_time, off_start, off_end):
174
+ return False
175
+
176
+ # 3. Recurring day off (e.g., every Friday)
177
+ weekday = datetime.strptime(date, "%Y-%m-%d").strftime("%A")
178
+ if weekday in provider.get("recurring_days_off", []):
179
+ return False
180
+
181
+ # 4. Supports the requested visit level
182
+ if visit_level not in provider.get("service_levels", []):
183
+ return False
184
+
185
+ if not on_call:
186
+ # 5. Within work hours
187
+ if not is_within_work_hours(provider, start_time, end_time):
188
+ return False
189
+
190
+ # 6. No lunch conflict
191
+ if conflicts_with_lunch(provider, start_time, end_time):
192
+ return False
193
+
194
+ # 7. No overlap with existing appointments
195
+ if has_overlap(existing_appointments, start_time, end_time):
196
+ return False
197
+
198
+ # 8. Lead-time rule — always applies, even for on-call providers.
199
+ # Compare in the configured timezone so a UTC server doesn't mis-evaluate local times.
200
+ proposed_dt = datetime.strptime(f"{date} {start_time}", "%Y-%m-%d %H:%M")
201
+ now_local = datetime.now(ZoneInfo(settings.get_timezone())).replace(tzinfo=None)
202
+ if proposed_dt < now_local + timedelta(hours=settings.get_min_lead_time_hours()):
203
+ return False
204
+
205
+ return True
206
+
207
+ def get_available_providers(all_providers: list[dict], date: str, start_time: str, visit_level: int | str, appointments_by_provider: dict[str, list[dict]]) -> list[dict]:
208
+ """Filter all providers down to those available for the proposed visit.
209
+
210
+ Args:
211
+ all_providers: List of all provider dicts
212
+ date: "YYYY-MM-DD"
213
+ start_time: "HH:MM"
214
+ visit_level: Visit level (1, 2, 3, or "Nurse")
215
+ appointments_by_provider: Dict mapping provider_id -> list of their appointments on that date
216
+
217
+ Returns:
218
+ List of available provider dicts
219
+ """
220
+ available = []
221
+ for provider in all_providers:
222
+ existing = appointments_by_provider.get(provider["id"], [])
223
+ if is_provider_available(provider, date, start_time, visit_level, existing):
224
+ available.append(provider)
225
+ return available
226
+
227
+ def rank_providers_by_travel(providers_with_travel: list[dict]) -> list[dict]:
228
+ """Sort providers by travel time (ascending). Boost providers who already have visits in the same area.
229
+
230
+ Each dict in the input list should have:
231
+ - All standard provider fields
232
+ - 'travel_time_minutes': int (travel time to the patient)
233
+ - 'has_nearby_visits': bool (True if provider has other visits nearby that day)
234
+
235
+ Ranking logic:
236
+ - Primary sort: is_oncall (True first — travel time is ignored)
237
+ - Secondary sort: has_nearby_visits (True first)
238
+ - Tertiary sort: travel_time_minutes (ascending)
239
+
240
+ Returns the sorted list.
241
+ """
242
+ return sorted(
243
+ providers_with_travel,
244
+ key=lambda p: (
245
+ not p.get("is_oncall", False),
246
+ not p.get("has_nearby_visits", False),
247
+ p.get("travel_time_minutes", 0),
248
+ ),
249
+ )
core/settings_manager.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Settings Manager — runtime-configurable values persisted in a "Settings" worksheet.
3
+
4
+ Each setting is a (key, value) row. Complex values (e.g. visit_levels) are JSON-encoded.
5
+ Reads are cached for a short TTL to avoid hammering the Google Sheets API. Defaults
6
+ fall back to the constants in `config.py`.
7
+ """
8
+ import json
9
+ import time
10
+ import threading
11
+ from typing import Any
12
+
13
+ import config
14
+ from core import data_manager
15
+
16
+
17
+ _SETTINGS_WORKSHEET = "Settings"
18
+ _CACHE_TTL_SECONDS = 30
19
+
20
+ _lock = threading.RLock()
21
+ _cache: dict = {"timestamp": 0.0, "values": {}}
22
+
23
+
24
+ # Schema:
25
+ # key -> (default_value, json_encoded?, type_hint)
26
+ # type_hint is used to coerce the string-cell value back to the right Python type.
27
+ _SCHEMA: dict[str, tuple] = {
28
+ "min_lead_time_hours": (config.MIN_LEAD_TIME_HOURS, False, int),
29
+ "buffer_minutes": (config.BUFFER_MINUTES, False, int),
30
+ "visit_levels": (config.VISIT_LEVELS, True, "visit_levels"),
31
+ "on_call_start_hour": ("07:00", False, str),
32
+ "region_name": (config.REGION_NAME, False, str),
33
+ "timezone": (config.TIMEZONE, False, str),
34
+ "service_area": (config.SERVICE_AREA, False, str),
35
+ "openai_model": (config.OPENAI_MODEL, False, str),
36
+ }
37
+
38
+
39
+ def _ensure_settings_worksheet() -> None:
40
+ """Create the Settings worksheet on first use if it doesn't exist."""
41
+ try:
42
+ data_manager._get_worksheet(_SETTINGS_WORKSHEET)
43
+ data_manager._ensure_columns(_SETTINGS_WORKSHEET, ["key", "value"])
44
+ except Exception:
45
+ # Worksheet doesn't exist — create it.
46
+ ss = data_manager._get_spreadsheet()
47
+ ws = ss.add_worksheet(title=_SETTINGS_WORKSHEET, rows=100, cols=2)
48
+ ws.update(values=[["key", "value"]], range_name="A1:B1", value_input_option="RAW")
49
+ # Bust caches so subsequent reads see the new worksheet.
50
+ with data_manager._lock:
51
+ data_manager._state["worksheets"].pop(_SETTINGS_WORKSHEET, None)
52
+ data_manager._state["headers"].pop(_SETTINGS_WORKSHEET, None)
53
+ data_manager._state["read_cache"].pop(_SETTINGS_WORKSHEET, None)
54
+
55
+
56
+ def _read_all_settings() -> dict[str, str]:
57
+ """Return {key: raw_string_value} for everything in the Settings sheet.
58
+ Returns {} if the sheet doesn't exist yet (callers fall back to defaults).
59
+ """
60
+ try:
61
+ rows = data_manager._read_all(_SETTINGS_WORKSHEET)
62
+ return {str(r.get("key", "")): str(r.get("value", "")) for r in rows if r.get("key")}
63
+ except Exception:
64
+ return {}
65
+
66
+
67
+ def _refresh_cache_if_stale() -> None:
68
+ with _lock:
69
+ if (time.time() - _cache["timestamp"]) < _CACHE_TTL_SECONDS:
70
+ return
71
+ _cache["values"] = _read_all_settings()
72
+ _cache["timestamp"] = time.time()
73
+
74
+
75
+ def _coerce(raw: str, schema_entry: tuple) -> Any:
76
+ default, is_json, type_hint = schema_entry
77
+ if raw is None or raw == "":
78
+ return default
79
+ if is_json:
80
+ try:
81
+ parsed = json.loads(raw)
82
+ except Exception:
83
+ return default
84
+ if type_hint == "visit_levels" and isinstance(parsed, dict):
85
+ # JSON keys are always strings; rebuild int keys for visit levels.
86
+ return {
87
+ (int(k) if isinstance(k, str) and k.isdigit() else k): v
88
+ for k, v in parsed.items()
89
+ }
90
+ return parsed
91
+ if type_hint is int:
92
+ try:
93
+ return int(raw)
94
+ except Exception:
95
+ return default
96
+ if type_hint is float:
97
+ try:
98
+ return float(raw)
99
+ except Exception:
100
+ return default
101
+ return raw
102
+
103
+
104
+ def get_setting(key: str, default: Any = None) -> Any:
105
+ """Return the current value for `key`. Falls back to schema default or `default`."""
106
+ schema = _SCHEMA.get(key)
107
+ if schema is None:
108
+ return default
109
+ _refresh_cache_if_stale()
110
+ raw = _cache["values"].get(key)
111
+ if raw is None or raw == "":
112
+ return schema[0] if default is None else default
113
+ return _coerce(raw, schema)
114
+
115
+
116
+ def get_all_settings() -> dict[str, Any]:
117
+ """Return every known setting with its current effective value."""
118
+ return {k: get_setting(k) for k in _SCHEMA.keys()}
119
+
120
+
121
+ def set_setting(key: str, value: Any) -> Any:
122
+ """Persist `value` for `key`. Returns the parsed-back value (after round-trip)."""
123
+ if key not in _SCHEMA:
124
+ raise KeyError(f"Unknown setting: {key}")
125
+ schema = _SCHEMA[key]
126
+ _, is_json, _ = schema
127
+
128
+ _ensure_settings_worksheet()
129
+
130
+ if is_json:
131
+ # Normalize: for visit_levels, ensure int durations stay int.
132
+ serialized = json.dumps(value)
133
+ elif value is None:
134
+ serialized = ""
135
+ elif isinstance(value, bool):
136
+ serialized = str(value)
137
+ else:
138
+ serialized = str(value)
139
+
140
+ row_idx = data_manager._find_row_index(_SETTINGS_WORKSHEET, "key", key)
141
+ payload = {"key": key, "value": serialized}
142
+ if row_idx:
143
+ data_manager._update_row(_SETTINGS_WORKSHEET, row_idx, payload)
144
+ else:
145
+ data_manager._append_row(_SETTINGS_WORKSHEET, payload)
146
+
147
+ # Invalidate cache so the next read sees the new value.
148
+ with _lock:
149
+ _cache["timestamp"] = 0.0
150
+
151
+ return get_setting(key)
152
+
153
+
154
+ def get_schema_defaults() -> dict[str, Any]:
155
+ """Return the original config.py defaults for every setting (for "reset to default" UIs)."""
156
+ return {k: v[0] for k, v in _SCHEMA.items()}
157
+
158
+
159
+ # --- Convenience accessors ---
160
+ # These let consumers write `settings.get_min_lead_time_hours()` instead of magic strings.
161
+
162
+ def get_min_lead_time_hours() -> int: return get_setting("min_lead_time_hours")
163
+ def get_buffer_minutes() -> int: return get_setting("buffer_minutes")
164
+ def get_visit_levels() -> dict: return get_setting("visit_levels")
165
+ def get_on_call_start_hour() -> str: return get_setting("on_call_start_hour")
166
+ def get_region_name() -> str: return get_setting("region_name")
167
+ def get_timezone() -> str: return get_setting("timezone")
168
+ def get_service_area() -> str: return get_setting("service_area")
169
+ def get_openai_model() -> str: return get_setting("openai_model")
main.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SOS Care Now — Medical Scheduling Agent (HTML / FastAPI version).
3
+ Single-page modern dashboard served from FastAPI.
4
+ Run locally: uvicorn main:app --host 0.0.0.0 --port 7860 --reload
5
+ In Docker: CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
6
+ """
7
+ from pathlib import Path
8
+ from fastapi import FastAPI, Request
9
+ from fastapi.responses import FileResponse, JSONResponse
10
+ from fastapi.staticfiles import StaticFiles
11
+ from fastapi.exceptions import HTTPException
12
+
13
+ from api import auth as auth_routes
14
+ from api import providers as providers_routes
15
+ from api import patients as patients_routes
16
+ from api import appointments as appointments_routes
17
+ from api import schedule as schedule_routes
18
+ from api import calendar as calendar_routes
19
+ from api import audit as audit_routes
20
+ from api import settings as settings_routes
21
+
22
+
23
+ BASE_DIR = Path(__file__).resolve().parent
24
+
25
+ app = FastAPI(title="SOS Care Now — Scheduling")
26
+
27
+
28
+ @app.exception_handler(HTTPException)
29
+ async def http_exception_handler(request: Request, exc: HTTPException):
30
+ return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
31
+
32
+
33
+ # API routers
34
+ app.include_router(auth_routes.router)
35
+ app.include_router(providers_routes.router)
36
+ app.include_router(patients_routes.router)
37
+ app.include_router(appointments_routes.router)
38
+ app.include_router(schedule_routes.router)
39
+ app.include_router(calendar_routes.router)
40
+ app.include_router(audit_routes.router)
41
+ app.include_router(settings_routes.router)
42
+
43
+
44
+ # Static assets
45
+ app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
46
+
47
+
48
+ @app.get("/")
49
+ def root():
50
+ return FileResponse(str(BASE_DIR / "templates" / "index.html"))
51
+
52
+
53
+ @app.get("/healthz")
54
+ def healthz():
55
+ return {"ok": True}
requirements.txt ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi>=0.115.0
2
+ uvicorn[standard]>=0.30.0
3
+ pydantic>=2.0.0
4
+ itsdangerous>=2.1.0
5
+ python-multipart>=0.0.9
6
+ langgraph>=0.2.0
7
+ langchain>=0.3.0
8
+ langchain-openai>=0.2.0
9
+ google-api-python-client>=2.100.0
10
+ google-auth>=2.23.0
11
+ googlemaps>=4.10.0
12
+ python-dateutil>=2.8.0
13
+ gspread>=6.0.0
14
+ google-auth-oauthlib>=1.2.0
15
+ jinja2>=3.1.0
static/css/app.css ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* SOS Care Now — Custom styles layered on top of Tailwind */
2
+
3
+ :root {
4
+ --brand-50: #eff6ff;
5
+ --brand-100: #dbeafe;
6
+ --brand-500: #3b82f6;
7
+ --brand-600: #2563eb;
8
+ --brand-700: #1d4ed8;
9
+ --brand-900: #1e3a8a;
10
+ }
11
+
12
+ html, body {
13
+ height: 100%;
14
+ background-color: #f8fafc;
15
+ font-family: 'Inter', system-ui, -apple-system, sans-serif;
16
+ color: #0f172a;
17
+ }
18
+
19
+ /* Sidebar */
20
+ .sidebar {
21
+ background: linear-gradient(180deg, #0f172a 0%, #1e293b 100%);
22
+ }
23
+
24
+ .sidebar-link {
25
+ display: flex;
26
+ align-items: center;
27
+ gap: 0.75rem;
28
+ padding: 0.625rem 1rem;
29
+ border-radius: 0.5rem;
30
+ color: #cbd5e1;
31
+ font-weight: 500;
32
+ transition: all 0.15s ease;
33
+ cursor: pointer;
34
+ }
35
+ .sidebar-link:hover {
36
+ background-color: rgba(255, 255, 255, 0.06);
37
+ color: #fff;
38
+ }
39
+ .sidebar-link.active {
40
+ background-color: var(--brand-600);
41
+ color: #fff;
42
+ box-shadow: 0 4px 14px -2px rgba(37, 99, 235, 0.6);
43
+ }
44
+
45
+ /* Cards */
46
+ .card {
47
+ background: #fff;
48
+ border: 1px solid #e2e8f0;
49
+ border-radius: 1rem;
50
+ box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04);
51
+ }
52
+
53
+ /* Inputs */
54
+ .input, .select, textarea.input {
55
+ width: 100%;
56
+ padding: 0.5rem 0.75rem;
57
+ border: 1px solid #cbd5e1;
58
+ border-radius: 0.5rem;
59
+ background-color: #fff;
60
+ font-size: 0.875rem;
61
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
62
+ }
63
+ .input:focus, .select:focus, textarea.input:focus {
64
+ outline: none;
65
+ border-color: var(--brand-500);
66
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
67
+ }
68
+
69
+ /* Buttons */
70
+ .btn {
71
+ display: inline-flex;
72
+ align-items: center;
73
+ justify-content: center;
74
+ gap: 0.5rem;
75
+ padding: 0.5rem 1rem;
76
+ border-radius: 0.5rem;
77
+ font-weight: 500;
78
+ font-size: 0.875rem;
79
+ transition: all 0.15s ease;
80
+ cursor: pointer;
81
+ border: 1px solid transparent;
82
+ white-space: nowrap;
83
+ }
84
+ .btn-primary {
85
+ background-color: var(--brand-600);
86
+ color: #fff;
87
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.1);
88
+ }
89
+ .btn-primary:hover { background-color: var(--brand-700); }
90
+ .btn-primary:disabled { background-color: #cbd5e1; cursor: not-allowed; }
91
+ .btn-secondary {
92
+ background-color: #fff;
93
+ color: #0f172a;
94
+ border-color: #cbd5e1;
95
+ }
96
+ .btn-secondary:hover { background-color: #f1f5f9; }
97
+ .btn-danger {
98
+ background-color: #dc2626;
99
+ color: #fff;
100
+ }
101
+ .btn-danger:hover { background-color: #b91c1c; }
102
+ .btn-ghost {
103
+ background-color: transparent;
104
+ color: #64748b;
105
+ }
106
+ .btn-ghost:hover { background-color: #f1f5f9; color: #0f172a; }
107
+ .btn-sm { padding: 0.25rem 0.625rem; font-size: 0.8125rem; }
108
+
109
+ /* Tables */
110
+ .table-wrap {
111
+ overflow-x: auto;
112
+ border-radius: 0.75rem;
113
+ }
114
+ .table {
115
+ width: 100%;
116
+ border-collapse: collapse;
117
+ }
118
+ .table th {
119
+ text-align: left;
120
+ font-size: 0.75rem;
121
+ font-weight: 600;
122
+ text-transform: uppercase;
123
+ letter-spacing: 0.05em;
124
+ color: #64748b;
125
+ padding: 0.75rem 1rem;
126
+ background-color: #f8fafc;
127
+ border-bottom: 1px solid #e2e8f0;
128
+ }
129
+ .table td {
130
+ padding: 0.75rem 1rem;
131
+ font-size: 0.875rem;
132
+ border-bottom: 1px solid #f1f5f9;
133
+ }
134
+ .table tr:last-child td { border-bottom: none; }
135
+ .table tbody tr:hover { background-color: #fafbfc; }
136
+
137
+ /* Badges */
138
+ .badge {
139
+ display: inline-flex;
140
+ align-items: center;
141
+ padding: 0.125rem 0.625rem;
142
+ border-radius: 9999px;
143
+ font-size: 0.75rem;
144
+ font-weight: 600;
145
+ }
146
+ .badge-green { background-color: #dcfce7; color: #166534; }
147
+ .badge-blue { background-color: #dbeafe; color: #1d4ed8; }
148
+ .badge-red { background-color: #fee2e2; color: #991b1b; }
149
+ .badge-amber { background-color: #fef3c7; color: #92400e; }
150
+ .badge-slate { background-color: #e2e8f0; color: #334155; }
151
+ .badge-emerald { background-color: #d1fae5; color: #065f46; }
152
+
153
+ /* Toggle (on-call switch) */
154
+ .toggle {
155
+ position: relative;
156
+ width: 44px;
157
+ height: 24px;
158
+ background-color: #cbd5e1;
159
+ border-radius: 9999px;
160
+ cursor: pointer;
161
+ transition: background-color 0.2s ease;
162
+ flex-shrink: 0;
163
+ }
164
+ .toggle::after {
165
+ content: "";
166
+ position: absolute;
167
+ top: 2px;
168
+ left: 2px;
169
+ width: 20px;
170
+ height: 20px;
171
+ background-color: #fff;
172
+ border-radius: 9999px;
173
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
174
+ transition: transform 0.2s ease;
175
+ }
176
+ .toggle.on {
177
+ background-color: #10b981;
178
+ }
179
+ .toggle.on::after {
180
+ transform: translateX(20px);
181
+ }
182
+
183
+ /* Modal */
184
+ .modal-backdrop {
185
+ position: fixed;
186
+ inset: 0;
187
+ background-color: rgba(15, 23, 42, 0.5);
188
+ backdrop-filter: blur(2px);
189
+ z-index: 50;
190
+ display: flex;
191
+ align-items: center;
192
+ justify-content: center;
193
+ padding: 1rem;
194
+ }
195
+ .modal {
196
+ background-color: #fff;
197
+ border-radius: 1rem;
198
+ max-width: 720px;
199
+ width: 100%;
200
+ max-height: 90vh;
201
+ overflow-y: auto;
202
+ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
203
+ }
204
+
205
+ /* Stat tile */
206
+ .stat {
207
+ padding: 1.25rem;
208
+ background: #fff;
209
+ border: 1px solid #e2e8f0;
210
+ border-radius: 1rem;
211
+ }
212
+ .stat-label { font-size: 0.75rem; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; }
213
+ .stat-value { font-size: 1.875rem; font-weight: 700; color: #0f172a; margin-top: 0.25rem; }
214
+
215
+ /* Calendar custom event styles */
216
+ .fc-event { cursor: pointer; }
217
+ .event-travel { opacity: 0.55; }
218
+ .event-completed { opacity: 0.65; }
219
+ .event-completed .fc-event-title { text-decoration: line-through; }
220
+
221
+ /* Match card */
222
+ .match-card {
223
+ border: 1px solid #e2e8f0;
224
+ border-radius: 0.75rem;
225
+ padding: 1rem;
226
+ background: #fff;
227
+ transition: all 0.15s ease;
228
+ cursor: pointer;
229
+ }
230
+ .match-card:hover {
231
+ border-color: var(--brand-500);
232
+ box-shadow: 0 4px 12px -2px rgba(59, 130, 246, 0.15);
233
+ }
234
+ .match-card.selected {
235
+ border-color: var(--brand-600);
236
+ background: linear-gradient(180deg, #eff6ff 0%, #fff 30%);
237
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
238
+ }
239
+
240
+ /* Toast */
241
+ .toast {
242
+ position: fixed;
243
+ bottom: 1.5rem;
244
+ right: 1.5rem;
245
+ z-index: 100;
246
+ padding: 0.875rem 1.25rem;
247
+ border-radius: 0.75rem;
248
+ color: #fff;
249
+ box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
250
+ font-weight: 500;
251
+ font-size: 0.875rem;
252
+ max-width: 420px;
253
+ }
254
+ .toast-success { background-color: #16a34a; }
255
+ .toast-error { background-color: #dc2626; }
256
+ .toast-info { background-color: #0f172a; }
257
+
258
+ /* Loader */
259
+ .spinner {
260
+ width: 1.25rem;
261
+ height: 1.25rem;
262
+ border: 2px solid #e2e8f0;
263
+ border-top-color: var(--brand-600);
264
+ border-radius: 50%;
265
+ animation: spin 0.6s linear infinite;
266
+ display: inline-block;
267
+ }
268
+ @keyframes spin { to { transform: rotate(360deg); } }
269
+
270
+ [x-cloak] { display: none !important; }
271
+
272
+ /* Login */
273
+ .login-bg {
274
+ background: linear-gradient(135deg, #1e3a8a 0%, #3b82f6 50%, #06b6d4 100%);
275
+ }
static/js/api.js ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Thin wrapper around fetch — JSON in, JSON out. Throws on non-2xx.
2
+ async function api(method, url, body) {
3
+ const opts = {
4
+ method,
5
+ headers: { "Content-Type": "application/json" },
6
+ credentials: "same-origin",
7
+ };
8
+ if (body !== undefined) opts.body = JSON.stringify(body);
9
+ const r = await fetch(url, opts);
10
+ let payload = null;
11
+ try { payload = await r.json(); } catch (_) { /* no body */ }
12
+ if (!r.ok) {
13
+ const msg = payload && payload.detail ? payload.detail : `Request failed (${r.status})`;
14
+ throw new Error(msg);
15
+ }
16
+ return payload;
17
+ }
18
+
19
+ window.API = {
20
+ get: (u) => api("GET", u),
21
+ post: (u, b) => api("POST", u, b),
22
+ put: (u, b) => api("PUT", u, b),
23
+ del: (u) => api("DELETE", u),
24
+ };
25
+
26
+ // Time/date helpers
27
+ window.fmt = {
28
+ time(t) {
29
+ if (!t) return "";
30
+ const [h, m] = t.split(":").map(Number);
31
+ const period = h >= 12 ? "PM" : "AM";
32
+ const h12 = h % 12 === 0 ? 12 : h % 12;
33
+ return `${h12}:${String(m).padStart(2, "0")} ${period}`;
34
+ },
35
+ date(d) {
36
+ if (!d) return "";
37
+ const dt = new Date(d + "T00:00:00");
38
+ return dt.toLocaleDateString(undefined, { weekday: "long", year: "numeric", month: "long", day: "numeric" });
39
+ },
40
+ dateShort(d) {
41
+ if (!d) return "";
42
+ const dt = new Date(d + "T00:00:00");
43
+ return dt.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
44
+ },
45
+ today() {
46
+ const d = new Date();
47
+ const y = d.getFullYear();
48
+ const m = String(d.getMonth() + 1).padStart(2, "0");
49
+ const day = String(d.getDate()).padStart(2, "0");
50
+ return `${y}-${m}-${day}`;
51
+ },
52
+ tomorrow() {
53
+ const d = new Date();
54
+ d.setDate(d.getDate() + 1);
55
+ const y = d.getFullYear();
56
+ const m = String(d.getMonth() + 1).padStart(2, "0");
57
+ const day = String(d.getDate()).padStart(2, "0");
58
+ return `${y}-${m}-${day}`;
59
+ },
60
+ };
static/js/app.js ADDED
@@ -0,0 +1,1180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Main Alpine app state for the SOS Care Now dashboard.
2
+
3
+ const WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
4
+
5
+ // Major US IANA timezones offered in the Settings dropdown.
6
+ const US_TIMEZONES = [
7
+ { key: "America/New_York", label: "Eastern — New York" },
8
+ { key: "America/Chicago", label: "Central — Chicago" },
9
+ { key: "America/Denver", label: "Mountain — Denver" },
10
+ { key: "America/Phoenix", label: "Arizona (no DST) — Phoenix" },
11
+ { key: "America/Los_Angeles", label: "Pacific — Los Angeles" },
12
+ { key: "America/Anchorage", label: "Alaska — Anchorage" },
13
+ { key: "Pacific/Honolulu", label: "Hawaii — Honolulu" },
14
+ ];
15
+
16
+ const VISIT_LEVELS_FALLBACK = [
17
+ { key: 1, label: "Level 1", duration_minutes: 45 },
18
+ { key: 2, label: "Level 2", duration_minutes: 60 },
19
+ { key: 3, label: "Level 3", duration_minutes: 90 },
20
+ { key: "Nurse", label: "Nurse Visit", duration_minutes: 60 },
21
+ ];
22
+
23
+ function generateTimeOptions() {
24
+ // 08:00 → 23:45 in 15-minute steps. Late slots exist for on-call coverage.
25
+ const out = [];
26
+ for (let h = 8; h < 24; h++) {
27
+ for (let m = 0; m < 60; m += 15) {
28
+ out.push(`${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`);
29
+ }
30
+ }
31
+ return out;
32
+ }
33
+
34
+ // Return {year, month, day, hour, minute} for an instant, projected into `tz` (IANA name).
35
+ // Falls back to browser local if tz is missing or invalid.
36
+ function wallClockIn(instant, tz) {
37
+ try {
38
+ const parts = new Intl.DateTimeFormat("en-US", {
39
+ timeZone: tz,
40
+ year: "numeric", month: "2-digit", day: "2-digit",
41
+ hour: "2-digit", minute: "2-digit", hour12: false,
42
+ }).formatToParts(instant);
43
+ const get = (t) => Number(parts.find(p => p.type === t).value);
44
+ let hour = get("hour");
45
+ if (hour === 24) hour = 0; // Intl quirk: hour12:false sometimes yields "24"
46
+ return { year: get("year"), month: get("month"), day: get("day"), hour, minute: get("minute") };
47
+ } catch (_) {
48
+ return {
49
+ year: instant.getFullYear(), month: instant.getMonth() + 1, day: instant.getDate(),
50
+ hour: instant.getHours(), minute: instant.getMinutes(),
51
+ };
52
+ }
53
+ }
54
+
55
+ // Earliest valid "HH:MM" on `dateStr` that satisfies the configured lead time,
56
+ // computed in the platform timezone (so an Eastern user booking Chicago appointments
57
+ // sees the floor in Central time, not their browser's local time).
58
+ // Returns null if no time on that date works (caller should treat as "no valid options").
59
+ function minValidTimeForDate(dateStr, leadHours = 24, tz = null) {
60
+ if (!dateStr) return "08:00";
61
+ const minInstant = new Date(Date.now() + leadHours * 60 * 60 * 1000);
62
+ const wc = wallClockIn(minInstant, tz);
63
+ const [ty, tmo, td] = dateStr.split("-").map(Number);
64
+ // Compare Y/M/D in the platform tz.
65
+ const cmp = (ty - wc.year) || (tmo - wc.month) || (td - wc.day);
66
+ if (cmp > 0) return "08:00"; // target date is after the min-lead day
67
+ if (cmp < 0) return null; // target date is before the min-lead day
68
+ // Same day in the platform tz: round UP to next 15-minute slot.
69
+ let h = wc.hour, m = wc.minute;
70
+ const rem = m % 15;
71
+ if (rem !== 0) m += 15 - rem;
72
+ if (m === 60) { m = 0; h += 1; }
73
+ if (h >= 24) return null; // past end-of-day window
74
+ if (h < 8) { h = 8; m = 0; } // floor at 08:00
75
+ return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
76
+ }
77
+
78
+ function appState() {
79
+ return {
80
+ // --- Auth ---
81
+ authenticated: false,
82
+ userRole: null, // "admin" | "provider"
83
+ currentUser: "",
84
+ providerId: null,
85
+ loginMode: "admin", // "admin" | "provider"
86
+ loginForm: { username: "", password: "" },
87
+ providerLoginForm: { name: "", password: "" },
88
+ providerOptions: [],
89
+ minLeadTimeHours: 24, // overwritten by /api/schedule/config on init
90
+ platformTimezone: null, // IANA tz (e.g. "America/Chicago") — overwritten by /api/schedule/config
91
+ loginError: "",
92
+ loginBusy: false,
93
+
94
+ // --- Navigation ---
95
+ view: "schedule", // schedule | providers | patients | appointments | calendar | audit | portal
96
+
97
+ // --- Loading + toast ---
98
+ toast: null,
99
+ loading: false,
100
+
101
+ // --- Data caches ---
102
+ providers: [],
103
+ patients: [],
104
+ appointments: [],
105
+ visitLevels: VISIT_LEVELS_FALLBACK,
106
+
107
+ // --- Schedule view ---
108
+ sched: {
109
+ phase: "form", // form | matches | booked | error
110
+ patientId: "",
111
+ visitLevel: "1",
112
+ address: "",
113
+ preferredDate: "",
114
+ timeStart: "09:00",
115
+ timeEnd: "12:00",
116
+ timeOptions: generateTimeOptions(),
117
+ error: "",
118
+ threadId: "",
119
+ matches: [],
120
+ selectedIdx: null,
121
+ bookedAppt: null,
122
+ notificationsSent: false,
123
+ },
124
+
125
+ // --- Cancel tab ---
126
+ cancel: {
127
+ patientFilter: "All",
128
+ providerFilter: "All",
129
+ selectedId: "",
130
+ },
131
+ scheduleTab: "new", // new | cancel
132
+
133
+ // --- Appointments view (admin) ---
134
+ apptView: {
135
+ statusFilter: "All", // All | scheduled | completed | cancelled
136
+ patientFilter: "All",
137
+ providerFilter: "All",
138
+ list: [],
139
+ },
140
+
141
+ // --- Providers view ---
142
+ prov: {
143
+ showAdd: false,
144
+ editingId: null,
145
+ expandedId: null, // id of the provider whose availability editor is open
146
+ form: this ? null : null, // placeholder
147
+ confirmDelete: null,
148
+ newOnCallDate: "",
149
+ newBlockType: "All Day",
150
+ newBlock: { title: "", date: "", start_time: "08:00", end_time: "12:00" },
151
+ },
152
+ provForm: {
153
+ name: "", email: "", password: "", address: "", category: "provider",
154
+ work_hours: { start: "08:00", end: "17:00" },
155
+ lunch_break: { start: "12:00", end: "13:00" },
156
+ service_levels: [1, 2, 3],
157
+ },
158
+
159
+ // --- Patients view ---
160
+ pat: {
161
+ showAdd: false,
162
+ editingId: null,
163
+ confirmDelete: null,
164
+ },
165
+ patForm: { name: "", email: "", address: "", notes: "" },
166
+
167
+ // --- Calendar view ---
168
+ cal: {
169
+ selectedProviderIds: [],
170
+ showUnavailable: true,
171
+ showTravel: true,
172
+ showOnCall: true,
173
+ providerLegend: [],
174
+ instance: null,
175
+ selectedEvent: null,
176
+ },
177
+
178
+ // --- Audit log ---
179
+ audit: { search: "", action: "All", entries: [], actionTypes: [] },
180
+
181
+ // --- Settings ---
182
+ sett: {
183
+ current: null,
184
+ defaults: null,
185
+ form: null,
186
+ saving: false,
187
+ },
188
+
189
+ // --- Provider portal ---
190
+ portal: {
191
+ self: null,
192
+ tab: "availability", // availability | schedule
193
+ editForm: { email: "", address: "", work_hours: { start: "08:00", end: "17:00" }, lunch_break: { start: "12:00", end: "13:00" }, recurring_days_off: [] },
194
+ newBlockType: "All Day",
195
+ newBlock: { title: "", date: "", start_time: "08:00", end_time: "12:00" },
196
+ newOnCallDate: "",
197
+ calendar: null,
198
+ selectedEvent: null,
199
+ },
200
+
201
+ // ============================================================
202
+ // INIT
203
+ // ============================================================
204
+ async init() {
205
+ this.sched.preferredDate = fmt.tomorrow();
206
+ this.prov.newBlock.date = fmt.tomorrow();
207
+ this.prov.newOnCallDate = fmt.today();
208
+ this.portal.newBlock.date = fmt.tomorrow();
209
+ this.portal.newOnCallDate = fmt.today();
210
+
211
+ await this.loadSchedulingConfig();
212
+ await this.refreshAuth();
213
+ if (!this.authenticated) {
214
+ await this.loadProviderOptions();
215
+ } else {
216
+ await this.afterLogin();
217
+ }
218
+
219
+ this.$watch("view", (v) => this.onViewChange(v));
220
+ this.$watch("sched.preferredDate", () => this.enforceLeadTime());
221
+ // Snap to first valid slot on the initial (tomorrow) default.
222
+ this.enforceLeadTime();
223
+ },
224
+
225
+ // Restrict sched.timeStart/timeEnd to slots that respect the configured lead time.
226
+ // Called on init and whenever the preferred date changes.
227
+ get validStartTimes() {
228
+ const minT = minValidTimeForDate(this.sched.preferredDate, this.minLeadTimeHours, this.platformTimezone);
229
+ if (!minT) return [];
230
+ return this.sched.timeOptions.filter(t => t >= minT);
231
+ },
232
+ // Earliest selectable date given the current lead time. Tries today first, then tomorrow.
233
+ get minSelectableDate() {
234
+ const today = fmt.today();
235
+ if (minValidTimeForDate(today, this.minLeadTimeHours, this.platformTimezone)) return today;
236
+ return fmt.tomorrow();
237
+ },
238
+ // Latest Start is NOT constrained by lead time — just must be >= timeStart.
239
+ // This lets users widen the window into the evening (on-call still respects lead time per-slot).
240
+ get validEndTimes() {
241
+ const start = this.sched.timeStart;
242
+ if (!start) return [];
243
+ return this.sched.timeOptions.filter(t => t >= start);
244
+ },
245
+ // Friendly label for the platform timezone, e.g. "Central — Chicago" for "America/Chicago".
246
+ // Falls back to the raw IANA name if not in the known list.
247
+ get platformTimezoneLabel() {
248
+ if (!this.platformTimezone) return "";
249
+ const match = US_TIMEZONES.find(t => t.key === this.platformTimezone);
250
+ return match ? match.label : this.platformTimezone;
251
+ },
252
+ // "Now" represented as a Date whose local-time fields equal the current wall-clock
253
+ // in the platform timezone. FullCalendar (with default timeZone:"local") then places
254
+ // the now-indicator at the correct slot for an Eastern user viewing Chicago times.
255
+ platformNow() {
256
+ const tz = this.platformTimezone;
257
+ if (!tz) return new Date();
258
+ const wc = wallClockIn(new Date(), tz);
259
+ return new Date(wc.year, wc.month - 1, wc.day, wc.hour, wc.minute);
260
+ },
261
+ enforceLeadTime() {
262
+ const valid = this.validStartTimes;
263
+ if (valid.length === 0) {
264
+ this.sched.timeStart = "";
265
+ this.sched.timeEnd = "";
266
+ return;
267
+ }
268
+ if (!valid.includes(this.sched.timeStart)) this.sched.timeStart = valid[0];
269
+ const endValid = this.sched.timeOptions.filter(t => t >= this.sched.timeStart);
270
+ if (!endValid.includes(this.sched.timeEnd) || this.sched.timeEnd < this.sched.timeStart) {
271
+ // Default end = 3 hours after start (12 slots) or last available
272
+ const endIdx = Math.min(12, endValid.length - 1);
273
+ this.sched.timeEnd = endValid[endIdx];
274
+ }
275
+ },
276
+
277
+ async refreshAuth() {
278
+ try {
279
+ const me = await API.get("/api/auth/me");
280
+ if (me.authenticated) {
281
+ this.authenticated = true;
282
+ this.userRole = me.user_role;
283
+ this.currentUser = me.current_user;
284
+ this.providerId = me.provider_id || null;
285
+ }
286
+ } catch (e) { /* not logged in */ }
287
+ },
288
+
289
+ async loadProviderOptions() {
290
+ try { this.providerOptions = await API.get("/api/auth/provider-names"); }
291
+ catch (e) { this.providerOptions = []; }
292
+ },
293
+
294
+ async loadSchedulingConfig() {
295
+ try {
296
+ const cfg = await API.get("/api/schedule/config");
297
+ if (cfg && typeof cfg.min_lead_time_hours === "number") {
298
+ this.minLeadTimeHours = cfg.min_lead_time_hours;
299
+ }
300
+ if (cfg && typeof cfg.timezone === "string" && cfg.timezone) {
301
+ this.platformTimezone = cfg.timezone;
302
+ }
303
+ } catch (e) { /* keep defaults */ }
304
+ },
305
+
306
+ async afterLogin() {
307
+ // Pick a default view per role
308
+ if (this.userRole === "provider") {
309
+ this.view = "portal";
310
+ await this.loadPortal();
311
+ } else {
312
+ this.view = "schedule";
313
+ await this.loadVisitLevels();
314
+ await this.loadPatients();
315
+ }
316
+ },
317
+
318
+ // ============================================================
319
+ // LOGIN
320
+ // ============================================================
321
+ async submitLogin() {
322
+ this.loginError = "";
323
+ this.loginBusy = true;
324
+ try {
325
+ if (this.loginMode === "admin") {
326
+ const res = await API.post("/api/auth/login", this.loginForm);
327
+ this.authenticated = true;
328
+ this.userRole = res.user_role;
329
+ this.currentUser = res.current_user;
330
+ this.providerId = null;
331
+ } else {
332
+ const res = await API.post("/api/auth/provider-login", this.providerLoginForm);
333
+ this.authenticated = true;
334
+ this.userRole = res.user_role;
335
+ this.currentUser = res.current_user;
336
+ this.providerId = res.provider_id;
337
+ }
338
+ await this.afterLogin();
339
+ } catch (e) {
340
+ this.loginError = e.message || "Login failed";
341
+ } finally {
342
+ this.loginBusy = false;
343
+ }
344
+ },
345
+
346
+ async logout() {
347
+ try { await API.post("/api/auth/logout"); } catch (e) {}
348
+ this.authenticated = false;
349
+ this.userRole = null;
350
+ this.currentUser = "";
351
+ this.providerId = null;
352
+ this.loginForm = { username: "", password: "" };
353
+ this.providerLoginForm = { name: "", password: "" };
354
+ await this.loadProviderOptions();
355
+ },
356
+
357
+ // ============================================================
358
+ // VIEW NAVIGATION
359
+ // ============================================================
360
+ async onViewChange(v) {
361
+ // Destroy any existing calendar instance
362
+ if (this.cal.instance) { this.cal.instance.destroy(); this.cal.instance = null; }
363
+ if (this.portal.calendar) { this.portal.calendar.destroy(); this.portal.calendar = null; }
364
+
365
+ if (v === "schedule") { await this.loadVisitLevels(); await this.loadPatients(); }
366
+ if (v === "providers") { await this.loadProviders(); }
367
+ if (v === "patients") { await this.loadPatients(); }
368
+ if (v === "appointments") { await this.loadAppointmentsList(); await this.loadProviders(); await this.loadPatients(); }
369
+ if (v === "calendar") { await this.loadProviders(); await this.$nextTick(); this.mountCalendar(); }
370
+ if (v === "audit") { await this.loadAudit(); }
371
+ if (v === "portal") { await this.loadPortal(); }
372
+ if (v === "settings") { await this.loadSettings(); }
373
+ },
374
+
375
+ // ============================================================
376
+ // SETTINGS
377
+ // ============================================================
378
+ async loadSettings() {
379
+ try {
380
+ const res = await API.get("/api/settings");
381
+ this.sett.current = res.current;
382
+ this.sett.defaults = res.defaults;
383
+ // Build editable form (deep-copy to avoid mutating .current until save)
384
+ this.sett.form = JSON.parse(JSON.stringify(res.current));
385
+ } catch (e) {
386
+ this.showToast(e.message, "error");
387
+ }
388
+ },
389
+
390
+ get timezoneOptions() {
391
+ // Surface the saved value even if it's not in the standard US list.
392
+ const current = this.sett.form && this.sett.form.timezone;
393
+ const standardKeys = US_TIMEZONES.map(t => t.key);
394
+ if (current && !standardKeys.includes(current)) {
395
+ return [{ key: current, label: `${current} (current)` }, ...US_TIMEZONES];
396
+ }
397
+ return US_TIMEZONES;
398
+ },
399
+
400
+ get visitLevelEntries() {
401
+ // Return [{key, label, duration_minutes}, ...] for stable iteration in the template.
402
+ if (!this.sett.form || !this.sett.form.visit_levels) return [];
403
+ return Object.entries(this.sett.form.visit_levels).map(([k, v]) => ({
404
+ key: k,
405
+ label: v.label || k,
406
+ duration_minutes: v.duration_minutes,
407
+ }));
408
+ },
409
+
410
+ updateVisitLevelDuration(key, value) {
411
+ const n = parseInt(value);
412
+ if (isNaN(n)) return;
413
+ this.sett.form.visit_levels[key].duration_minutes = n;
414
+ },
415
+
416
+ async saveSettings() {
417
+ if (this.sett.saving) return;
418
+ this.sett.saving = true;
419
+ try {
420
+ const res = await API.put("/api/settings", this.sett.form);
421
+ this.sett.current = res.current;
422
+ this.sett.form = JSON.parse(JSON.stringify(res.current));
423
+ // Refresh the schedule view's visit-level dropdown so changes are visible immediately.
424
+ this.visitLevels = Object.entries(res.current.visit_levels).map(([k, v]) => ({
425
+ key: (isNaN(parseInt(k)) ? k : parseInt(k)),
426
+ label: v.label,
427
+ duration_minutes: v.duration_minutes,
428
+ }));
429
+ if (typeof res.current.min_lead_time_hours === "number") {
430
+ this.minLeadTimeHours = res.current.min_lead_time_hours;
431
+ this.enforceLeadTime();
432
+ }
433
+ if (res.changes.length === 0) {
434
+ this.showToast("No changes to save.", "info");
435
+ } else {
436
+ this.showToast(`Saved ${res.changes.length} change(s).`, "success");
437
+ }
438
+ } catch (e) {
439
+ this.showToast(e.message, "error");
440
+ } finally {
441
+ this.sett.saving = false;
442
+ }
443
+ },
444
+
445
+ async resetSettings() {
446
+ if (!confirm("Reset ALL settings to defaults? This cannot be undone.")) return;
447
+ try {
448
+ const res = await API.post("/api/settings/reset");
449
+ this.sett.current = res.current;
450
+ this.sett.form = JSON.parse(JSON.stringify(res.current));
451
+ this.showToast("Settings reset to defaults.", "success");
452
+ } catch (e) {
453
+ this.showToast(e.message, "error");
454
+ }
455
+ },
456
+
457
+ // ============================================================
458
+ // TOAST
459
+ // ============================================================
460
+ showToast(msg, type = "info") {
461
+ this.toast = { msg, type };
462
+ setTimeout(() => { if (this.toast && this.toast.msg === msg) this.toast = null; }, 3500);
463
+ },
464
+
465
+ // ============================================================
466
+ // DATA LOADERS
467
+ // ============================================================
468
+ async loadVisitLevels() {
469
+ try { this.visitLevels = await API.get("/api/schedule/visit-levels"); }
470
+ catch (e) { /* keep fallback */ }
471
+ },
472
+
473
+ async loadProviders() {
474
+ try { this.providers = await API.get("/api/providers?active_only=false"); }
475
+ catch (e) { this.showToast(e.message, "error"); }
476
+ },
477
+
478
+ async loadPatients() {
479
+ try { this.patients = await API.get("/api/patients"); }
480
+ catch (e) { this.showToast(e.message, "error"); }
481
+ },
482
+
483
+ // ============================================================
484
+ // SCHEDULE — find matches & book
485
+ // ============================================================
486
+ get selectedPatient() {
487
+ return this.patients.find(p => p.id === this.sched.patientId);
488
+ },
489
+
490
+ onPatientChange() {
491
+ const p = this.selectedPatient;
492
+ if (p) this.sched.address = p.address || "";
493
+ },
494
+
495
+ async findMatches() {
496
+ if (!this.sched.patientId) { this.showToast("Select a patient", "error"); return; }
497
+ if (!this.sched.address) { this.showToast("Address required", "error"); return; }
498
+ if (!this.sched.timeStart || !this.sched.timeEnd) {
499
+ this.showToast(`No appointment times on this date meet the ${this.minLeadTimeHours}-hour lead time. Pick a later date.`, "error");
500
+ return;
501
+ }
502
+ this.loading = true;
503
+ this.sched.error = "";
504
+ try {
505
+ const vl = isNaN(parseInt(this.sched.visitLevel)) ? this.sched.visitLevel : parseInt(this.sched.visitLevel);
506
+ const res = await API.post("/api/schedule/find-matches", {
507
+ patient_id: this.sched.patientId,
508
+ visit_level: vl,
509
+ visit_address: this.sched.address,
510
+ preferred_dates: [this.sched.preferredDate],
511
+ preferred_time_window: { start: this.sched.timeStart, end: this.sched.timeEnd },
512
+ });
513
+ if (res.error) {
514
+ this.sched.error = res.error;
515
+ this.sched.phase = "error";
516
+ } else if ((res.ranked_matches || []).length === 0) {
517
+ this.sched.error = "No matches found. Try different dates or times.";
518
+ this.sched.phase = "error";
519
+ } else {
520
+ this.sched.threadId = res.thread_id;
521
+ this.sched.matches = res.ranked_matches;
522
+ this.sched.selectedIdx = null;
523
+ this.sched.phase = "matches";
524
+ }
525
+ } catch (e) {
526
+ this.sched.error = e.message;
527
+ this.sched.phase = "error";
528
+ } finally {
529
+ this.loading = false;
530
+ }
531
+ },
532
+
533
+ get groupedMatches() {
534
+ // Group ranked matches by provider, preserving rank order.
535
+ const groups = [];
536
+ const seen = {};
537
+ this.sched.matches.forEach((m, idx) => {
538
+ if (!(m.provider_id in seen)) {
539
+ seen[m.provider_id] = { provider_id: m.provider_id, name: m.provider_name, min_travel: m.travel_time_minutes, is_oncall: !!m.is_oncall, slots: [] };
540
+ groups.push(seen[m.provider_id]);
541
+ } else {
542
+ seen[m.provider_id].is_oncall = seen[m.provider_id].is_oncall || !!m.is_oncall;
543
+ }
544
+ seen[m.provider_id].slots.push({ idx, ...m });
545
+ });
546
+ return groups;
547
+ },
548
+
549
+ selectSlot(idx) {
550
+ this.sched.selectedIdx = idx;
551
+ },
552
+
553
+ async bookAppointment() {
554
+ if (this.sched.selectedIdx === null) return;
555
+ this.loading = true;
556
+ try {
557
+ const res = await API.post("/api/schedule/book", {
558
+ thread_id: this.sched.threadId,
559
+ selection_index: this.sched.selectedIdx,
560
+ });
561
+ if (res.error) {
562
+ this.sched.error = res.error;
563
+ this.sched.phase = "error";
564
+ } else {
565
+ this.sched.bookedAppt = res.booked_appointment;
566
+ this.sched.notificationsSent = res.notifications_sent;
567
+ this.sched.phase = "booked";
568
+ }
569
+ } catch (e) {
570
+ this.sched.error = e.message;
571
+ this.sched.phase = "error";
572
+ } finally {
573
+ this.loading = false;
574
+ }
575
+ },
576
+
577
+ startOver() {
578
+ this.sched.phase = "form";
579
+ this.sched.error = "";
580
+ this.sched.matches = [];
581
+ this.sched.selectedIdx = null;
582
+ this.sched.bookedAppt = null;
583
+ },
584
+
585
+ // Like startOver(), but keeps the form inputs so the user can tweak and re-submit.
586
+ backToForm() {
587
+ this.sched.phase = "form";
588
+ this.sched.error = "";
589
+ this.sched.matches = [];
590
+ this.sched.selectedIdx = null;
591
+ },
592
+
593
+ get matchesCount() {
594
+ return this.sched.matches.length;
595
+ },
596
+
597
+ // ============================================================
598
+ // CANCEL APPOINTMENT (admin)
599
+ // ============================================================
600
+ async loadFutureAppointments() {
601
+ const all = await API.get("/api/appointments?status=scheduled");
602
+ const today = fmt.today();
603
+ this.appointments = all.filter(a => a.date >= today);
604
+ },
605
+
606
+ async openCancelTab() {
607
+ this.scheduleTab = "cancel";
608
+ await this.loadFutureAppointments();
609
+ await this.loadProviders();
610
+ await this.loadPatients();
611
+ },
612
+
613
+ get filteredCancelAppts() {
614
+ let list = [...this.appointments];
615
+ if (this.cancel.patientFilter !== "All") {
616
+ const pid = (this.patients.find(p => p.name === this.cancel.patientFilter) || {}).id;
617
+ if (pid) list = list.filter(a => a.patient_id === pid);
618
+ }
619
+ if (this.cancel.providerFilter !== "All") {
620
+ const pid = (this.providers.find(p => p.name === this.cancel.providerFilter) || {}).id;
621
+ if (pid) list = list.filter(a => a.provider_id === pid);
622
+ }
623
+ return list;
624
+ },
625
+
626
+ async cancelAppointment() {
627
+ if (!this.cancel.selectedId) { this.showToast("Select an appointment", "error"); return; }
628
+ if (!confirm("Cancel this appointment? Calendar event and notifications will be sent.")) return;
629
+ this.loading = true;
630
+ try {
631
+ await API.del(`/api/appointments/${this.cancel.selectedId}`);
632
+ this.showToast("Appointment cancelled", "success");
633
+ this.cancel.selectedId = "";
634
+ await this.loadFutureAppointments();
635
+ } catch (e) {
636
+ this.showToast(e.message, "error");
637
+ } finally {
638
+ this.loading = false;
639
+ }
640
+ },
641
+
642
+ // ============================================================
643
+ // APPOINTMENTS view (admin)
644
+ // ============================================================
645
+ async loadAppointmentsList() {
646
+ this.apptView.list = await API.get("/api/appointments");
647
+ },
648
+
649
+ get filteredAppointmentsList() {
650
+ let list = [...this.apptView.list];
651
+ if (this.apptView.statusFilter !== "All") {
652
+ list = list.filter(a => (a.status || "scheduled") === this.apptView.statusFilter);
653
+ }
654
+ if (this.apptView.patientFilter !== "All") {
655
+ const pid = (this.patients.find(p => p.name === this.apptView.patientFilter) || {}).id;
656
+ if (pid) list = list.filter(a => a.patient_id === pid);
657
+ }
658
+ if (this.apptView.providerFilter !== "All") {
659
+ const pid = (this.providers.find(p => p.name === this.apptView.providerFilter) || {}).id;
660
+ if (pid) list = list.filter(a => a.provider_id === pid);
661
+ }
662
+ // Sort: upcoming first (date+time ascending), then completed/cancelled at the bottom.
663
+ const today = fmt.today();
664
+ list.sort((a, b) => {
665
+ const akey = (a.date || "") + "T" + (a.start_time || "");
666
+ const bkey = (b.date || "") + "T" + (b.start_time || "");
667
+ const aFuture = (a.date || "") >= today && (a.status || "scheduled") === "scheduled";
668
+ const bFuture = (b.date || "") >= today && (b.status || "scheduled") === "scheduled";
669
+ if (aFuture !== bFuture) return aFuture ? -1 : 1;
670
+ return akey.localeCompare(bkey);
671
+ });
672
+ return list;
673
+ },
674
+
675
+ statusBadgeClass(status) {
676
+ const s = status || "scheduled";
677
+ if (s === "completed") return "badge badge-emerald";
678
+ if (s === "cancelled") return "badge badge-red";
679
+ return "badge badge-blue";
680
+ },
681
+
682
+ // True when the appointment's start time has passed in the platform timezone.
683
+ // Used to enable/disable the "Mark Complete" button for providers.
684
+ canCompleteAppointment(a) {
685
+ if (!a || (a.status && a.status !== "scheduled")) return false;
686
+ if (!a.date || !a.start_time) return false;
687
+ const now = this.platformNow();
688
+ const [y, mo, d] = a.date.split("-").map(Number);
689
+ const [h, mi] = a.start_time.split(":").map(Number);
690
+ const startLocal = new Date(y, mo - 1, d, h, mi);
691
+ return now >= startLocal;
692
+ },
693
+
694
+ async markAppointmentComplete(appointmentId) {
695
+ if (!appointmentId) return;
696
+ this.loading = true;
697
+ try {
698
+ await API.post(`/api/appointments/${appointmentId}/complete`, {});
699
+ this.showToast("Marked complete", "success");
700
+ // Refresh whichever view we're on
701
+ if (this.view === "portal" && this.portal.calendar) this.portal.calendar.refetchEvents();
702
+ if (this.view === "appointments") await this.loadAppointmentsList();
703
+ this.portal.selectedEvent = null;
704
+ } catch (e) {
705
+ this.showToast(e.message, "error");
706
+ } finally {
707
+ this.loading = false;
708
+ }
709
+ },
710
+
711
+ // ============================================================
712
+ // PROVIDERS view
713
+ // ============================================================
714
+ openAddProvider() {
715
+ this.prov.editingId = null;
716
+ this.provForm = {
717
+ name: "", email: "", password: "", address: "", category: "provider",
718
+ work_hours: { start: "08:00", end: "17:00" },
719
+ lunch_break: { start: "12:00", end: "13:00" },
720
+ service_levels: [1, 2, 3],
721
+ };
722
+ this.prov.showAdd = true;
723
+ },
724
+
725
+ openEditProvider(p) {
726
+ this.prov.editingId = p.id;
727
+ this.provForm = {
728
+ name: p.name || "",
729
+ email: p.email || "",
730
+ password: p.password || "",
731
+ address: p.address || "",
732
+ category: p.category || "provider",
733
+ work_hours: { ...(p.work_hours || { start: "08:00", end: "17:00" }) },
734
+ lunch_break: { ...(p.lunch_break || { start: "12:00", end: "13:00" }) },
735
+ service_levels: [...(p.service_levels || [])],
736
+ };
737
+ this.prov.showAdd = true;
738
+ },
739
+
740
+ toggleServiceLevel(level) {
741
+ const idx = this.provForm.service_levels.findIndex(x => x == level);
742
+ if (idx >= 0) this.provForm.service_levels.splice(idx, 1);
743
+ else this.provForm.service_levels.push(level);
744
+ },
745
+
746
+ async saveProvider() {
747
+ this.loading = true;
748
+ try {
749
+ if (this.prov.editingId) {
750
+ await API.put(`/api/providers/${this.prov.editingId}`, this.provForm);
751
+ this.showToast("Provider updated", "success");
752
+ } else {
753
+ await API.post("/api/providers", this.provForm);
754
+ this.showToast("Provider added", "success");
755
+ }
756
+ this.prov.showAdd = false;
757
+ await this.loadProviders();
758
+ } catch (e) {
759
+ this.showToast(e.message, "error");
760
+ } finally {
761
+ this.loading = false;
762
+ }
763
+ },
764
+
765
+ async deleteProvider(id) {
766
+ try {
767
+ await API.del(`/api/providers/${id}`);
768
+ this.showToast("Provider deleted", "success");
769
+ this.prov.confirmDelete = null;
770
+ await this.loadProviders();
771
+ } catch (e) {
772
+ this.showToast(e.message, "error");
773
+ }
774
+ },
775
+
776
+ async saveRecurringDaysOff(provider) {
777
+ try {
778
+ await API.put(`/api/providers/${provider.id}/recurring-days-off`, { recurring_days_off: provider.recurring_days_off || [] });
779
+ this.showToast("Standing days off updated", "success");
780
+ await this.loadProviders();
781
+ } catch (e) {
782
+ this.showToast(e.message, "error");
783
+ }
784
+ },
785
+
786
+ toggleRecurringDay(provider, day) {
787
+ const list = provider.recurring_days_off || [];
788
+ const idx = list.indexOf(day);
789
+ if (idx >= 0) list.splice(idx, 1);
790
+ else list.push(day);
791
+ provider.recurring_days_off = list;
792
+ },
793
+
794
+ async addBlock(provider) {
795
+ const b = this.prov.newBlock;
796
+ const payload = {
797
+ date: b.date,
798
+ all_day: this.prov.newBlockType === "All Day",
799
+ start_time: this.prov.newBlockType === "Specific Time" ? b.start_time : null,
800
+ end_time: this.prov.newBlockType === "Specific Time" ? b.end_time : null,
801
+ title: b.title || "Unavailable",
802
+ };
803
+ try {
804
+ await API.post(`/api/providers/${provider.id}/days-off`, payload);
805
+ this.showToast("Unavailable block added", "success");
806
+ this.prov.newBlock = { title: "", date: b.date, start_time: "08:00", end_time: "12:00" };
807
+ await this.loadProviders();
808
+ } catch (e) {
809
+ this.showToast(e.message, "error");
810
+ }
811
+ },
812
+
813
+ async removeBlock(providerId, index) {
814
+ try {
815
+ await API.del(`/api/providers/${providerId}/days-off/${index}`);
816
+ this.showToast("Block removed", "success");
817
+ await this.loadProviders();
818
+ } catch (e) {
819
+ this.showToast(e.message, "error");
820
+ }
821
+ },
822
+
823
+ async addOnCall(provider) {
824
+ if (!this.prov.newOnCallDate) return;
825
+ try {
826
+ await API.post(`/api/providers/${provider.id}/on-call`, { date: this.prov.newOnCallDate });
827
+ this.showToast("On-call day added", "success");
828
+ await this.loadProviders();
829
+ } catch (e) {
830
+ this.showToast(e.message, "error");
831
+ }
832
+ },
833
+
834
+ async toggleOnCallDate(provider, date) {
835
+ const isOn = (provider.on_call || []).includes(date);
836
+ try {
837
+ await API.post(`/api/providers/${provider.id}/on-call/toggle`, { date, on_call: !isOn });
838
+ this.showToast(isOn ? "On-call removed" : "On-call set", "success");
839
+ await this.loadProviders();
840
+ } catch (e) {
841
+ this.showToast(e.message, "error");
842
+ }
843
+ },
844
+
845
+ async removeOnCall(providerId, date) {
846
+ try {
847
+ await API.del(`/api/providers/${providerId}/on-call/${date}`);
848
+ this.showToast("On-call day removed", "success");
849
+ await this.loadProviders();
850
+ } catch (e) {
851
+ this.showToast(e.message, "error");
852
+ }
853
+ },
854
+
855
+ formatBlock(off) {
856
+ if (typeof off === "string") return `${off} — All Day`;
857
+ if (off.all_day) return `${off.date} — All Day — ${off.title || "Unavailable"}`;
858
+ return `${off.date} ${off.start_time}–${off.end_time} — ${off.title || "Unavailable"}`;
859
+ },
860
+
861
+ // Compact summary like "May 30" (all day) or "May 30 9:00 AM–12:00 PM".
862
+ formatBlockShort(off) {
863
+ if (typeof off === "string") return fmt.dateShort(off);
864
+ const d = fmt.dateShort(off.date);
865
+ if (off.all_day) return d;
866
+ return `${d} ${fmt.time(off.start_time)}–${fmt.time(off.end_time)}`;
867
+ },
868
+
869
+ // Returns {block, originalIndex} pairs for blocks whose date is today or later.
870
+ // Preserves original index so removeBlock still targets the right sheet row.
871
+ upcomingBlocks(provider) {
872
+ const today = fmt.today();
873
+ const out = [];
874
+ (provider.days_off || []).forEach((off, i) => {
875
+ const d = typeof off === "string" ? off : off?.date;
876
+ if (d && d >= today) out.push({ block: off, originalIndex: i });
877
+ });
878
+ return out;
879
+ },
880
+
881
+ // ============================================================
882
+ // PATIENTS view
883
+ // ============================================================
884
+ openAddPatient() {
885
+ this.pat.editingId = null;
886
+ this.patForm = { name: "", email: "", address: "", notes: "" };
887
+ this.pat.showAdd = true;
888
+ },
889
+
890
+ openEditPatient(p) {
891
+ this.pat.editingId = p.id;
892
+ this.patForm = { name: p.name || "", email: p.email || "", address: p.address || "", notes: p.notes || "" };
893
+ this.pat.showAdd = true;
894
+ },
895
+
896
+ async savePatient() {
897
+ this.loading = true;
898
+ try {
899
+ if (this.pat.editingId) {
900
+ await API.put(`/api/patients/${this.pat.editingId}`, this.patForm);
901
+ this.showToast("Patient updated", "success");
902
+ } else {
903
+ await API.post("/api/patients", this.patForm);
904
+ this.showToast("Patient added", "success");
905
+ }
906
+ this.pat.showAdd = false;
907
+ await this.loadPatients();
908
+ } catch (e) {
909
+ this.showToast(e.message, "error");
910
+ } finally {
911
+ this.loading = false;
912
+ }
913
+ },
914
+
915
+ async deletePatient(id) {
916
+ try {
917
+ await API.del(`/api/patients/${id}`);
918
+ this.showToast("Patient deleted", "success");
919
+ this.pat.confirmDelete = null;
920
+ await this.loadPatients();
921
+ } catch (e) {
922
+ this.showToast(e.message, "error");
923
+ }
924
+ },
925
+
926
+ // ============================================================
927
+ // CALENDAR view
928
+ // ============================================================
929
+ async mountCalendar() {
930
+ const el = document.getElementById("calendar-root");
931
+ if (!el || typeof FullCalendar === "undefined") return;
932
+ this.cal.providerLegend = this.providers.map(p => ({ id: p.id, name: p.name }));
933
+ if (this.cal.selectedProviderIds.length === 0) {
934
+ this.cal.selectedProviderIds = this.providers.map(p => p.id);
935
+ }
936
+ this.cal.instance = new FullCalendar.Calendar(el, {
937
+ initialView: "timeGridWeek",
938
+ headerToolbar: { left: "prev,next today", center: "title", right: "dayGridMonth,timeGridWeek,timeGridDay,listWeek" },
939
+ height: 720,
940
+ slotMinTime: "00:00:00",
941
+ slotMaxTime: "24:00:00",
942
+ scrollTime: "07:00:00",
943
+ nowIndicator: true,
944
+ now: () => this.platformNow(),
945
+ events: (info, success, failure) => this.fetchCalEvents(info, success, failure),
946
+ eventClick: (info) => {
947
+ const p = info.event.extendedProps;
948
+ if (p.type === "appointment" || p.type === "days_off" || p.type === "on_call") {
949
+ this.cal.selectedEvent = { ...p, eventStart: info.event.start, eventEnd: info.event.end, title: info.event.title };
950
+ }
951
+ },
952
+ });
953
+ this.cal.instance.render();
954
+ },
955
+
956
+ async fetchCalEvents(info, success, failure) {
957
+ try {
958
+ const params = new URLSearchParams({
959
+ start: info.startStr,
960
+ end: info.endStr,
961
+ show_unavailable: this.cal.showUnavailable ? "true" : "false",
962
+ show_travel: this.cal.showTravel ? "true" : "false",
963
+ show_oncall: this.cal.showOnCall ? "true" : "false",
964
+ });
965
+ if (this.cal.selectedProviderIds.length) params.set("provider_ids", this.cal.selectedProviderIds.join(","));
966
+ const res = await API.get(`/api/calendar/events?${params.toString()}`);
967
+ this.cal.providerLegend = res.providers || [];
968
+ success(res.events || []);
969
+ } catch (e) {
970
+ failure(e);
971
+ this.showToast(e.message, "error");
972
+ }
973
+ },
974
+
975
+ toggleCalProvider(id) {
976
+ const i = this.cal.selectedProviderIds.indexOf(id);
977
+ if (i >= 0) this.cal.selectedProviderIds.splice(i, 1);
978
+ else this.cal.selectedProviderIds.push(id);
979
+ if (this.cal.instance) this.cal.instance.refetchEvents();
980
+ },
981
+
982
+ toggleAllProviders() {
983
+ if (this.cal.selectedProviderIds.length === this.providers.length) {
984
+ this.cal.selectedProviderIds = [];
985
+ } else {
986
+ this.cal.selectedProviderIds = this.providers.map(p => p.id);
987
+ }
988
+ if (this.cal.instance) this.cal.instance.refetchEvents();
989
+ },
990
+
991
+ refetchCalendar() {
992
+ if (this.cal.instance) this.cal.instance.refetchEvents();
993
+ },
994
+
995
+ // ============================================================
996
+ // AUDIT
997
+ // ============================================================
998
+ async loadAudit() {
999
+ try {
1000
+ this.audit.actionTypes = await API.get("/api/audit/actions");
1001
+ } catch (e) {}
1002
+ await this.refreshAudit();
1003
+ },
1004
+
1005
+ async refreshAudit() {
1006
+ try {
1007
+ const params = new URLSearchParams();
1008
+ if (this.audit.search) params.set("search", this.audit.search);
1009
+ if (this.audit.action && this.audit.action !== "All") params.set("action", this.audit.action);
1010
+ this.audit.entries = await API.get(`/api/audit?${params.toString()}`);
1011
+ } catch (e) {
1012
+ this.showToast(e.message, "error");
1013
+ }
1014
+ },
1015
+
1016
+ // ============================================================
1017
+ // PROVIDER PORTAL
1018
+ // ============================================================
1019
+ async loadPortal() {
1020
+ if (!this.providerId) return;
1021
+ try {
1022
+ this.portal.self = await API.get(`/api/providers/${this.providerId}`);
1023
+ this.portal.editForm = {
1024
+ email: this.portal.self.email || "",
1025
+ address: this.portal.self.address || "",
1026
+ work_hours: { ...(this.portal.self.work_hours || { start: "08:00", end: "17:00" }) },
1027
+ lunch_break: { ...(this.portal.self.lunch_break || { start: "12:00", end: "13:00" }) },
1028
+ recurring_days_off: [...(this.portal.self.recurring_days_off || [])],
1029
+ };
1030
+ if (this.portal.tab === "schedule") {
1031
+ await this.$nextTick();
1032
+ this.mountPortalCalendar();
1033
+ }
1034
+ } catch (e) {
1035
+ this.showToast(e.message, "error");
1036
+ }
1037
+ },
1038
+
1039
+ async savePortalProfile() {
1040
+ if (!this.providerId) return;
1041
+ try {
1042
+ await API.put(`/api/providers/${this.providerId}`, {
1043
+ email: this.portal.editForm.email,
1044
+ address: this.portal.editForm.address,
1045
+ work_hours: this.portal.editForm.work_hours,
1046
+ lunch_break: this.portal.editForm.lunch_break,
1047
+ });
1048
+ this.showToast("Profile updated", "success");
1049
+ await this.loadPortal();
1050
+ } catch (e) {
1051
+ this.showToast(e.message, "error");
1052
+ }
1053
+ },
1054
+
1055
+ togglePortalRecurringDay(day) {
1056
+ const list = this.portal.editForm.recurring_days_off;
1057
+ const i = list.indexOf(day);
1058
+ if (i >= 0) list.splice(i, 1);
1059
+ else list.push(day);
1060
+ },
1061
+
1062
+ async savePortalRecurring() {
1063
+ try {
1064
+ await API.put(`/api/providers/${this.providerId}/recurring-days-off`, { recurring_days_off: this.portal.editForm.recurring_days_off });
1065
+ this.showToast("Standing days off updated", "success");
1066
+ await this.loadPortal();
1067
+ } catch (e) {
1068
+ this.showToast(e.message, "error");
1069
+ }
1070
+ },
1071
+
1072
+ async addPortalBlock() {
1073
+ const b = this.portal.newBlock;
1074
+ const payload = {
1075
+ date: b.date,
1076
+ all_day: this.portal.newBlockType === "All Day",
1077
+ start_time: this.portal.newBlockType === "Specific Time" ? b.start_time : null,
1078
+ end_time: this.portal.newBlockType === "Specific Time" ? b.end_time : null,
1079
+ title: b.title || "Unavailable",
1080
+ };
1081
+ try {
1082
+ await API.post(`/api/providers/${this.providerId}/days-off`, payload);
1083
+ this.showToast("Block added", "success");
1084
+ this.portal.newBlock = { title: "", date: b.date, start_time: "08:00", end_time: "12:00" };
1085
+ await this.loadPortal();
1086
+ } catch (e) {
1087
+ this.showToast(e.message, "error");
1088
+ }
1089
+ },
1090
+
1091
+ async removePortalBlock(index) {
1092
+ try {
1093
+ await API.del(`/api/providers/${this.providerId}/days-off/${index}`);
1094
+ this.showToast("Block removed", "success");
1095
+ await this.loadPortal();
1096
+ } catch (e) {
1097
+ this.showToast(e.message, "error");
1098
+ }
1099
+ },
1100
+
1101
+ async addPortalOnCall() {
1102
+ if (!this.portal.newOnCallDate) return;
1103
+ try {
1104
+ await API.post(`/api/providers/${this.providerId}/on-call`, { date: this.portal.newOnCallDate });
1105
+ this.showToast("On-call day added", "success");
1106
+ await this.loadPortal();
1107
+ } catch (e) {
1108
+ this.showToast(e.message, "error");
1109
+ }
1110
+ },
1111
+
1112
+ async togglePortalOnCall(date) {
1113
+ const isOn = (this.portal.self?.on_call || []).includes(date);
1114
+ try {
1115
+ await API.post(`/api/providers/${this.providerId}/on-call/toggle`, { date, on_call: !isOn });
1116
+ this.showToast(isOn ? "On-call removed" : "On-call set", "success");
1117
+ await this.loadPortal();
1118
+ } catch (e) {
1119
+ this.showToast(e.message, "error");
1120
+ }
1121
+ },
1122
+
1123
+ async removePortalOnCall(date) {
1124
+ try {
1125
+ await API.del(`/api/providers/${this.providerId}/on-call/${date}`);
1126
+ this.showToast("Removed", "success");
1127
+ await this.loadPortal();
1128
+ } catch (e) {
1129
+ this.showToast(e.message, "error");
1130
+ }
1131
+ },
1132
+
1133
+ async switchPortalTab(tab) {
1134
+ this.portal.tab = tab;
1135
+ if (this.portal.calendar) { this.portal.calendar.destroy(); this.portal.calendar = null; }
1136
+ if (tab === "schedule") {
1137
+ await this.$nextTick();
1138
+ this.mountPortalCalendar();
1139
+ }
1140
+ },
1141
+
1142
+ mountPortalCalendar() {
1143
+ const el = document.getElementById("portal-calendar-root");
1144
+ if (!el || typeof FullCalendar === "undefined") return;
1145
+ this.portal.calendar = new FullCalendar.Calendar(el, {
1146
+ initialView: "timeGridWeek",
1147
+ headerToolbar: { left: "prev,next today", center: "title", right: "dayGridMonth,timeGridWeek,timeGridDay,listWeek" },
1148
+ height: 720,
1149
+ slotMinTime: "00:00:00",
1150
+ slotMaxTime: "24:00:00",
1151
+ scrollTime: "07:00:00",
1152
+ nowIndicator: true,
1153
+ now: () => this.platformNow(),
1154
+ events: async (info, success, failure) => {
1155
+ try {
1156
+ const params = new URLSearchParams({ start: info.startStr, end: info.endStr, show_unavailable: "true", show_travel: "true" });
1157
+ const res = await API.get(`/api/calendar/events?${params.toString()}`);
1158
+ success(res.events || []);
1159
+ } catch (e) { failure(e); }
1160
+ },
1161
+ eventClick: (info) => {
1162
+ const p = info.event.extendedProps;
1163
+ this.portal.selectedEvent = { ...p, eventStart: info.event.start, eventEnd: info.event.end, title: info.event.title };
1164
+ },
1165
+ });
1166
+ this.portal.calendar.render();
1167
+ },
1168
+
1169
+ formatTime: (t) => fmt.time(t),
1170
+ formatDate: (d) => fmt.date(d),
1171
+ formatDateShort: (d) => fmt.dateShort(d),
1172
+ formatTs(ts) {
1173
+ if (!ts) return "";
1174
+ const d = new Date(ts);
1175
+ return d.toLocaleString();
1176
+ },
1177
+ };
1178
+ }
1179
+
1180
+ window.appState = appState;
templates/index.html ADDED
@@ -0,0 +1,1268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>SOS Care Now — Scheduling</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
9
+ <link href="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.10/index.global.min.css" rel="stylesheet" />
10
+ <link rel="stylesheet" href="/static/css/app.css" />
11
+ <script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.10/index.global.min.js"></script>
12
+ <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js"></script>
13
+ <script src="/static/js/api.js?v=5"></script>
14
+ <script src="/static/js/app.js?v=5"></script>
15
+ </head>
16
+ <body x-data="appState()" x-init="init()" x-cloak>
17
+
18
+ <!-- ============================================================ -->
19
+ <!-- LOGIN -->
20
+ <!-- ============================================================ -->
21
+ <template x-if="!authenticated">
22
+ <div class="login-bg min-h-screen flex items-center justify-center p-6">
23
+ <div class="card w-full max-w-md p-8">
24
+ <div class="text-center mb-6">
25
+ <div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-blue-600 text-white text-2xl font-bold mb-3">SOS</div>
26
+ <h1 class="text-2xl font-bold">SOS Care Now</h1>
27
+ <p class="text-sm text-slate-500">Medical Scheduling Agent</p>
28
+ </div>
29
+
30
+ <div class="flex bg-slate-100 rounded-lg p-1 mb-5 text-sm">
31
+ <button
32
+ @click="loginMode = 'admin'; loginError = ''"
33
+ :class="loginMode === 'admin' ? 'bg-white shadow-sm font-semibold text-slate-900' : 'text-slate-500'"
34
+ class="flex-1 py-2 rounded-md transition-all">Admin</button>
35
+ <button
36
+ @click="loginMode = 'provider'; loginError = ''"
37
+ :class="loginMode === 'provider' ? 'bg-white shadow-sm font-semibold text-slate-900' : 'text-slate-500'"
38
+ class="flex-1 py-2 rounded-md transition-all">Provider</button>
39
+ </div>
40
+
41
+ <form @submit.prevent="submitLogin()" class="space-y-4">
42
+ <template x-if="loginMode === 'admin'">
43
+ <div class="space-y-3">
44
+ <div>
45
+ <label class="text-sm font-medium text-slate-700">Username</label>
46
+ <input type="text" x-model="loginForm.username" class="input mt-1" required autofocus />
47
+ </div>
48
+ <div>
49
+ <label class="text-sm font-medium text-slate-700">Password</label>
50
+ <input type="password" x-model="loginForm.password" class="input mt-1" required />
51
+ </div>
52
+ </div>
53
+ </template>
54
+ <template x-if="loginMode === 'provider'">
55
+ <div class="space-y-3">
56
+ <div>
57
+ <label class="text-sm font-medium text-slate-700">Provider</label>
58
+ <select x-model="providerLoginForm.name" class="select mt-1" required>
59
+ <option value="" disabled>Select your name</option>
60
+ <template x-for="p in providerOptions" :key="p.id">
61
+ <option :value="p.name" x-text="p.name"></option>
62
+ </template>
63
+ </select>
64
+ </div>
65
+ <div>
66
+ <label class="text-sm font-medium text-slate-700">Password</label>
67
+ <input type="password" x-model="providerLoginForm.password" class="input mt-1" required />
68
+ </div>
69
+ </div>
70
+ </template>
71
+
72
+ <template x-if="loginError">
73
+ <div class="text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2" x-text="loginError"></div>
74
+ </template>
75
+
76
+ <button type="submit" class="btn btn-primary w-full" :disabled="loginBusy">
77
+ <span x-show="!loginBusy">Sign In</span>
78
+ <span x-show="loginBusy" class="spinner"></span>
79
+ </button>
80
+ </form>
81
+ </div>
82
+ </div>
83
+ </template>
84
+
85
+ <!-- ============================================================ -->
86
+ <!-- DASHBOARD SHELL -->
87
+ <!-- ============================================================ -->
88
+ <template x-if="authenticated">
89
+ <div class="flex min-h-screen">
90
+ <!-- Sidebar -->
91
+ <aside class="sidebar w-64 flex-shrink-0 hidden md:flex flex-col text-slate-300">
92
+ <div class="px-6 py-6 border-b border-white/10">
93
+ <div class="flex items-center gap-3">
94
+ <div class="w-10 h-10 rounded-xl bg-blue-600 text-white flex items-center justify-center font-bold">SOS</div>
95
+ <div>
96
+ <div class="text-white font-semibold">SOS Care Now</div>
97
+ <div class="text-xs text-slate-400">Scheduling</div>
98
+ </div>
99
+ </div>
100
+ </div>
101
+ <nav class="flex-1 px-3 py-4 space-y-1">
102
+ <template x-if="userRole === 'admin'">
103
+ <div>
104
+ <div class="sidebar-link" :class="view === 'schedule' && 'active'" @click="view = 'schedule'">
105
+ <span>📅</span><span>Schedule Visit</span>
106
+ </div>
107
+ <div class="sidebar-link" :class="view === 'providers' && 'active'" @click="view = 'providers'">
108
+ <span>🩺</span><span>Providers</span>
109
+ </div>
110
+ <div class="sidebar-link" :class="view === 'patients' && 'active'" @click="view = 'patients'">
111
+ <span>👥</span><span>Patients</span>
112
+ </div>
113
+ <div class="sidebar-link" :class="view === 'appointments' && 'active'" @click="view = 'appointments'">
114
+ <span>📋</span><span>Appointments</span>
115
+ </div>
116
+ <div class="sidebar-link" :class="view === 'calendar' && 'active'" @click="view = 'calendar'">
117
+ <span>🗓️</span><span>Calendar</span>
118
+ </div>
119
+ <div class="sidebar-link" :class="view === 'audit' && 'active'" @click="view = 'audit'">
120
+ <span>📜</span><span>Audit Log</span>
121
+ </div>
122
+ <div class="sidebar-link" :class="view === 'settings' && 'active'" @click="view = 'settings'">
123
+ <span>⚙️</span><span>Settings</span>
124
+ </div>
125
+ </div>
126
+ </template>
127
+ <template x-if="userRole === 'provider'">
128
+ <div>
129
+ <div class="sidebar-link" :class="view === 'portal' && 'active'" @click="view = 'portal'">
130
+ <span>👤</span><span>My Portal</span>
131
+ </div>
132
+ </div>
133
+ </template>
134
+ </nav>
135
+ <div class="px-4 py-4 border-t border-white/10">
136
+ <div class="text-xs text-slate-400 mb-2">Logged in as</div>
137
+ <div class="text-white font-medium text-sm mb-3" x-text="currentUser"></div>
138
+ <button class="btn btn-secondary w-full" @click="logout()">Logout</button>
139
+ </div>
140
+ </aside>
141
+
142
+ <!-- Mobile top bar -->
143
+ <div class="md:hidden fixed top-0 left-0 right-0 z-40 bg-slate-900 text-white px-4 py-3 flex justify-between items-center">
144
+ <div class="font-semibold">SOS Care Now</div>
145
+ <div class="flex items-center gap-3">
146
+ <span class="text-xs text-slate-300" x-text="currentUser"></span>
147
+ <button class="text-xs underline" @click="logout()">Logout</button>
148
+ </div>
149
+ </div>
150
+
151
+ <!-- Main -->
152
+ <main class="flex-1 px-4 md:px-8 py-6 md:py-8 overflow-x-hidden mt-12 md:mt-0">
153
+
154
+ <!-- Mobile nav tabs -->
155
+ <div class="md:hidden mb-4 flex gap-2 overflow-x-auto pb-2" x-show="userRole === 'admin'">
156
+ <button class="btn btn-sm" :class="view === 'schedule' ? 'btn-primary' : 'btn-secondary'" @click="view='schedule'">Schedule</button>
157
+ <button class="btn btn-sm" :class="view === 'providers' ? 'btn-primary' : 'btn-secondary'" @click="view='providers'">Providers</button>
158
+ <button class="btn btn-sm" :class="view === 'patients' ? 'btn-primary' : 'btn-secondary'" @click="view='patients'">Patients</button>
159
+ <button class="btn btn-sm" :class="view === 'appointments' ? 'btn-primary' : 'btn-secondary'" @click="view='appointments'">Appointments</button>
160
+ <button class="btn btn-sm" :class="view === 'calendar' ? 'btn-primary' : 'btn-secondary'" @click="view='calendar'">Calendar</button>
161
+ <button class="btn btn-sm" :class="view === 'audit' ? 'btn-primary' : 'btn-secondary'" @click="view='audit'">Audit</button>
162
+ <button class="btn btn-sm" :class="view === 'settings' ? 'btn-primary' : 'btn-secondary'" @click="view='settings'">Settings</button>
163
+ </div>
164
+
165
+ <!-- ====================== SCHEDULE VIEW ====================== -->
166
+ <div x-show="view === 'schedule'" x-cloak>
167
+ <header class="mb-6 flex justify-between items-end flex-wrap gap-3">
168
+ <div>
169
+ <h1 class="text-2xl font-bold">Schedule Visit</h1>
170
+ <p class="text-sm text-slate-500">Match a patient to an available provider using the scheduling agent.</p>
171
+ </div>
172
+ <div class="flex gap-2">
173
+ <button class="btn" :class="scheduleTab === 'new' ? 'btn-primary' : 'btn-secondary'" @click="scheduleTab = 'new'; startOver()">Schedule New Visit</button>
174
+ <button class="btn" :class="scheduleTab === 'cancel' ? 'btn-primary' : 'btn-secondary'" @click="openCancelTab()">Cancel Visit</button>
175
+ </div>
176
+ </header>
177
+
178
+ <!-- NEW visit -->
179
+ <div x-show="scheduleTab === 'new'">
180
+ <!-- FORM phase -->
181
+ <div x-show="sched.phase === 'form'" class="card p-6">
182
+ <h2 class="text-lg font-semibold mb-4">Visit Details</h2>
183
+ <div class="grid md:grid-cols-2 gap-4">
184
+ <div>
185
+ <label class="text-sm font-medium text-slate-700">Patient</label>
186
+ <select x-model="sched.patientId" @change="onPatientChange()" class="select mt-1">
187
+ <option value="" disabled>Select a patient</option>
188
+ <template x-for="p in patients" :key="p.id">
189
+ <option :value="p.id" x-text="p.name"></option>
190
+ </template>
191
+ </select>
192
+ </div>
193
+ <div>
194
+ <label class="text-sm font-medium text-slate-700">Visit Address</label>
195
+ <input type="text" x-model="sched.address" class="input mt-1" />
196
+ </div>
197
+ <template x-if="selectedPatient && selectedPatient.notes">
198
+ <div class="md:col-span-2">
199
+ <label class="text-sm font-medium text-slate-700">Patient Notes</label>
200
+ <div class="mt-1 text-sm text-slate-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 whitespace-pre-wrap" x-text="selectedPatient.notes"></div>
201
+ </div>
202
+ </template>
203
+ <div>
204
+ <label class="text-sm font-medium text-slate-700">Visit Level</label>
205
+ <select x-model="sched.visitLevel" class="select mt-1">
206
+ <template x-for="v in visitLevels" :key="v.key">
207
+ <option :value="String(v.key)" x-text="`${v.label} — ${v.duration_minutes} min`"></option>
208
+ </template>
209
+ </select>
210
+ </div>
211
+ <div>
212
+ <label class="text-sm font-medium text-slate-700">Preferred Date</label>
213
+ <input type="date" x-model="sched.preferredDate" :min="minSelectableDate" class="input mt-1" />
214
+ </div>
215
+ <div>
216
+ <label class="text-sm font-medium text-slate-700">Earliest Start</label>
217
+ <select x-model="sched.timeStart" class="select mt-1">
218
+ <template x-for="t in validStartTimes" :key="t">
219
+ <option :value="t" x-text="formatTime(t)"></option>
220
+ </template>
221
+ </select>
222
+ </div>
223
+ <div>
224
+ <label class="text-sm font-medium text-slate-700">Latest Start</label>
225
+ <select x-model="sched.timeEnd" class="select mt-1">
226
+ <template x-for="t in validEndTimes" :key="t">
227
+ <option :value="t" x-text="formatTime(t)"></option>
228
+ </template>
229
+ </select>
230
+ </div>
231
+ <template x-if="platformTimezone">
232
+ <div class="md:col-span-2 text-xs text-slate-500">
233
+ <span x-text="`All times shown are in ${platformTimezoneLabel} time.`"></span>
234
+ </div>
235
+ </template>
236
+ <template x-if="validStartTimes.length === 0">
237
+ <div class="md:col-span-2 text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
238
+ <span x-text="`No appointment times on this date meet the ${minLeadTimeHours}-hour lead time requirement. Pick a later date.`"></span>
239
+ </div>
240
+ </template>
241
+ </div>
242
+ <div class="mt-6 flex justify-end">
243
+ <button class="btn btn-primary" @click="findMatches()" :disabled="loading">
244
+ <span x-show="!loading">Find Matches</span>
245
+ <span x-show="loading" class="spinner"></span>
246
+ </button>
247
+ </div>
248
+ </div>
249
+
250
+ <!-- MATCHES phase -->
251
+ <div x-show="sched.phase === 'matches'" class="card p-6">
252
+ <div class="flex justify-between items-center mb-4">
253
+ <h2 class="text-lg font-semibold">Available Matches <span class="text-slate-400 font-normal text-sm" x-text="`(${matchesCount} found)`"></span></h2>
254
+ <div class="flex gap-2">
255
+ <button class="btn btn-ghost btn-sm" @click="backToForm()">← Back</button>
256
+ <button class="btn btn-ghost btn-sm" @click="startOver()">Start Over</button>
257
+ </div>
258
+ </div>
259
+
260
+ <div class="space-y-3">
261
+ <template x-for="group in groupedMatches" :key="group.provider_id">
262
+ <div class="border border-slate-200 rounded-xl overflow-hidden">
263
+ <div class="px-4 py-3 bg-slate-50 flex justify-between items-center">
264
+ <div class="flex items-center gap-2">
265
+ <template x-if="group.is_oncall"><span class="badge badge-emerald">🟢 ON CALL</span></template>
266
+ <span class="font-semibold" x-text="group.name"></span>
267
+ </div>
268
+ <span class="text-sm text-slate-500" x-text="`${group.min_travel} min travel`"></span>
269
+ </div>
270
+ <div class="p-3 grid sm:grid-cols-2 gap-3">
271
+ <template x-for="slot in group.slots" :key="slot.idx">
272
+ <div class="match-card" :class="sched.selectedIdx === slot.idx && 'selected'" @click="selectSlot(slot.idx)">
273
+ <div class="font-semibold" x-text="`${formatTime(slot.start_time)} – ${formatTime(slot.end_time)}`"></div>
274
+ <div class="text-xs text-slate-500 mt-1" x-text="formatDate(slot.date)"></div>
275
+ <div class="text-xs text-slate-500 mt-1">
276
+ <span x-text="`${slot.travel_time_minutes} min travel`"></span>
277
+ <template x-if="slot.recommended_departure_time">
278
+ <span> · depart by <span x-text="formatTime(slot.recommended_departure_time)"></span></span>
279
+ </template>
280
+ <template x-if="slot.has_nearby_visits"><span class="ml-1 badge badge-blue">nearby visits</span></template>
281
+ </div>
282
+ </div>
283
+ </template>
284
+ </div>
285
+ </div>
286
+ </template>
287
+ </div>
288
+
289
+ <div class="mt-6 flex justify-between items-center">
290
+ <div class="text-sm text-slate-600">
291
+ <template x-if="sched.selectedIdx !== null">
292
+ <span>Selected: <strong x-text="sched.matches[sched.selectedIdx]?.provider_name"></strong> on <span x-text="formatDate(sched.matches[sched.selectedIdx]?.date)"></span> at <span x-text="formatTime(sched.matches[sched.selectedIdx]?.start_time)"></span></span>
293
+ </template>
294
+ <template x-if="sched.selectedIdx === null">
295
+ <span class="text-slate-400">Select a slot to continue</span>
296
+ </template>
297
+ </div>
298
+ <button class="btn btn-primary" :disabled="sched.selectedIdx === null || loading" @click="bookAppointment()">
299
+ <span x-show="!loading">Book Appointment</span>
300
+ <span x-show="loading" class="spinner"></span>
301
+ </button>
302
+ </div>
303
+ </div>
304
+
305
+ <!-- BOOKED phase -->
306
+ <div x-show="sched.phase === 'booked'" class="card p-8 text-center">
307
+ <div class="w-16 h-16 mx-auto rounded-full bg-green-100 text-green-600 flex items-center justify-center text-3xl mb-4">✓</div>
308
+ <h2 class="text-xl font-semibold mb-2">Appointment booked!</h2>
309
+ <p class="text-slate-600 mb-4">
310
+ <strong x-text="sched.bookedAppt?.provider_name"></strong> will visit
311
+ <strong x-text="sched.bookedAppt?.patient_name"></strong> on
312
+ <span x-text="formatDate(sched.bookedAppt?.date)"></span> at
313
+ <span x-text="formatTime(sched.bookedAppt?.start_time)"></span>.
314
+ </p>
315
+ <template x-if="sched.bookedAppt?.recommended_departure_time">
316
+ <p class="text-sm text-slate-500">Recommended departure: <span x-text="formatTime(sched.bookedAppt?.recommended_departure_time)"></span></p>
317
+ </template>
318
+ <template x-if="sched.notificationsSent">
319
+ <p class="text-sm text-blue-600 mt-2">Confirmation emails sent.</p>
320
+ </template>
321
+ <div class="mt-6">
322
+ <button class="btn btn-primary" @click="startOver()">Schedule Another Visit</button>
323
+ </div>
324
+ </div>
325
+
326
+ <!-- ERROR phase -->
327
+ <div x-show="sched.phase === 'error'" class="card p-8 text-center">
328
+ <div class="w-16 h-16 mx-auto rounded-full bg-red-100 text-red-600 flex items-center justify-center text-3xl mb-4">!</div>
329
+ <h2 class="text-xl font-semibold mb-2">Could not schedule</h2>
330
+ <p class="text-slate-600" x-text="sched.error"></p>
331
+ <div class="mt-6 flex justify-center gap-2">
332
+ <button class="btn btn-ghost" @click="backToForm()">← Back</button>
333
+ <button class="btn btn-primary" @click="startOver()">Start Over</button>
334
+ </div>
335
+ </div>
336
+ </div>
337
+
338
+ <!-- CANCEL visit tab -->
339
+ <div x-show="scheduleTab === 'cancel'" class="card p-6">
340
+ <h2 class="text-lg font-semibold mb-4">Cancel an Appointment</h2>
341
+ <template x-if="appointments.length === 0">
342
+ <p class="text-sm text-slate-500">No future scheduled appointments.</p>
343
+ </template>
344
+
345
+ <div x-show="appointments.length > 0">
346
+ <div class="grid md:grid-cols-2 gap-4 mb-4">
347
+ <div>
348
+ <label class="text-sm font-medium text-slate-700">Filter by Patient</label>
349
+ <select x-model="cancel.patientFilter" class="select mt-1">
350
+ <option value="All">All</option>
351
+ <template x-for="p in patients" :key="p.id"><option :value="p.name" x-text="p.name"></option></template>
352
+ </select>
353
+ </div>
354
+ <div>
355
+ <label class="text-sm font-medium text-slate-700">Filter by Provider</label>
356
+ <select x-model="cancel.providerFilter" class="select mt-1">
357
+ <option value="All">All</option>
358
+ <template x-for="p in providers" :key="p.id"><option :value="p.name" x-text="p.name"></option></template>
359
+ </select>
360
+ </div>
361
+ </div>
362
+
363
+ <div class="table-wrap border border-slate-200 mb-4">
364
+ <table class="table">
365
+ <thead>
366
+ <tr><th></th><th>Date</th><th>Time</th><th>Patient</th><th>Provider</th><th>Level</th></tr>
367
+ </thead>
368
+ <tbody>
369
+ <template x-for="a in filteredCancelAppts" :key="a.id">
370
+ <tr :class="cancel.selectedId === a.id && 'bg-blue-50'" class="cursor-pointer" @click="cancel.selectedId = a.id">
371
+ <td><input type="radio" :checked="cancel.selectedId === a.id" /></td>
372
+ <td x-text="formatDateShort(a.date)"></td>
373
+ <td x-text="`${formatTime(a.start_time)} – ${formatTime(a.end_time)}`"></td>
374
+ <td x-text="a.patient_name"></td>
375
+ <td x-text="a.provider_name"></td>
376
+ <td x-text="a.visit_level"></td>
377
+ </tr>
378
+ </template>
379
+ </tbody>
380
+ </table>
381
+ </div>
382
+
383
+ <div class="flex justify-end">
384
+ <button class="btn btn-danger" @click="cancelAppointment()" :disabled="!cancel.selectedId || loading">
385
+ <span x-show="!loading">Cancel Appointment</span>
386
+ <span x-show="loading" class="spinner"></span>
387
+ </button>
388
+ </div>
389
+ </div>
390
+ </div>
391
+ </div>
392
+
393
+ <!-- ====================== PROVIDERS VIEW ====================== -->
394
+ <div x-show="view === 'providers'" x-cloak>
395
+ <header class="mb-6 flex justify-between items-end flex-wrap gap-3">
396
+ <div>
397
+ <h1 class="text-2xl font-bold">Provider Management</h1>
398
+ <p class="text-sm text-slate-500">Manage providers, availability, and on-call assignments.</p>
399
+ </div>
400
+ <button class="btn btn-primary" @click="openAddProvider()">+ Add Provider</button>
401
+ </header>
402
+
403
+ <!-- One unified card per provider. Click "Edit Availability" to expand the editor. -->
404
+ <template x-for="p in providers" :key="p.id">
405
+ <div class="card p-6 mb-4">
406
+ <!-- Header: name + category + actions -->
407
+ <div class="flex justify-between items-start gap-4 mb-4 flex-wrap">
408
+ <div>
409
+ <div class="flex items-center gap-2 mb-1">
410
+ <h3 class="text-lg font-semibold" x-text="p.name"></h3>
411
+ <span class="badge" :class="p.category === 'nurse' ? 'badge-blue' : 'badge-slate'" x-text="p.category"></span>
412
+ </div>
413
+ <p class="text-sm text-slate-500" x-text="p.email || '—'"></p>
414
+ </div>
415
+ <div class="flex gap-1 flex-wrap">
416
+ <button class="btn btn-ghost btn-sm" @click="openEditProvider(p)">Edit Details</button>
417
+ <button class="btn btn-secondary btn-sm" @click="prov.expandedId = (prov.expandedId === p.id ? null : p.id)">
418
+ <span x-text="prov.expandedId === p.id ? 'Hide Availability' : 'Edit Availability'"></span>
419
+ </button>
420
+ <button class="btn btn-ghost btn-sm text-red-600" @click="prov.confirmDelete = p.id">Delete</button>
421
+ </div>
422
+ </div>
423
+
424
+ <!-- Summary (always visible) -->
425
+ <div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 text-sm">
426
+ <div>
427
+ <div class="text-xs uppercase tracking-wide text-slate-500 mb-1">Hours</div>
428
+ <div x-text="`${p.work_hours?.start || '—'}–${p.work_hours?.end || ''}`"></div>
429
+ </div>
430
+ <div>
431
+ <div class="text-xs uppercase tracking-wide text-slate-500 mb-1">Lunch</div>
432
+ <div x-text="`${p.lunch_break?.start || '—'}–${p.lunch_break?.end || ''}`"></div>
433
+ </div>
434
+ <div>
435
+ <div class="text-xs uppercase tracking-wide text-slate-500 mb-1">Levels</div>
436
+ <div x-text="(p.service_levels || []).join(', ') || '—'"></div>
437
+ </div>
438
+ <div>
439
+ <div class="text-xs uppercase tracking-wide text-slate-500 mb-1">Standing Off</div>
440
+ <div x-text="(p.recurring_days_off || []).join(', ') || '—'"></div>
441
+ </div>
442
+ <div>
443
+ <div class="text-xs uppercase tracking-wide text-slate-500 mb-1">On-Call</div>
444
+ <template x-if="(p.on_call || []).length">
445
+ <div class="flex flex-wrap gap-1">
446
+ <template x-for="d in (p.on_call || [])" :key="d">
447
+ <span class="badge badge-emerald" x-text="fmt.dateShort(d)"></span>
448
+ </template>
449
+ </div>
450
+ </template>
451
+ <template x-if="!(p.on_call || []).length"><span class="text-slate-400">—</span></template>
452
+ </div>
453
+ <div class="col-span-2 md:col-span-3 lg:col-span-5">
454
+ <div class="text-xs uppercase tracking-wide text-slate-500 mb-1">Unavailable Blocks</div>
455
+ <template x-if="!upcomingBlocks(p).length"><span class="text-slate-400">—</span></template>
456
+ <template x-if="upcomingBlocks(p).length">
457
+ <div class="flex flex-wrap gap-1">
458
+ <template x-for="entry in upcomingBlocks(p)" :key="entry.originalIndex">
459
+ <span class="badge badge-slate" x-text="formatBlockShort(entry.block)"></span>
460
+ </template>
461
+ </div>
462
+ </template>
463
+ </div>
464
+ </div>
465
+
466
+ <!-- Availability editor (only when expanded) -->
467
+ <template x-if="prov.expandedId === p.id">
468
+ <div class="mt-6 pt-6 border-t border-slate-200">
469
+ <!-- Standing days off -->
470
+ <div class="mb-5">
471
+ <div class="text-sm font-semibold mb-2">Standing days off (every week)</div>
472
+ <div class="flex flex-wrap gap-2">
473
+ <template x-for="d in ['Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday']" :key="d">
474
+ <button
475
+ class="btn btn-sm"
476
+ :class="(p.recurring_days_off || []).includes(d) ? 'btn-primary' : 'btn-secondary'"
477
+ @click="toggleRecurringDay(p, d)" x-text="d">
478
+ </button>
479
+ </template>
480
+ <button class="btn btn-sm btn-secondary ml-auto" @click="saveRecurringDaysOff(p)">Save</button>
481
+ </div>
482
+ </div>
483
+
484
+ <!-- Unavailable blocks (upcoming only) -->
485
+ <div class="mb-5">
486
+ <div class="text-sm font-semibold mb-2">Unavailable Blocks</div>
487
+ <div class="space-y-1 mb-3">
488
+ <template x-if="!upcomingBlocks(p).length"><p class="text-xs text-slate-400">None scheduled.</p></template>
489
+ <template x-for="entry in upcomingBlocks(p)" :key="entry.originalIndex">
490
+ <div class="flex items-center justify-between text-sm bg-slate-50 px-3 py-2 rounded-lg">
491
+ <span x-text="formatBlock(entry.block)"></span>
492
+ <button class="btn btn-ghost btn-sm text-red-600" @click="removeBlock(p.id, entry.originalIndex)">Remove</button>
493
+ </div>
494
+ </template>
495
+ </div>
496
+ <div class="bg-slate-50 rounded-lg p-3 grid sm:grid-cols-4 gap-2 items-end">
497
+ <div>
498
+ <label class="text-xs text-slate-600">Type</label>
499
+ <select x-model="prov.newBlockType" class="select"><option>All Day</option><option>Specific Time</option></select>
500
+ </div>
501
+ <div>
502
+ <label class="text-xs text-slate-600">Date</label>
503
+ <input type="date" x-model="prov.newBlock.date" class="input" />
504
+ </div>
505
+ <div>
506
+ <label class="text-xs text-slate-600">Reason</label>
507
+ <input type="text" x-model="prov.newBlock.title" class="input" placeholder="Unavailable" />
508
+ </div>
509
+ <div>
510
+ <button class="btn btn-secondary w-full" @click="addBlock(p)">Add Block</button>
511
+ </div>
512
+ <template x-if="prov.newBlockType === 'Specific Time'">
513
+ <div class="sm:col-span-4 grid grid-cols-2 gap-2">
514
+ <div>
515
+ <label class="text-xs text-slate-600">Start</label>
516
+ <input type="time" x-model="prov.newBlock.start_time" class="input" />
517
+ </div>
518
+ <div>
519
+ <label class="text-xs text-slate-600">End</label>
520
+ <input type="time" x-model="prov.newBlock.end_time" class="input" />
521
+ </div>
522
+ </div>
523
+ </template>
524
+ </div>
525
+ </div>
526
+
527
+ <!-- On-Call -->
528
+ <div>
529
+ <div class="text-sm font-semibold mb-1">On-Call</div>
530
+ <p class="text-xs text-slate-500 mb-2">24-hour window from 07:00 to next day 07:00. Within this window, the provider supersedes all blocks and is ranked first.</p>
531
+ <div class="space-y-1 mb-3">
532
+ <template x-if="!(p.on_call || []).length"><p class="text-xs text-slate-400">No on-call days scheduled.</p></template>
533
+ <template x-for="d in (p.on_call || [])" :key="d">
534
+ <div class="flex items-center justify-between bg-emerald-50 px-3 py-2 rounded-lg">
535
+ <div class="flex items-center gap-3">
536
+ <div class="toggle on" @click="toggleOnCallDate(p, d)"></div>
537
+ <span class="text-sm font-medium" x-text="`${d} — 07:00 → next day 07:00`"></span>
538
+ </div>
539
+ <button class="btn btn-ghost btn-sm text-red-600" @click="removeOnCall(p.id, d)">Remove</button>
540
+ </div>
541
+ </template>
542
+ </div>
543
+ <div class="flex gap-2 items-end">
544
+ <div class="flex-1">
545
+ <label class="text-xs text-slate-600">Add on-call date</label>
546
+ <input type="date" x-model="prov.newOnCallDate" class="input" />
547
+ </div>
548
+ <button class="btn btn-secondary" @click="addOnCall(p)">Add On-Call Day</button>
549
+ </div>
550
+ </div>
551
+ </div>
552
+ </template>
553
+
554
+ <!-- Delete confirm -->
555
+ <template x-if="prov.confirmDelete === p.id">
556
+ <div class="mt-4 p-4 bg-red-50 border border-red-200 rounded-lg">
557
+ <p class="text-sm text-red-800 mb-3">
558
+ Delete <strong x-text="p.name"></strong>? Existing appointments will reference a missing provider. Cannot be undone.
559
+ </p>
560
+ <div class="flex gap-2 justify-end">
561
+ <button class="btn btn-secondary btn-sm" @click="prov.confirmDelete = null">Cancel</button>
562
+ <button class="btn btn-danger btn-sm" @click="deleteProvider(p.id)">Yes, Delete</button>
563
+ </div>
564
+ </div>
565
+ </template>
566
+ </div>
567
+ </template>
568
+ </div>
569
+
570
+ <!-- ====================== PATIENTS VIEW ====================== -->
571
+ <div x-show="view === 'patients'" x-cloak>
572
+ <header class="mb-6 flex justify-between items-end flex-wrap gap-3">
573
+ <div>
574
+ <h1 class="text-2xl font-bold">Patients</h1>
575
+ <p class="text-sm text-slate-500">Manage patient records. Addresses are geocoded on save.</p>
576
+ </div>
577
+ <button class="btn btn-primary" @click="openAddPatient()">+ Add Patient</button>
578
+ </header>
579
+
580
+ <div class="card overflow-hidden">
581
+ <div class="table-wrap">
582
+ <table class="table">
583
+ <thead>
584
+ <tr><th>Name</th><th>Email</th><th>Address</th><th>Notes</th><th></th></tr>
585
+ </thead>
586
+ <tbody>
587
+ <template x-for="p in patients" :key="p.id">
588
+ <tr>
589
+ <td class="font-medium" x-text="p.name"></td>
590
+ <td class="text-slate-500" x-text="p.email"></td>
591
+ <td x-text="p.address"></td>
592
+ <td class="text-slate-500 max-w-xs truncate" x-text="p.notes"></td>
593
+ <td>
594
+ <div class="flex gap-1">
595
+ <button class="btn btn-ghost btn-sm" @click="openEditPatient(p)">Edit</button>
596
+ <button class="btn btn-ghost btn-sm text-red-600" @click="pat.confirmDelete = p.id">Delete</button>
597
+ </div>
598
+ </td>
599
+ </tr>
600
+ </template>
601
+ </tbody>
602
+ </table>
603
+ </div>
604
+ </div>
605
+
606
+ <!-- Delete confirm -->
607
+ <template x-if="pat.confirmDelete">
608
+ <div class="modal-backdrop" @click.self="pat.confirmDelete = null">
609
+ <div class="modal p-6">
610
+ <h3 class="font-semibold mb-2">Delete patient?</h3>
611
+ <p class="text-sm text-slate-600 mb-4">This cannot be undone. Existing appointments will reference a missing patient.</p>
612
+ <div class="flex gap-2 justify-end">
613
+ <button class="btn btn-secondary" @click="pat.confirmDelete = null">Cancel</button>
614
+ <button class="btn btn-danger" @click="deletePatient(pat.confirmDelete)">Delete</button>
615
+ </div>
616
+ </div>
617
+ </div>
618
+ </template>
619
+ </div>
620
+
621
+ <!-- ====================== APPOINTMENTS VIEW ====================== -->
622
+ <div x-show="view === 'appointments'" x-cloak>
623
+ <header class="mb-6">
624
+ <h1 class="text-2xl font-bold">Appointments</h1>
625
+ <p class="text-sm text-slate-500">All scheduled, completed, and cancelled visits.</p>
626
+ </header>
627
+ <div class="card p-6">
628
+ <div class="grid md:grid-cols-3 gap-4 mb-4">
629
+ <div>
630
+ <label class="text-sm font-medium text-slate-700">Status</label>
631
+ <select x-model="apptView.statusFilter" class="select mt-1">
632
+ <option value="All">All</option>
633
+ <option value="scheduled">Scheduled</option>
634
+ <option value="completed">Completed</option>
635
+ <option value="cancelled">Cancelled</option>
636
+ </select>
637
+ </div>
638
+ <div>
639
+ <label class="text-sm font-medium text-slate-700">Patient</label>
640
+ <select x-model="apptView.patientFilter" class="select mt-1">
641
+ <option value="All">All</option>
642
+ <template x-for="p in patients" :key="p.id"><option :value="p.name" x-text="p.name"></option></template>
643
+ </select>
644
+ </div>
645
+ <div>
646
+ <label class="text-sm font-medium text-slate-700">Provider</label>
647
+ <select x-model="apptView.providerFilter" class="select mt-1">
648
+ <option value="All">All</option>
649
+ <template x-for="p in providers" :key="p.id"><option :value="p.name" x-text="p.name"></option></template>
650
+ </select>
651
+ </div>
652
+ </div>
653
+
654
+ <template x-if="platformTimezone">
655
+ <div class="text-xs text-slate-500 mb-2">
656
+ <span x-text="`All times shown are in ${platformTimezoneLabel} time.`"></span>
657
+ </div>
658
+ </template>
659
+
660
+ <template x-if="filteredAppointmentsList.length === 0">
661
+ <p class="text-sm text-slate-500">No appointments match these filters.</p>
662
+ </template>
663
+
664
+ <div x-show="filteredAppointmentsList.length > 0" class="table-wrap border border-slate-200">
665
+ <table class="table">
666
+ <thead>
667
+ <tr>
668
+ <th>Status</th>
669
+ <th>Date</th>
670
+ <th>Time</th>
671
+ <th>Patient</th>
672
+ <th>Provider</th>
673
+ <th>Level</th>
674
+ <th>Address</th>
675
+ </tr>
676
+ </thead>
677
+ <tbody>
678
+ <template x-for="a in filteredAppointmentsList" :key="a.id">
679
+ <tr>
680
+ <td><span :class="statusBadgeClass(a.status)" x-text="(a.status || 'scheduled')"></span></td>
681
+ <td x-text="formatDateShort(a.date)"></td>
682
+ <td x-text="`${formatTime(a.start_time)} – ${formatTime(a.end_time)}`"></td>
683
+ <td x-text="a.patient_name"></td>
684
+ <td x-text="a.provider_name"></td>
685
+ <td x-text="a.visit_level"></td>
686
+ <td class="text-xs text-slate-500" x-text="a.visit_address"></td>
687
+ </tr>
688
+ </template>
689
+ </tbody>
690
+ </table>
691
+ </div>
692
+ </div>
693
+ </div>
694
+
695
+ <!-- ====================== CALENDAR VIEW ====================== -->
696
+ <div x-show="view === 'calendar'" x-cloak>
697
+ <header class="mb-6">
698
+ <h1 class="text-2xl font-bold">Calendar</h1>
699
+ <p class="text-sm text-slate-500">All scheduled visits and unavailability across providers.</p>
700
+ </header>
701
+
702
+ <div class="grid lg:grid-cols-[260px_1fr] gap-6">
703
+ <div class="card p-4 h-fit">
704
+ <h3 class="font-semibold mb-3 text-sm">Providers</h3>
705
+ <button class="btn btn-secondary btn-sm w-full mb-3" @click="toggleAllProviders()">
706
+ <span x-show="cal.selectedProviderIds.length === providers.length">Deselect All</span>
707
+ <span x-show="cal.selectedProviderIds.length !== providers.length">Select All</span>
708
+ </button>
709
+ <div class="space-y-2 mb-4">
710
+ <template x-for="p in providers" :key="p.id">
711
+ <label class="flex items-center gap-2 text-sm cursor-pointer">
712
+ <input type="checkbox" :checked="cal.selectedProviderIds.includes(p.id)" @change="toggleCalProvider(p.id)" />
713
+ <span class="w-3 h-3 rounded-full" :style="`background-color: ${(cal.providerLegend.find(x => x.id === p.id) || {}).color || '#94a3b8'}`"></span>
714
+ <span x-text="p.name"></span>
715
+ </label>
716
+ </template>
717
+ </div>
718
+ <hr class="my-3 border-slate-200" />
719
+ <label class="flex items-center gap-2 text-sm cursor-pointer mb-2">
720
+ <input type="checkbox" x-model="cal.showUnavailable" @change="refetchCalendar()" />
721
+ <span>Show unavailable blocks</span>
722
+ </label>
723
+ <label class="flex items-center gap-2 text-sm cursor-pointer mb-2">
724
+ <input type="checkbox" x-model="cal.showTravel" @change="refetchCalendar()" />
725
+ <span>Show travel time</span>
726
+ </label>
727
+ <label class="flex items-center gap-2 text-sm cursor-pointer">
728
+ <input type="checkbox" x-model="cal.showOnCall" @change="refetchCalendar()" />
729
+ <span>Show on-call</span>
730
+ </label>
731
+ </div>
732
+
733
+ <div class="card p-4">
734
+ <template x-if="platformTimezone">
735
+ <div class="text-xs text-slate-500 mb-2">
736
+ <span x-text="`All times shown are in ${platformTimezoneLabel} time.`"></span>
737
+ </div>
738
+ </template>
739
+ <div id="calendar-root"></div>
740
+ </div>
741
+ </div>
742
+
743
+ <!-- Event detail modal -->
744
+ <template x-if="cal.selectedEvent">
745
+ <div class="modal-backdrop" @click.self="cal.selectedEvent = null">
746
+ <div class="modal p-6">
747
+ <div class="flex justify-between items-start mb-4">
748
+ <div>
749
+ <h3 class="font-semibold" x-text="cal.selectedEvent.title"></h3>
750
+ <p class="text-xs text-slate-500" x-text="cal.selectedEvent.type"></p>
751
+ </div>
752
+ <button class="btn btn-ghost btn-sm" @click="cal.selectedEvent = null">✕</button>
753
+ </div>
754
+ <template x-if="cal.selectedEvent.type === 'appointment'">
755
+ <div class="space-y-2 text-sm">
756
+ <div><span class="text-slate-500">Patient:</span> <strong x-text="cal.selectedEvent.patient_name"></strong></div>
757
+ <div><span class="text-slate-500">Provider:</span> <strong x-text="cal.selectedEvent.provider_name"></strong></div>
758
+ <div><span class="text-slate-500">Date:</span> <span x-text="formatDate(cal.selectedEvent.date)"></span></div>
759
+ <div><span class="text-slate-500">Time:</span> <span x-text="`${formatTime(cal.selectedEvent.start_time)} – ${formatTime(cal.selectedEvent.end_time)}`"></span></div>
760
+ <div><span class="text-slate-500">Level:</span> <span x-text="cal.selectedEvent.visit_level"></span></div>
761
+ <div><span class="text-slate-500">Address:</span> <span x-text="cal.selectedEvent.visit_address"></span></div>
762
+ <template x-if="cal.selectedEvent.recommended_departure_time">
763
+ <div><span class="text-slate-500">Depart by:</span> <span x-text="formatTime(cal.selectedEvent.recommended_departure_time)"></span></div>
764
+ </template>
765
+ </div>
766
+ </template>
767
+ <template x-if="cal.selectedEvent.type === 'days_off'">
768
+ <div class="text-sm">
769
+ <div><span class="text-slate-500">Provider:</span> <strong x-text="cal.selectedEvent.provider_name"></strong></div>
770
+ <div><span class="text-slate-500">Reason:</span> <span x-text="cal.selectedEvent.title"></span></div>
771
+ </div>
772
+ </template>
773
+ <template x-if="cal.selectedEvent.type === 'on_call'">
774
+ <div class="text-sm">
775
+ <p>Provider is on-call for this 24-hour window.</p>
776
+ </div>
777
+ </template>
778
+ </div>
779
+ </div>
780
+ </template>
781
+ </div>
782
+
783
+ <!-- ====================== AUDIT LOG VIEW ====================== -->
784
+ <div x-show="view === 'audit'" x-cloak>
785
+ <header class="mb-6">
786
+ <h1 class="text-2xl font-bold">Audit Log</h1>
787
+ <p class="text-sm text-slate-500">All system actions, newest first.</p>
788
+ </header>
789
+
790
+ <div class="card p-4 mb-4">
791
+ <div class="grid md:grid-cols-3 gap-3">
792
+ <div>
793
+ <label class="text-sm font-medium text-slate-700">Search</label>
794
+ <input type="text" x-model.debounce.300ms="audit.search" @input="refreshAudit()" class="input mt-1" placeholder="Search details, user, action..." />
795
+ </div>
796
+ <div>
797
+ <label class="text-sm font-medium text-slate-700">Action Type</label>
798
+ <select x-model="audit.action" @change="refreshAudit()" class="select mt-1">
799
+ <option>All</option>
800
+ <template x-for="a in audit.actionTypes" :key="a"><option :value="a" x-text="a"></option></template>
801
+ </select>
802
+ </div>
803
+ <div class="flex items-end">
804
+ <button class="btn btn-secondary w-full" @click="refreshAudit()">Refresh</button>
805
+ </div>
806
+ </div>
807
+ </div>
808
+
809
+ <div class="card overflow-hidden">
810
+ <div class="table-wrap">
811
+ <table class="table">
812
+ <thead>
813
+ <tr><th>Timestamp</th><th>User</th><th>Action</th><th>Details</th></tr>
814
+ </thead>
815
+ <tbody>
816
+ <template x-for="(e, i) in audit.entries" :key="i">
817
+ <tr>
818
+ <td class="text-slate-500" x-text="formatTs(e.timestamp)"></td>
819
+ <td x-text="e.user"></td>
820
+ <td><span class="badge badge-slate" x-text="e.action"></span></td>
821
+ <td x-text="e.details"></td>
822
+ </tr>
823
+ </template>
824
+ <template x-if="audit.entries.length === 0">
825
+ <tr><td colspan="4" class="text-center text-slate-400">No entries.</td></tr>
826
+ </template>
827
+ </tbody>
828
+ </table>
829
+ </div>
830
+ </div>
831
+ </div>
832
+
833
+ <!-- ====================== SETTINGS VIEW ====================== -->
834
+ <div x-show="view === 'settings'" x-cloak>
835
+ <header class="mb-6">
836
+ <h1 class="text-2xl font-bold">Settings</h1>
837
+ <p class="text-sm text-slate-500">Tune scheduling behavior. Changes apply to the next scheduling action.</p>
838
+ </header>
839
+
840
+ <template x-if="!sett.form">
841
+ <div class="card p-6 text-slate-400 text-sm">Loading settings…</div>
842
+ </template>
843
+
844
+ <template x-if="sett.form">
845
+ <div class="space-y-4">
846
+ <!-- Scheduling rules -->
847
+ <div class="card p-6">
848
+ <h3 class="font-semibold mb-1">Scheduling rules</h3>
849
+ <p class="text-xs text-slate-500 mb-4">Used by the agent to determine when and how appointments can be booked.</p>
850
+ <div class="grid md:grid-cols-2 gap-4">
851
+ <div>
852
+ <label class="text-sm font-medium text-slate-700">Minimum lead time (hours)</label>
853
+ <input type="number" min="0" max="168" step="1" x-model.number="sett.form.min_lead_time_hours" class="input mt-1" />
854
+ <p class="text-xs text-slate-500 mt-1">Earliest an appointment can be scheduled relative to now.</p>
855
+ </div>
856
+ <div>
857
+ <label class="text-sm font-medium text-slate-700">Buffer between visits (minutes)</label>
858
+ <input type="number" min="0" max="120" step="5" x-model.number="sett.form.buffer_minutes" class="input mt-1" />
859
+ <p class="text-xs text-slate-500 mt-1">Extra time blocked after every visit, on top of travel time.</p>
860
+ </div>
861
+ <div class="md:col-span-2">
862
+ <label class="text-sm font-medium text-slate-700">On-call window start hour (HH:MM)</label>
863
+ <input type="text" x-model="sett.form.on_call_start_hour" placeholder="07:00" class="input mt-1" />
864
+ <p class="text-xs text-slate-500 mt-1">Start of the 24-hour on-call window. Default 07:00 (07:00 → next day 07:00).</p>
865
+ </div>
866
+ </div>
867
+ </div>
868
+
869
+ <!-- Visit levels -->
870
+ <div class="card p-6">
871
+ <h3 class="font-semibold mb-1">Visit level durations</h3>
872
+ <p class="text-xs text-slate-500 mb-4">Edit the duration in minutes for each visit level. Labels stay fixed.</p>
873
+ <div class="space-y-3">
874
+ <template x-for="lvl in visitLevelEntries" :key="lvl.key">
875
+ <div class="flex items-center gap-4">
876
+ <div class="w-32">
877
+ <div class="font-medium" x-text="lvl.label"></div>
878
+ <div class="text-xs text-slate-400" x-text="`key: ${lvl.key}`"></div>
879
+ </div>
880
+ <div class="flex-1 max-w-xs">
881
+ <div class="flex items-center gap-2">
882
+ <input
883
+ type="number" min="5" max="480" step="5"
884
+ :value="lvl.duration_minutes"
885
+ @input="updateVisitLevelDuration(lvl.key, $event.target.value)"
886
+ class="input" />
887
+ <span class="text-sm text-slate-500">minutes</span>
888
+ </div>
889
+ </div>
890
+ </div>
891
+ </template>
892
+ </div>
893
+ </div>
894
+
895
+ <!-- Regional / locale -->
896
+ <div class="card p-6">
897
+ <h3 class="font-semibold mb-1">Region & locale</h3>
898
+ <p class="text-xs text-slate-500 mb-4">⚠️ Changing the timezone affects how appointment times are interpreted by Google Calendar and email .ics invites going forward. Existing appointment records keep their original times.</p>
899
+ <div class="grid md:grid-cols-3 gap-4">
900
+ <div>
901
+ <label class="text-sm font-medium text-slate-700">Region name</label>
902
+ <input type="text" x-model="sett.form.region_name" class="input mt-1" />
903
+ </div>
904
+ <div>
905
+ <label class="text-sm font-medium text-slate-700">Timezone</label>
906
+ <select x-model="sett.form.timezone" class="select mt-1">
907
+ <template x-for="tz in timezoneOptions" :key="tz.key">
908
+ <option :value="tz.key" x-text="tz.label"></option>
909
+ </template>
910
+ </select>
911
+ </div>
912
+ <div>
913
+ <label class="text-sm font-medium text-slate-700">Service area</label>
914
+ <input type="text" x-model="sett.form.service_area" class="input mt-1" />
915
+ </div>
916
+ </div>
917
+ </div>
918
+
919
+ <!-- Agent model -->
920
+ <div class="card p-6">
921
+ <h3 class="font-semibold mb-1">Agent model</h3>
922
+ <p class="text-xs text-slate-500 mb-4">Used by the no-match suggestion node. Server restart required for the LLM client to pick up the change.</p>
923
+ <div>
924
+ <label class="text-sm font-medium text-slate-700">OpenAI model name</label>
925
+ <input type="text" x-model="sett.form.openai_model" class="input mt-1" />
926
+ </div>
927
+ </div>
928
+
929
+ <!-- Actions -->
930
+ <div class="flex justify-end gap-2">
931
+ <button class="btn btn-secondary" @click="resetSettings()">Reset to Defaults</button>
932
+ <button class="btn btn-primary" @click="saveSettings()" :disabled="sett.saving">
933
+ <span x-show="!sett.saving">Save Settings</span>
934
+ <span x-show="sett.saving" class="spinner"></span>
935
+ </button>
936
+ </div>
937
+
938
+ <!-- Reference table -->
939
+ <details class="card p-4">
940
+ <summary class="cursor-pointer text-sm font-medium text-slate-600">Current effective values</summary>
941
+ <div class="table-wrap mt-3">
942
+ <table class="table">
943
+ <thead><tr><th>Setting</th><th>Current</th><th>Default</th></tr></thead>
944
+ <tbody>
945
+ <template x-for="key in Object.keys(sett.current || {})" :key="key">
946
+ <tr>
947
+ <td class="font-mono text-xs" x-text="key"></td>
948
+ <td x-text="typeof sett.current[key] === 'object' ? JSON.stringify(sett.current[key]) : sett.current[key]"></td>
949
+ <td class="text-slate-500" x-text="typeof sett.defaults[key] === 'object' ? JSON.stringify(sett.defaults[key]) : sett.defaults[key]"></td>
950
+ </tr>
951
+ </template>
952
+ </tbody>
953
+ </table>
954
+ </div>
955
+ </details>
956
+ </div>
957
+ </template>
958
+ </div>
959
+
960
+ <!-- ====================== PROVIDER PORTAL ====================== -->
961
+ <div x-show="view === 'portal'" x-cloak>
962
+ <header class="mb-6 flex justify-between items-end">
963
+ <div>
964
+ <h1 class="text-2xl font-bold">Provider Portal</h1>
965
+ <template x-if="portal.self">
966
+ <p class="text-sm text-slate-500" x-text="`${portal.self.name} · ${portal.self.category}`"></p>
967
+ </template>
968
+ </div>
969
+ <div class="flex gap-2">
970
+ <button class="btn" :class="portal.tab === 'availability' ? 'btn-primary' : 'btn-secondary'" @click="switchPortalTab('availability')">My Availability</button>
971
+ <button class="btn" :class="portal.tab === 'schedule' ? 'btn-primary' : 'btn-secondary'" @click="switchPortalTab('schedule')">My Schedule</button>
972
+ </div>
973
+ </header>
974
+
975
+ <!-- AVAILABILITY tab -->
976
+ <div x-show="portal.tab === 'availability'" x-cloak>
977
+ <template x-if="portal.self">
978
+ <div class="space-y-4">
979
+ <div class="card p-6">
980
+ <h3 class="font-semibold mb-4">Profile</h3>
981
+ <div class="grid md:grid-cols-2 gap-4">
982
+ <div>
983
+ <label class="text-sm font-medium text-slate-700">Email</label>
984
+ <input type="email" x-model="portal.editForm.email" class="input mt-1" />
985
+ </div>
986
+ <div>
987
+ <label class="text-sm font-medium text-slate-700">Home Address</label>
988
+ <input type="text" x-model="portal.editForm.address" class="input mt-1" />
989
+ </div>
990
+ <div>
991
+ <label class="text-sm font-medium text-slate-700">Work Hours Start</label>
992
+ <input type="text" x-model="portal.editForm.work_hours.start" placeholder="HH:MM" class="input mt-1" />
993
+ </div>
994
+ <div>
995
+ <label class="text-sm font-medium text-slate-700">Work Hours End</label>
996
+ <input type="text" x-model="portal.editForm.work_hours.end" placeholder="HH:MM" class="input mt-1" />
997
+ </div>
998
+ <div>
999
+ <label class="text-sm font-medium text-slate-700">Lunch Start</label>
1000
+ <input type="text" x-model="portal.editForm.lunch_break.start" placeholder="HH:MM" class="input mt-1" />
1001
+ </div>
1002
+ <div>
1003
+ <label class="text-sm font-medium text-slate-700">Lunch End</label>
1004
+ <input type="text" x-model="portal.editForm.lunch_break.end" placeholder="HH:MM" class="input mt-1" />
1005
+ </div>
1006
+ </div>
1007
+ <div class="mt-4 flex justify-end">
1008
+ <button class="btn btn-primary" @click="savePortalProfile()">Save Profile</button>
1009
+ </div>
1010
+ </div>
1011
+
1012
+ <div class="card p-6">
1013
+ <h3 class="font-semibold mb-1">Standing Days Off</h3>
1014
+ <p class="text-xs text-slate-500 mb-3">Recurring weekly days you are not available.</p>
1015
+ <div class="flex flex-wrap gap-2 mb-3">
1016
+ <template x-for="d in ['Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday']" :key="d">
1017
+ <button
1018
+ class="btn btn-sm"
1019
+ :class="portal.editForm.recurring_days_off.includes(d) ? 'btn-primary' : 'btn-secondary'"
1020
+ @click="togglePortalRecurringDay(d)" x-text="d">
1021
+ </button>
1022
+ </template>
1023
+ </div>
1024
+ <div class="flex justify-end">
1025
+ <button class="btn btn-secondary btn-sm" @click="savePortalRecurring()">Save Standing Days</button>
1026
+ </div>
1027
+ </div>
1028
+
1029
+ <div class="card p-6">
1030
+ <h3 class="font-semibold mb-3">Unavailable Blocks</h3>
1031
+ <div class="space-y-1 mb-3">
1032
+ <template x-if="!upcomingBlocks(portal.self).length"><p class="text-xs text-slate-400">None scheduled.</p></template>
1033
+ <template x-for="entry in upcomingBlocks(portal.self)" :key="entry.originalIndex">
1034
+ <div class="flex items-center justify-between text-sm bg-slate-50 px-3 py-2 rounded-lg">
1035
+ <span x-text="formatBlock(entry.block)"></span>
1036
+ <button class="btn btn-ghost btn-sm text-red-600" @click="removePortalBlock(entry.originalIndex)">Remove</button>
1037
+ </div>
1038
+ </template>
1039
+ </div>
1040
+ <div class="bg-slate-50 rounded-lg p-3 grid sm:grid-cols-4 gap-2 items-end">
1041
+ <div>
1042
+ <label class="text-xs text-slate-600">Type</label>
1043
+ <select x-model="portal.newBlockType" class="select"><option>All Day</option><option>Specific Time</option></select>
1044
+ </div>
1045
+ <div>
1046
+ <label class="text-xs text-slate-600">Date</label>
1047
+ <input type="date" x-model="portal.newBlock.date" class="input" />
1048
+ </div>
1049
+ <div>
1050
+ <label class="text-xs text-slate-600">Reason</label>
1051
+ <input type="text" x-model="portal.newBlock.title" class="input" placeholder="Unavailable" />
1052
+ </div>
1053
+ <button class="btn btn-secondary" @click="addPortalBlock()">Add Block</button>
1054
+ <template x-if="portal.newBlockType === 'Specific Time'">
1055
+ <div class="sm:col-span-4 grid grid-cols-2 gap-2">
1056
+ <div>
1057
+ <label class="text-xs text-slate-600">Start</label>
1058
+ <input type="time" x-model="portal.newBlock.start_time" class="input" />
1059
+ </div>
1060
+ <div>
1061
+ <label class="text-xs text-slate-600">End</label>
1062
+ <input type="time" x-model="portal.newBlock.end_time" class="input" />
1063
+ </div>
1064
+ </div>
1065
+ </template>
1066
+ </div>
1067
+ </div>
1068
+
1069
+ <div class="card p-6">
1070
+ <h3 class="font-semibold mb-1">On-Call</h3>
1071
+ <p class="text-xs text-slate-500 mb-3">24-hour window from 07:00 of the date to 07:00 the next day.</p>
1072
+ <div class="space-y-1 mb-3">
1073
+ <template x-if="!(portal.self.on_call || []).length"><p class="text-xs text-slate-400">No on-call days scheduled.</p></template>
1074
+ <template x-for="d in (portal.self.on_call || [])" :key="d">
1075
+ <div class="flex items-center justify-between bg-emerald-50 px-3 py-2 rounded-lg">
1076
+ <div class="flex items-center gap-3">
1077
+ <div class="toggle on" @click="togglePortalOnCall(d)"></div>
1078
+ <span class="text-sm font-medium" x-text="`${d} — 07:00 → next day 07:00`"></span>
1079
+ </div>
1080
+ <button class="btn btn-ghost btn-sm text-red-600" @click="removePortalOnCall(d)">Remove</button>
1081
+ </div>
1082
+ </template>
1083
+ </div>
1084
+ <div class="flex gap-2 items-end">
1085
+ <div class="flex-1">
1086
+ <label class="text-xs text-slate-600">Add on-call date</label>
1087
+ <input type="date" x-model="portal.newOnCallDate" class="input" />
1088
+ </div>
1089
+ <button class="btn btn-secondary" @click="addPortalOnCall()">Add</button>
1090
+ </div>
1091
+ </div>
1092
+ </div>
1093
+ </template>
1094
+ </div>
1095
+
1096
+ <!-- SCHEDULE tab -->
1097
+ <div x-show="portal.tab === 'schedule'" x-cloak>
1098
+ <div class="card p-4">
1099
+ <template x-if="platformTimezone">
1100
+ <div class="text-xs text-slate-500 mb-2">
1101
+ <span x-text="`All times shown are in ${platformTimezoneLabel} time.`"></span>
1102
+ </div>
1103
+ </template>
1104
+ <div id="portal-calendar-root"></div>
1105
+ </div>
1106
+ <template x-if="portal.selectedEvent">
1107
+ <div class="modal-backdrop" @click.self="portal.selectedEvent = null">
1108
+ <div class="modal p-6">
1109
+ <div class="flex justify-between items-start mb-4">
1110
+ <h3 class="font-semibold" x-text="portal.selectedEvent.title"></h3>
1111
+ <button class="btn btn-ghost btn-sm" @click="portal.selectedEvent = null">✕</button>
1112
+ </div>
1113
+ <template x-if="portal.selectedEvent.type === 'appointment'">
1114
+ <div class="space-y-2 text-sm">
1115
+ <div>
1116
+ <span class="text-slate-500">Status:</span>
1117
+ <span :class="statusBadgeClass(portal.selectedEvent.status)" x-text="portal.selectedEvent.status || 'scheduled'"></span>
1118
+ </div>
1119
+ <div><span class="text-slate-500">Date:</span> <span x-text="formatDate(portal.selectedEvent.date)"></span></div>
1120
+ <div><span class="text-slate-500">Time:</span> <span x-text="`${formatTime(portal.selectedEvent.start_time)} – ${formatTime(portal.selectedEvent.end_time)}`"></span></div>
1121
+ <div><span class="text-slate-500">Level:</span> <span x-text="portal.selectedEvent.visit_level"></span></div>
1122
+ <div><span class="text-slate-500">Address:</span> <span x-text="portal.selectedEvent.visit_address"></span></div>
1123
+ <template x-if="portal.selectedEvent.recommended_departure_time">
1124
+ <div><span class="text-slate-500">Depart by:</span> <span x-text="formatTime(portal.selectedEvent.recommended_departure_time)"></span></div>
1125
+ </template>
1126
+ <div class="mt-4 flex justify-end">
1127
+ <template x-if="(portal.selectedEvent.status || 'scheduled') === 'scheduled'">
1128
+ <button
1129
+ class="btn btn-primary"
1130
+ :disabled="!canCompleteAppointment(portal.selectedEvent) || loading"
1131
+ :title="canCompleteAppointment(portal.selectedEvent) ? '' : 'Available once the appointment start time has passed.'"
1132
+ @click="markAppointmentComplete(portal.selectedEvent.appointment_id)"
1133
+ >
1134
+ <span x-show="!loading">Mark Complete</span>
1135
+ <span x-show="loading" class="spinner"></span>
1136
+ </button>
1137
+ </template>
1138
+ <template x-if="(portal.selectedEvent.status || 'scheduled') === 'completed'">
1139
+ <span class="text-xs text-slate-500">Completed<span x-show="portal.selectedEvent.completed_at" x-text="` · ${portal.selectedEvent.completed_at}`"></span></span>
1140
+ </template>
1141
+ </div>
1142
+ </div>
1143
+ </template>
1144
+ </div>
1145
+ </div>
1146
+ </template>
1147
+ </div>
1148
+ </div>
1149
+
1150
+ </main>
1151
+ </div>
1152
+ </template>
1153
+
1154
+ <!-- ============================================================ -->
1155
+ <!-- PROVIDER MODAL (add/edit) -->
1156
+ <!-- ============================================================ -->
1157
+ <template x-if="prov.showAdd">
1158
+ <div class="modal-backdrop" @click.self="prov.showAdd = false">
1159
+ <div class="modal p-6">
1160
+ <div class="flex justify-between items-center mb-4">
1161
+ <h3 class="font-semibold text-lg" x-text="prov.editingId ? 'Edit Provider' : 'Add Provider'"></h3>
1162
+ <button class="btn btn-ghost btn-sm" @click="prov.showAdd = false">✕</button>
1163
+ </div>
1164
+ <div class="grid md:grid-cols-2 gap-4">
1165
+ <div>
1166
+ <label class="text-sm font-medium text-slate-700">Name</label>
1167
+ <input type="text" x-model="provForm.name" class="input mt-1" />
1168
+ </div>
1169
+ <div>
1170
+ <label class="text-sm font-medium text-slate-700">Email</label>
1171
+ <input type="email" x-model="provForm.email" class="input mt-1" />
1172
+ </div>
1173
+ <div>
1174
+ <label class="text-sm font-medium text-slate-700">Portal Password</label>
1175
+ <input type="text" x-model="provForm.password" class="input mt-1" />
1176
+ </div>
1177
+ <div>
1178
+ <label class="text-sm font-medium text-slate-700">Category</label>
1179
+ <select x-model="provForm.category" class="select mt-1"><option>provider</option><option>nurse</option></select>
1180
+ </div>
1181
+ <div class="md:col-span-2">
1182
+ <label class="text-sm font-medium text-slate-700">Home Address</label>
1183
+ <input type="text" x-model="provForm.address" class="input mt-1" />
1184
+ </div>
1185
+ <div>
1186
+ <label class="text-sm font-medium text-slate-700">Work Hours Start</label>
1187
+ <input type="text" x-model="provForm.work_hours.start" placeholder="HH:MM" class="input mt-1" />
1188
+ </div>
1189
+ <div>
1190
+ <label class="text-sm font-medium text-slate-700">Work Hours End</label>
1191
+ <input type="text" x-model="provForm.work_hours.end" placeholder="HH:MM" class="input mt-1" />
1192
+ </div>
1193
+ <div>
1194
+ <label class="text-sm font-medium text-slate-700">Lunch Start</label>
1195
+ <input type="text" x-model="provForm.lunch_break.start" placeholder="HH:MM" class="input mt-1" />
1196
+ </div>
1197
+ <div>
1198
+ <label class="text-sm font-medium text-slate-700">Lunch End</label>
1199
+ <input type="text" x-model="provForm.lunch_break.end" placeholder="HH:MM" class="input mt-1" />
1200
+ </div>
1201
+ <div class="md:col-span-2">
1202
+ <label class="text-sm font-medium text-slate-700">Service Levels</label>
1203
+ <div class="flex flex-wrap gap-2 mt-1">
1204
+ <template x-for="lvl in [1,2,3,'Nurse']" :key="lvl">
1205
+ <button class="btn btn-sm" :class="provForm.service_levels.some(x => x == lvl) ? 'btn-primary' : 'btn-secondary'" @click="toggleServiceLevel(lvl)" x-text="lvl"></button>
1206
+ </template>
1207
+ </div>
1208
+ </div>
1209
+ </div>
1210
+ <div class="mt-6 flex justify-end gap-2">
1211
+ <button class="btn btn-secondary" @click="prov.showAdd = false">Cancel</button>
1212
+ <button class="btn btn-primary" @click="saveProvider()" :disabled="loading">
1213
+ <span x-show="!loading" x-text="prov.editingId ? 'Save Changes' : 'Add Provider'"></span>
1214
+ <span x-show="loading" class="spinner"></span>
1215
+ </button>
1216
+ </div>
1217
+ </div>
1218
+ </div>
1219
+ </template>
1220
+
1221
+ <!-- ============================================================ -->
1222
+ <!-- PATIENT MODAL (add/edit) -->
1223
+ <!-- ============================================================ -->
1224
+ <template x-if="pat.showAdd">
1225
+ <div class="modal-backdrop" @click.self="pat.showAdd = false">
1226
+ <div class="modal p-6">
1227
+ <div class="flex justify-between items-center mb-4">
1228
+ <h3 class="font-semibold text-lg" x-text="pat.editingId ? 'Edit Patient' : 'Add Patient'"></h3>
1229
+ <button class="btn btn-ghost btn-sm" @click="pat.showAdd = false">✕</button>
1230
+ </div>
1231
+ <div class="grid md:grid-cols-2 gap-4">
1232
+ <div>
1233
+ <label class="text-sm font-medium text-slate-700">Name</label>
1234
+ <input type="text" x-model="patForm.name" class="input mt-1" />
1235
+ </div>
1236
+ <div>
1237
+ <label class="text-sm font-medium text-slate-700">Email</label>
1238
+ <input type="email" x-model="patForm.email" class="input mt-1" />
1239
+ </div>
1240
+ <div class="md:col-span-2">
1241
+ <label class="text-sm font-medium text-slate-700">Address</label>
1242
+ <input type="text" x-model="patForm.address" class="input mt-1" />
1243
+ </div>
1244
+ <div class="md:col-span-2">
1245
+ <label class="text-sm font-medium text-slate-700">Notes</label>
1246
+ <textarea x-model="patForm.notes" class="input mt-1" rows="3"></textarea>
1247
+ </div>
1248
+ </div>
1249
+ <div class="mt-6 flex justify-end gap-2">
1250
+ <button class="btn btn-secondary" @click="pat.showAdd = false">Cancel</button>
1251
+ <button class="btn btn-primary" @click="savePatient()" :disabled="loading">
1252
+ <span x-show="!loading" x-text="pat.editingId ? 'Save Changes' : 'Add Patient'"></span>
1253
+ <span x-show="loading" class="spinner"></span>
1254
+ </button>
1255
+ </div>
1256
+ </div>
1257
+ </div>
1258
+ </template>
1259
+
1260
+ <!-- ============================================================ -->
1261
+ <!-- TOAST -->
1262
+ <!-- ============================================================ -->
1263
+ <template x-if="toast">
1264
+ <div class="toast" :class="`toast-${toast.type}`" x-text="toast.msg" @click="toast = null"></div>
1265
+ </template>
1266
+
1267
+ </body>
1268
+ </html>
utils/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """utils package."""
utils/auth.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Authentication helpers — session-cookie-based for FastAPI.
3
+ Supports admin and provider login. Sessions stored in-memory keyed by token.
4
+ """
5
+ import os
6
+ import secrets
7
+ import threading
8
+ from core import data_manager
9
+
10
+
11
+ def get_secret(key: str) -> str:
12
+ return os.environ.get(key)
13
+
14
+
15
+ # In-memory session store: token -> session dict
16
+ _lock = threading.Lock()
17
+ _sessions: dict[str, dict] = {}
18
+
19
+
20
+ def check_credentials(username: str, password: str) -> bool:
21
+ admin_user = get_secret("ADMIN_USERNAME")
22
+ admin_pass = get_secret("ADMIN_PASSWORD")
23
+ return bool(admin_user) and bool(admin_pass) and username == admin_user and password == admin_pass
24
+
25
+
26
+ def check_provider_credentials(name: str, password: str) -> dict | None:
27
+ provider = data_manager.get_provider_by_name(name)
28
+ if provider and provider.get("password") == password:
29
+ return provider
30
+ return None
31
+
32
+
33
+ def create_session(user_role: str, current_user: str, provider_id: str | None = None) -> str:
34
+ token = secrets.token_urlsafe(32)
35
+ with _lock:
36
+ _sessions[token] = {
37
+ "user_role": user_role,
38
+ "current_user": current_user,
39
+ "provider_id": provider_id,
40
+ }
41
+ return token
42
+
43
+
44
+ def get_session(token: str | None) -> dict | None:
45
+ if not token:
46
+ return None
47
+ with _lock:
48
+ return _sessions.get(token)
49
+
50
+
51
+ def destroy_session(token: str | None) -> None:
52
+ if not token:
53
+ return
54
+ with _lock:
55
+ _sessions.pop(token, None)
utils/helpers.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Shared utility functions — time formatting, address formatting, etc.
3
+ """
4
+ from datetime import datetime, timedelta
5
+ from core import settings_manager as settings
6
+
7
+
8
+ def format_time(time_str: str) -> str:
9
+ """Convert 'HH:MM' (24h) to '2:30 PM' (12h) format for display."""
10
+ t = datetime.strptime(time_str, "%H:%M")
11
+ return t.strftime("%I:%M %p") #previously %-I
12
+
13
+
14
+ def format_date(date_str: str) -> str:
15
+ """Convert 'YYYY-MM-DD' to 'Friday, March 20, 2026' for display."""
16
+ d = datetime.strptime(date_str, "%Y-%m-%d")
17
+ return d.strftime("%A, %B %d, %Y") #previously %-d
18
+
19
+
20
+ def is_future_date(date_str: str, min_hours_ahead: int = 24) -> bool:
21
+ """Check if a date string is at least min_hours_ahead in the future."""
22
+ target = datetime.strptime(date_str, "%Y-%m-%d")
23
+ cutoff = datetime.now() + timedelta(hours=min_hours_ahead)
24
+ return target >= cutoff
25
+
26
+
27
+ def build_event_title(visit_level: int | str, patient_name: str, provider_name: str) -> str:
28
+ """Build a Google Calendar event title.
29
+
30
+ Format: '[Level X] PatientName — ProviderName'
31
+ """
32
+ level_info = settings.get_visit_levels().get(visit_level, {})
33
+ label = level_info.get("label", f"Level {visit_level}")
34
+ return f"[{label}] {patient_name} — {provider_name}"
35
+
36
+
37
+ def build_event_description(patient: dict, provider: dict, visit_address: str) -> str:
38
+ """Build a Google Calendar event description with contact info and address."""
39
+ lines = [
40
+ f"Patient: {patient.get('name', 'N/A')}",
41
+ f"Patient Email: {patient.get('email', 'N/A')}",
42
+ f"Provider: {provider.get('name', 'N/A')}",
43
+ f"Provider Email: {provider.get('email', 'N/A')}",
44
+ f"Visit Address: {visit_address}",
45
+ ]
46
+ if patient.get("notes"):
47
+ lines.append(f"Notes: {patient['notes']}")
48
+ return "\n".join(lines)
49
+
50
+
51
+ def minutes_to_display(minutes: int) -> str:
52
+ """Convert minutes to a readable string: '1h 15m' or '45m'."""
53
+ if minutes < 60:
54
+ return f"{minutes}m"
55
+ hours = minutes // 60
56
+ remaining = minutes % 60
57
+ if remaining == 0:
58
+ return f"{hours}h"
59
+ return f"{hours}h {remaining}m"