# βš™οΈ Technisch Ontwerp (TO) – Agenda Module **Projectnaam:** Mini-EPD Agenda Module **Versie:** v1.0 **Datum:** 02-12-2024 **Auteur:** Colin --- ## 1. Doel en relatie met PRD en FO 🎯 **Doel van dit document:** Het Technisch Ontwerp beschrijft **hoe** de Agenda Module technisch wordt geΓ―mplementeerd. Dit document vertaalt de functionele specificaties uit het FO naar concrete technische oplossingen, architectuur en implementatiedetails. πŸ“˜ **Document hiΓ«rarchie:** ``` PRD (Wat & Waarom) β†’ FO (Hoe functioneel) β†’ TO (Hoe technisch) ``` **Referenties:** - PRD: `prd-agenda-module-v1_0.md` β€” Scope, user stories, success criteria - FO: `fo-agenda-module-v1_0.md` β€” Functionele werking, UX, flows - Bestaande codebase: `/home/colin/development/15-mini-epd-prototype` **Scope:** - MVP implementatie van agenda module - Integratie met bestaande EPD modules (patients, rapportage) - FullCalendar integratie - FHIR-compliant data model (Encounter resource) --- ## 2. Technische Architectuur Overzicht 🎯 **Doel:** Globaal beeld van de systeemarchitectuur. ### 2.1 High-Level Architecture ``` β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ FRONTEND β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ Next.js 14 (App Router) β”‚ β”‚ β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ β”‚ β”‚ EPD Layout β”‚ β”‚ Agenda β”‚ β”‚ Shared Components β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ + Sidebar β”‚ β”‚ Module β”‚ β”‚ (shadcn/ui, forms) β”‚ β”‚ β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ β”‚ β”‚ FullCalendarβ”‚ β”‚ β”‚ β”‚ β”‚ β”‚ React β”‚ β”‚ β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ API Layer β”‚ β”‚ (Next.js API Routes β”‚ β”‚ + Server Actions) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ β–Ό β–Ό β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Supabase β”‚ β”‚ Supabase β”‚ β”‚ Deepgram β”‚ β”‚ PostgreSQL β”‚ β”‚ Auth β”‚ β”‚ (Audioβ†’Text) β”‚ β”‚ (Data) β”‚ β”‚ (Identity) β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` ### 2.2 Agenda Module Architecture ``` app/epd/agenda/ β”œβ”€β”€ page.tsx # Server Component - data fetching β”œβ”€β”€ layout.tsx # Optional: agenda-specific layout β”œβ”€β”€ loading.tsx # Suspense fallback β”œβ”€β”€ error.tsx # Error boundary β”‚ β”œβ”€β”€ components/ β”‚ β”œβ”€β”€ calendar-view.tsx # FullCalendar wrapper (Client) β”‚ β”œβ”€β”€ calendar-toolbar.tsx # Navigation, view switcher β”‚ β”œβ”€β”€ appointment-modal.tsx # Create/Edit form (Client) β”‚ β”œβ”€β”€ appointment-detail.tsx # Popup/popover detail view β”‚ β”œβ”€β”€ conflict-dialog.tsx # Double booking warning β”‚ β”œβ”€β”€ patient-search.tsx # Autocomplete combobox β”‚ └── appointment-card.tsx # Event rendering in calendar β”‚ β”œβ”€β”€ hooks/ β”‚ β”œβ”€β”€ use-appointments.ts # Data fetching hook (SWR/React Query) β”‚ β”œβ”€β”€ use-appointment-mutations.ts # Create/Update/Delete β”‚ └── use-conflict-check.ts # Real-time conflict detection β”‚ β”œβ”€β”€ lib/ β”‚ β”œβ”€β”€ calendar-config.ts # FullCalendar configuration β”‚ β”œβ”€β”€ appointment-utils.ts # Date/time helpers β”‚ └── conflict-detection.ts # Overlap logic β”‚ β”œβ”€β”€ actions.ts # Server Actions (mutations) └── types.ts # Module-specific types ``` ### 2.3 Data Flow ``` β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ CREATE APPOINTMENT FLOW β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ β”‚ β”‚ 1. User clicks time slot β”‚ β”‚ β”‚ β”‚ β”‚ β–Ό β”‚ β”‚ 2. Modal opens (Client Component) β”‚ β”‚ β”‚ β”‚ β”‚ β–Ό β”‚ β”‚ 3. User fills form + patient search β”‚ β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€β–Ί Patient Search API (/api/patients/search) β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β–Ό β”‚ β”‚ β”‚ Supabase Query (patients table) β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β–Ό β”‚ β”‚ β”‚ Return patient list β”‚ β”‚ β”‚ β”‚ β”‚ β–Ό β”‚ β”‚ 4. User clicks "Save" β”‚ β”‚ β”‚ β”‚ β”‚ β–Ό β”‚ β”‚ 5. Client-side validation (Zod) β”‚ β”‚ β”‚ β”‚ β”‚ β–Ό β”‚ β”‚ 6. Conflict check (Server Action) β”‚ β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€β–Ί No conflict ──► Create appointment β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β–Ό β”‚ β”‚ β”‚ Supabase INSERT (encounters) β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β–Ό β”‚ β”‚ β”‚ Optimistic UI update β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β–Ό β”‚ β”‚ β”‚ Toast: "Afspraak opgeslagen" β”‚ β”‚ β”‚ β”‚ β”‚ └──► Conflict ──► Show Conflict Dialog β”‚ β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€β–Ί "Wijzigen" ──► Back to form β”‚ β”‚ β”‚ β”‚ β”‚ └──► "Toch inplannen" ──► Create with flag β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` --- ## 3. Techstack Selectie 🎯 **Doel:** Onderbouwde keuze van technologieΓ«n. ### 3.1 Core Stack (Bestaand) | Component | Technologie | Versie | Argumentatie | |-----------|-------------|--------|--------------| | **Framework** | Next.js | 14.2.18 | Bestaande codebase, App Router, Server Components | | **Runtime** | React | 18.3.1 | Bestaande codebase | | **Language** | TypeScript | ^5 | Type safety, bestaande patterns | | **Database** | Supabase PostgreSQL | - | Bestaande infra, RLS, realtime | | **Auth** | Supabase Auth | ^0.7.0 | Bestaande implementatie | | **Styling** | TailwindCSS | ^3.4.18 | Bestaande design system | | **UI Components** | shadcn/ui | - | Bestaande component library | | **Forms** | React Hook Form | ^7.66.1 | Bestaande pattern | | **Validation** | Zod | ^4.1.12 | Bestaande pattern | | **Date Handling** | date-fns | ^4.1.0 | Bestaande utility | ### 3.2 Nieuwe Dependencies (Agenda-specifiek) | Component | Technologie | Versie | Argumentatie | Alternatieven | |-----------|-------------|--------|--------------|---------------| | **Calendar** | FullCalendar | ^6.1.x | Feature-rijk, React support, drag-drop | react-big-calendar, custom | | **FC Core** | @fullcalendar/core | ^6.1.x | Base package | - | | **FC React** | @fullcalendar/react | ^6.1.x | React wrapper | - | | **FC DayGrid** | @fullcalendar/daygrid | ^6.1.x | Month/week grid view | - | | **FC TimeGrid** | @fullcalendar/timegrid | ^6.1.x | Day/week time view | - | | **FC Interaction** | @fullcalendar/interaction | ^6.1.x | Drag-drop, resize | - | | **Data Fetching** | SWR | ^2.x | Caching, revalidation, optimistic | React Query | ### 3.3 Package Installation ```bash # FullCalendar packages pnpm add @fullcalendar/core @fullcalendar/react @fullcalendar/daygrid @fullcalendar/timegrid @fullcalendar/interaction # Data fetching (optional, can use existing pattern) pnpm add swr ``` ### 3.4 Techstack Decision: FullCalendar **Waarom FullCalendar:** - βœ… Mature library (10+ jaar development) - βœ… Comprehensive API voor alle agenda features - βœ… Built-in drag-drop met event resizing - βœ… Mobile-responsive out of the box - βœ… Goede TypeScript support - βœ… Styling customizable via CSS variables - βœ… Grote community, veel voorbeelden **Risico's & Mitigatie:** | Risico | Mitigatie | |--------|-----------| | Styling conflicts met Tailwind | CSS isolation, custom CSS variables | | Bundle size (~200KB) | Tree-shaking, alleen noodzakelijke plugins | | Learning curve | Spike in week 1, prototype first | --- ## 4. Datamodel 🎯 **Doel:** Structuur van de data in de database. ### 4.1 Bestaande Tabel: `encounters` De `encounters` tabel bestaat al en is FHIR-compliant. We gebruiken deze voor afspraken. ```sql -- Bestaande tabel structuur (uit database.types.ts) CREATE TABLE encounters ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Relaties patient_id UUID NOT NULL REFERENCES patients(patient_id), practitioner_id UUID REFERENCES practitioners(practitioner_id), intake_id UUID REFERENCES intakes(id), intake_note_id UUID REFERENCES intake_notes(id), organization_id UUID REFERENCES organizations(id), -- Timing (FHIR Period) period_start TIMESTAMPTZ NOT NULL, period_end TIMESTAMPTZ, -- Classification (FHIR Coding) class_code TEXT, -- 'AMB' (ambulatory), 'VR' (virtual) class_display TEXT, type_code TEXT, -- 'intake', 'treatment', 'followup' type_display TEXT, -- 'Intakegesprek', 'Behandeling', 'Follow-up' -- Status (FHIR enum) status encounter_status NOT NULL DEFAULT 'planned', -- Values: planned, in-progress, on-hold, completed, cancelled (actual DB enum) -- Priority (optional) priority_code TEXT, priority_display TEXT, -- Reason (array for multiple reasons) reason_code TEXT[], reason_display TEXT[], -- Notes notes TEXT, -- Audit created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now(), identifier TEXT -- External identifier if needed ); -- Indexes for performance CREATE INDEX idx_encounters_patient ON encounters(patient_id); CREATE INDEX idx_encounters_practitioner ON encounters(practitioner_id); CREATE INDEX idx_encounters_period ON encounters(period_start, period_end); CREATE INDEX idx_encounters_status ON encounters(status); ``` ### 4.2 Nieuw: Encounter-Report Linking (E0 Migration) ```sql -- Migration: Add encounter_id to reports table ALTER TABLE reports ADD COLUMN encounter_id UUID REFERENCES encounters(id); -- Index for quick lookups CREATE INDEX idx_reports_encounter ON reports(encounter_id); -- Optional: Backfill existing reports based on matching dates/patients -- (Post-MVP task) ``` ### 4.3 Type Definitions ```typescript // lib/types/encounter.ts import { z } from 'zod'; import type { Database } from '@/lib/supabase/database.types'; // Base types from database export type Encounter = Database['public']['Tables']['encounters']['Row']; export type EncounterInsert = Database['public']['Tables']['encounters']['Insert']; export type EncounterUpdate = Database['public']['Tables']['encounters']['Update']; export type EncounterStatus = Database['public']['Enums']['encounter_status']; // Appointment types (subset of FHIR encounter types for GGZ) export const AppointmentType = { INTAKE: 'intake', TREATMENT: 'treatment', FOLLOWUP: 'followup', } as const; export type AppointmentTypeValue = typeof AppointmentType[keyof typeof AppointmentType]; export const AppointmentTypeDisplay: Record = { intake: 'Intakegesprek', treatment: 'Behandeling', followup: 'Follow-up', }; export const AppointmentTypeDuration: Record = { intake: 60, treatment: 45, followup: 30, }; // Zod Schemas for validation export const CreateAppointmentSchema = z.object({ patient_id: z.string().uuid('Patient ID moet een geldige UUID zijn'), period_start: z.string().datetime('Ongeldige datum/tijd'), duration_minutes: z.number().min(15).max(120), type_code: z.enum(['intake', 'treatment', 'followup']), notes: z.string().max(500).optional(), }); export const UpdateAppointmentSchema = CreateAppointmentSchema.partial().extend({ id: z.string().uuid(), status: z.enum(['planned', 'in-progress', 'on-hold', 'completed', 'cancelled']).optional(), }); export type CreateAppointmentInput = z.infer; export type UpdateAppointmentInput = z.infer; // Extended type with relations export interface AppointmentWithPatient extends Encounter { patient: { patient_id: string; name_family: string; name_given: string[]; birth_date: string; gender: string; }; reports?: { id: string; created_at: string; }[]; } // FullCalendar event type export interface CalendarEvent { id: string; title: string; start: Date; end: Date; extendedProps: { patient_id: string; patient_name: string; type_code: AppointmentTypeValue; type_display: string; status: EncounterStatus; notes?: string; has_report: boolean; }; backgroundColor?: string; borderColor?: string; classNames?: string[]; } ``` ### 4.4 Entity Relationship Diagram ``` β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ practitioners β”‚ β”‚ encounters β”‚ β”‚ patients β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ practitioner_id │◄──────│ practitioner_id β”‚ β”‚ patient_id β”‚ β”‚ name_family β”‚ 1:N β”‚ patient_id │──────►│ name_family β”‚ β”‚ name_given[] β”‚ β”‚ period_start β”‚ N:1 β”‚ name_given[] β”‚ β”‚ ... β”‚ β”‚ period_end β”‚ β”‚ birth_date β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ type_code β”‚ β”‚ gender β”‚ β”‚ status β”‚ β”‚ ... β”‚ β”‚ notes β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ ... β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ 1:N (optional) β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ reports β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ id β”‚ β”‚ encounter_id β”‚ ◄── NEW (E0 migration) β”‚ patient_id β”‚ β”‚ type β”‚ β”‚ content β”‚ β”‚ ... β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` --- ## 5. API Ontwerp 🎯 **Doel:** Overzicht van endpoints en server actions. ### 5.1 API Routes | Endpoint | Method | Input | Output | Auth | Gebruik | |----------|--------|-------|--------|------|---------| | `/api/appointments` | GET | `?start=&end=&practitioner_id=` | `Appointment[]` | Required | Kalender data laden | | `/api/appointments` | POST | `CreateAppointmentInput` | `Appointment` | Required | Nieuwe afspraak | | `/api/appointments/[id]` | GET | - | `AppointmentWithPatient` | Required | Detail ophalen | | `/api/appointments/[id]` | PATCH | `UpdateAppointmentInput` | `Appointment` | Required | Afspraak wijzigen | | `/api/appointments/[id]` | DELETE | - | `{ success: true }` | Required | Soft delete (status=cancelled) | | `/api/appointments/conflicts` | POST | `{ start, end, exclude_id? }` | `Appointment[]` | Required | Conflict check | | `/api/patients/search` | GET | `?q=&limit=` | `Patient[]` | Required | Patient autocomplete | ### 5.2 Server Actions (Preferred for Mutations) ```typescript // app/epd/agenda/actions.ts 'use server'; import { revalidatePath } from 'next/cache'; import { createClient } from '@/lib/auth/server'; import { CreateAppointmentSchema, UpdateAppointmentSchema } from './types'; import type { CreateAppointmentInput, UpdateAppointmentInput } from './types'; export async function createAppointment(input: CreateAppointmentInput) { // 1. Validate input const validated = CreateAppointmentSchema.parse(input); // 2. Get authenticated user const supabase = await createClient(); const { data: { user } } = await supabase.auth.getUser(); if (!user) throw new Error('Niet ingelogd'); // 3. Get practitioner_id from user const { data: practitioner } = await supabase .from('practitioners') .select('practitioner_id') .eq('user_id', user.id) .single(); // 4. Calculate period_end const periodStart = new Date(validated.period_start); const periodEnd = new Date(periodStart.getTime() + validated.duration_minutes * 60000); // 5. Insert encounter const { data, error } = await supabase .from('encounters') .insert({ patient_id: validated.patient_id, practitioner_id: practitioner?.practitioner_id, period_start: periodStart.toISOString(), period_end: periodEnd.toISOString(), type_code: validated.type_code, type_display: AppointmentTypeDisplay[validated.type_code], status: 'planned', notes: validated.notes, class_code: 'AMB', class_display: 'ambulatory', }) .select() .single(); if (error) throw error; // 6. Revalidate cache revalidatePath('/epd/agenda'); return data; } export async function updateAppointment(input: UpdateAppointmentInput) { const validated = UpdateAppointmentSchema.parse(input); const supabase = await createClient(); const updateData: Record = {}; if (validated.period_start) { updateData.period_start = validated.period_start; if (validated.duration_minutes) { const start = new Date(validated.period_start); updateData.period_end = new Date(start.getTime() + validated.duration_minutes * 60000).toISOString(); } } if (validated.type_code) { updateData.type_code = validated.type_code; updateData.type_display = AppointmentTypeDisplay[validated.type_code]; } if (validated.status) updateData.status = validated.status; if (validated.notes !== undefined) updateData.notes = validated.notes; updateData.updated_at = new Date().toISOString(); const { data, error } = await supabase .from('encounters') .update(updateData) .eq('id', validated.id) .select() .single(); if (error) throw error; revalidatePath('/epd/agenda'); return data; } export async function cancelAppointment(id: string) { const supabase = await createClient(); const { data, error } = await supabase .from('encounters') .update({ status: 'cancelled', updated_at: new Date().toISOString(), }) .eq('id', id) .select() .single(); if (error) throw error; revalidatePath('/epd/agenda'); return data; } export async function checkConflicts( start: string, end: string, excludeId?: string ): Promise { const supabase = await createClient(); const { data: { user } } = await supabase.auth.getUser(); if (!user) throw new Error('Niet ingelogd'); // Get practitioner_id const { data: practitioner } = await supabase .from('practitioners') .select('practitioner_id') .eq('user_id', user.id) .single(); if (!practitioner) return []; // Query overlapping appointments let query = supabase .from('encounters') .select('*') .eq('practitioner_id', practitioner.practitioner_id) .neq('status', 'cancelled') .lt('period_start', end) .gt('period_end', start); if (excludeId) { query = query.neq('id', excludeId); } const { data, error } = await query; if (error) throw error; return data || []; } ``` ### 5.3 API Route Implementation ```typescript // app/api/appointments/route.ts import { NextRequest, NextResponse } from 'next/server'; import { createClient } from '@/lib/auth/server'; export async function GET(request: NextRequest) { try { const { searchParams } = new URL(request.url); const start = searchParams.get('start'); const end = searchParams.get('end'); if (!start || !end) { return NextResponse.json( { error: 'start en end parameters zijn verplicht' }, { status: 400 } ); } const supabase = await createClient(); const { data: { user } } = await supabase.auth.getUser(); if (!user) { return NextResponse.json({ error: 'Niet ingelogd' }, { status: 401 }); } // Get practitioner const { data: practitioner } = await supabase .from('practitioners') .select('practitioner_id') .eq('user_id', user.id) .single(); // Fetch appointments with patient data const { data, error } = await supabase .from('encounters') .select(` *, patient:patients(patient_id, name_family, name_given, birth_date, gender), reports(id, created_at) `) .eq('practitioner_id', practitioner?.practitioner_id) .gte('period_start', start) .lte('period_start', end) .neq('status', 'cancelled') .order('period_start', { ascending: true }); if (error) throw error; return NextResponse.json({ appointments: data }); } catch (error) { console.error('Error fetching appointments:', error); return NextResponse.json( { error: 'Kon afspraken niet laden' }, { status: 500 } ); } } ``` ### 5.4 Patient Search API ```typescript // app/api/patients/search/route.ts import { NextRequest, NextResponse } from 'next/server'; import { createClient } from '@/lib/auth/server'; export async function GET(request: NextRequest) { try { const { searchParams } = new URL(request.url); const query = searchParams.get('q') || ''; const limit = parseInt(searchParams.get('limit') || '10'); const supabase = await createClient(); const { data: { user } } = await supabase.auth.getUser(); if (!user) { return NextResponse.json({ error: 'Niet ingelogd' }, { status: 401 }); } // Build search query let dbQuery = supabase .from('patients') .select('patient_id, name_family, name_given, birth_date, gender') .limit(limit); if (query.length >= 2) { // Search on name (case-insensitive) dbQuery = dbQuery.or(`name_family.ilike.%${query}%,name_given.cs.{${query}}`); } const { data, error } = await dbQuery.order('name_family'); if (error) throw error; return NextResponse.json({ patients: data }); } catch (error) { console.error('Error searching patients:', error); return NextResponse.json( { error: 'Zoeken mislukt' }, { status: 500 } ); } } ``` --- ## 6. Component Implementatie 🎯 **Doel:** Technische details van UI componenten. ### 6.1 Calendar View Component ```typescript // app/epd/agenda/components/calendar-view.tsx 'use client'; import { useRef, useCallback } from 'react'; import FullCalendar from '@fullcalendar/react'; import dayGridPlugin from '@fullcalendar/daygrid'; import timeGridPlugin from '@fullcalendar/timegrid'; import interactionPlugin from '@fullcalendar/interaction'; import type { EventClickArg, DateSelectArg, EventDropArg } from '@fullcalendar/core'; import { nl } from 'date-fns/locale'; import { useAppointments } from '../hooks/use-appointments'; import { useAppointmentMutations } from '../hooks/use-appointment-mutations'; import { transformToCalendarEvents } from '../lib/appointment-utils'; import { AppointmentModal } from './appointment-modal'; import { AppointmentDetail } from './appointment-detail'; import { CalendarToolbar } from './calendar-toolbar'; interface CalendarViewProps { initialDate?: Date; initialView?: 'timeGridDay' | 'timeGridWeek' | 'timeGridWorkWeek'; } export function CalendarView({ initialDate = new Date(), initialView = 'timeGridWeek' }: CalendarViewProps) { const calendarRef = useRef(null); // Data fetching const { appointments, isLoading, mutate } = useAppointments(); const { updateAppointment } = useAppointmentMutations(); // State for modals const [selectedSlot, setSelectedSlot] = useState(null); const [selectedEvent, setSelectedEvent] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); const [isDetailOpen, setIsDetailOpen] = useState(false); // Transform appointments to FullCalendar events const events = useMemo(() => transformToCalendarEvents(appointments), [appointments] ); // Handlers const handleDateSelect = useCallback((selectInfo: DateSelectArg) => { setSelectedSlot(selectInfo); setIsModalOpen(true); }, []); const handleEventClick = useCallback((clickInfo: EventClickArg) => { setSelectedEvent(clickInfo.event.toPlainObject() as CalendarEvent); setIsDetailOpen(true); }, []); const handleEventDrop = useCallback(async (dropInfo: EventDropArg) => { const { event, revert } = dropInfo; try { await updateAppointment({ id: event.id, period_start: event.start!.toISOString(), duration_minutes: (event.end!.getTime() - event.start!.getTime()) / 60000, }); mutate(); // Revalidate } catch (error) { revert(); // Rollback on error toast.error('Kon afspraak niet verzetten'); } }, [updateAppointment, mutate]); // Navigation handlers for toolbar const handlePrev = () => calendarRef.current?.getApi().prev(); const handleNext = () => calendarRef.current?.getApi().next(); const handleToday = () => calendarRef.current?.getApi().today(); const handleViewChange = (view: string) => calendarRef.current?.getApi().changeView(view); return (
setIsModalOpen(true)} />
setIsLoading(loading)} />
{/* Modals */} { setIsModalOpen(false); setSelectedSlot(null); mutate(); }} /> { setIsDetailOpen(false); setIsModalOpen(true); }} onDelete={() => { mutate(); setIsDetailOpen(false); }} />
); } ``` ### 6.2 Appointment Modal Component ```typescript // app/epd/agenda/components/appointment-modal.tsx 'use client'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { useState, useTransition, useEffect } from 'react'; import { format } from 'date-fns'; import { nl } from 'date-fns/locale'; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { PatientSearch } from './patient-search'; import { ConflictDialog } from './conflict-dialog'; import { createAppointment, updateAppointment, checkConflicts } from '../actions'; import { CreateAppointmentSchema, AppointmentType, AppointmentTypeDuration } from '../types'; interface AppointmentModalProps { open: boolean; onOpenChange: (open: boolean) => void; defaultValues?: { date?: Date; startTime?: Date; appointment?: Appointment; // For edit mode }; onSuccess: () => void; } export function AppointmentModal({ open, onOpenChange, defaultValues, onSuccess, }: AppointmentModalProps) { const [isPending, startTransition] = useTransition(); const [conflicts, setConflicts] = useState([]); const [showConflictDialog, setShowConflictDialog] = useState(false); const [showNotes, setShowNotes] = useState(false); const isEdit = !!defaultValues?.appointment; const form = useForm({ resolver: zodResolver(CreateAppointmentSchema), defaultValues: { patient_id: defaultValues?.appointment?.patient_id || '', period_start: defaultValues?.startTime?.toISOString() || defaultValues?.date?.toISOString() || new Date().toISOString(), duration_minutes: defaultValues?.appointment ? /* calculate from period_end - period_start */ 45 : 45, type_code: defaultValues?.appointment?.type_code || 'treatment', notes: defaultValues?.appointment?.notes || '', }, }); // Auto-update duration when type changes const typeCode = form.watch('type_code'); useEffect(() => { if (!isEdit) { form.setValue('duration_minutes', AppointmentTypeDuration[typeCode as keyof typeof AppointmentTypeDuration]); } }, [typeCode, isEdit, form]); // Real-time conflict check const periodStart = form.watch('period_start'); const durationMinutes = form.watch('duration_minutes'); useEffect(() => { if (periodStart && durationMinutes) { const start = new Date(periodStart); const end = new Date(start.getTime() + durationMinutes * 60000); checkConflicts( start.toISOString(), end.toISOString(), defaultValues?.appointment?.id ).then(setConflicts); } }, [periodStart, durationMinutes, defaultValues?.appointment?.id]); const onSubmit = async (data: CreateAppointmentInput) => { // Check for conflicts before submit if (conflicts.length > 0) { setShowConflictDialog(true); return; } await saveAppointment(data); }; const saveAppointment = async (data: CreateAppointmentInput, ignoreConflict = false) => { startTransition(async () => { try { if (isEdit) { await updateAppointment({ ...data, id: defaultValues.appointment!.id }); } else { await createAppointment(data); } onSuccess(); toast.success(isEdit ? 'Afspraak gewijzigd' : 'Afspraak opgeslagen'); } catch (error) { toast.error('Er ging iets mis'); } }); }; return ( <> {isEdit ? 'Afspraak bewerken' : 'Nieuwe afspraak'}
{/* Patient Search */}
form.setValue('patient_id', patientId)} error={form.formState.errors.patient_id?.message} />
{/* Date & Time (inline) */}
{/* Inline conflict warning */} {conflicts.length > 0 && (

⚠️ Overlap met: {conflicts[0].patient?.name_family} {format(new Date(conflicts[0].period_start), 'HH:mm')}

)} {/* Duration (radio buttons) */}
form.setValue('duration_minutes', Number(v))} className="flex gap-4" > {[30, 45, 60, 90].map((min) => (
))}
{/* Type (radio buttons) */}
form.setValue('type_code', v)} className="flex gap-4" >
{/* Notes (collapsible) */} {!showNotes ? ( ) : (