Spaces:
Runtime error
Runtime error
Upload 31 files
Browse files- Dockerfile +22 -0
- README.md +59 -7
- agent/__init__.py +1 -0
- agent/graph.py +83 -0
- agent/nodes.py +392 -0
- agent/state.py +44 -0
- api/__init__.py +0 -0
- api/appointments.py +113 -0
- api/audit.py +34 -0
- api/auth.py +74 -0
- api/calendar.py +187 -0
- api/deps.py +30 -0
- api/patients.py +106 -0
- api/providers.py +309 -0
- api/schedule.py +133 -0
- api/settings.py +110 -0
- config.py +35 -0
- core/__init__.py +1 -0
- core/data_manager.py +379 -0
- core/google_services.py +484 -0
- core/scheduling_engine.py +249 -0
- core/settings_manager.py +169 -0
- main.py +55 -0
- requirements.txt +15 -0
- static/css/app.css +275 -0
- static/js/api.js +60 -0
- static/js/app.js +1180 -0
- templates/index.html +1268 -0
- utils/__init__.py +1 -0
- utils/auth.py +55 -0
- utils/helpers.py +59 -0
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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
-
license: mit
|
| 9 |
-
short_description: Medical Scheduler for SOS Care Now Chicago
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|