- E0.S1: Add encounter_id and intake_id columns to reports table - E0.S2: Add performance indices for reports and encounters - E0.S3: Regenerate TypeScript types 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
58 KiB
⚙️ 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
# 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.
-- 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)
-- 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
// 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<AppointmentTypeValue, string> = {
intake: 'Intakegesprek',
treatment: 'Behandeling',
followup: 'Follow-up',
};
export const AppointmentTypeDuration: Record<AppointmentTypeValue, number> = {
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<typeof CreateAppointmentSchema>;
export type UpdateAppointmentInput = z.infer<typeof UpdateAppointmentSchema>;
// 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)
// 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<string, unknown> = {};
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<Encounter[]> {
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
// 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
// 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
// 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<FullCalendar>(null);
// Data fetching
const { appointments, isLoading, mutate } = useAppointments();
const { updateAppointment } = useAppointmentMutations();
// State for modals
const [selectedSlot, setSelectedSlot] = useState<DateSelectArg | null>(null);
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(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 (
<div className="flex flex-col h-full">
<CalendarToolbar
calendarRef={calendarRef}
onPrev={handlePrev}
onNext={handleNext}
onToday={handleToday}
onViewChange={handleViewChange}
onNewAppointment={() => setIsModalOpen(true)}
/>
<div className="flex-1 min-h-0">
<FullCalendar
ref={calendarRef}
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
initialView={initialView}
initialDate={initialDate}
// Localization
locale="nl"
firstDay={1} // Monday
// Time settings
slotMinTime="08:00:00"
slotMaxTime="18:00:00"
slotDuration="00:30:00"
// Business hours
businessHours={{
daysOfWeek: [1, 2, 3, 4, 5],
startTime: '08:00',
endTime: '18:00',
}}
// Events
events={events}
// Interaction
selectable={true}
selectMirror={true}
editable={true}
eventResizableFromStart={true}
// Callbacks
select={handleDateSelect}
eventClick={handleEventClick}
eventDrop={handleEventDrop}
// Styling
height="100%"
headerToolbar={false} // We use custom toolbar
nowIndicator={true}
dayMaxEvents={true}
// Loading
loading={(loading) => setIsLoading(loading)}
/>
</div>
{/* Modals */}
<AppointmentModal
open={isModalOpen}
onOpenChange={setIsModalOpen}
defaultValues={selectedSlot ? {
date: selectedSlot.start,
startTime: selectedSlot.start,
} : undefined}
onSuccess={() => {
setIsModalOpen(false);
setSelectedSlot(null);
mutate();
}}
/>
<AppointmentDetail
open={isDetailOpen}
onOpenChange={setIsDetailOpen}
appointment={selectedEvent}
onEdit={() => {
setIsDetailOpen(false);
setIsModalOpen(true);
}}
onDelete={() => {
mutate();
setIsDetailOpen(false);
}}
/>
</div>
);
}
6.2 Appointment Modal Component
// 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<Encounter[]>([]);
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 (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>
{isEdit ? 'Afspraak bewerken' : 'Nieuwe afspraak'}
</DialogTitle>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{/* Patient Search */}
<div className="space-y-2">
<Label htmlFor="patient">Patient *</Label>
<PatientSearch
value={form.watch('patient_id')}
onChange={(patientId) => form.setValue('patient_id', patientId)}
error={form.formState.errors.patient_id?.message}
/>
</div>
{/* Date & Time (inline) */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="date">Datum *</Label>
<Input
type="date"
{...form.register('period_start')}
className={form.formState.errors.period_start ? 'border-red-500' : ''}
/>
</div>
<div className="space-y-2">
<Label htmlFor="time">Tijd *</Label>
<Input
type="time"
step="1800"
{...form.register('period_start')}
/>
</div>
</div>
{/* Inline conflict warning */}
{conflicts.length > 0 && (
<div className="p-3 bg-amber-50 border border-amber-200 rounded-md text-sm">
<p className="font-medium text-amber-800">
⚠️ Overlap met: {conflicts[0].patient?.name_family} {format(new Date(conflicts[0].period_start), 'HH:mm')}
</p>
</div>
)}
{/* Duration (radio buttons) */}
<div className="space-y-2">
<Label>Duur</Label>
<RadioGroup
value={String(form.watch('duration_minutes'))}
onValueChange={(v) => form.setValue('duration_minutes', Number(v))}
className="flex gap-4"
>
{[30, 45, 60, 90].map((min) => (
<div key={min} className="flex items-center space-x-2">
<RadioGroupItem value={String(min)} id={`dur-${min}`} />
<Label htmlFor={`dur-${min}`} className="font-normal">
{min} min
</Label>
</div>
))}
</RadioGroup>
</div>
{/* Type (radio buttons) */}
<div className="space-y-2">
<Label>Type *</Label>
<RadioGroup
value={form.watch('type_code')}
onValueChange={(v) => form.setValue('type_code', v)}
className="flex gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="intake" id="type-intake" />
<Label htmlFor="type-intake" className="font-normal">Intake</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="treatment" id="type-treatment" />
<Label htmlFor="type-treatment" className="font-normal">Behandeling</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="followup" id="type-followup" />
<Label htmlFor="type-followup" className="font-normal">Follow-up</Label>
</div>
</RadioGroup>
</div>
{/* Notes (collapsible) */}
{!showNotes ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowNotes(true)}
className="text-slate-600"
>
+ Notitie toevoegen
</Button>
) : (
<div className="space-y-2">
<Label htmlFor="notes">Notities</Label>
<Textarea
{...form.register('notes')}
placeholder="Optionele notities..."
rows={3}
/>
<p className="text-xs text-slate-500 text-right">
{form.watch('notes')?.length || 0}/500
</p>
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Annuleren
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? 'Opslaan...' : 'Opslaan'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
{/* Conflict Dialog */}
<ConflictDialog
open={showConflictDialog}
onOpenChange={setShowConflictDialog}
conflicts={conflicts}
onModify={() => {
setShowConflictDialog(false);
// Focus time field
}}
onProceed={() => {
setShowConflictDialog(false);
saveAppointment(form.getValues(), true);
}}
/>
</>
);
}
6.3 Patient Search Component
// app/epd/agenda/components/patient-search.tsx
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useDebounce } from '@/hooks/use-debounce';
import { cn } from '@/lib/utils';
import { Search, X, Loader2 } from 'lucide-react';
interface Patient {
patient_id: string;
name_family: string;
name_given: string[];
birth_date: string;
gender: string;
}
interface PatientSearchProps {
value: string;
onChange: (patientId: string) => void;
error?: string;
}
export function PatientSearch({ value, onChange, error }: PatientSearchProps) {
const [query, setQuery] = useState('');
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [patients, setPatients] = useState<Patient[]>([]);
const [recentPatients, setRecentPatients] = useState<Patient[]>([]);
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
const [highlightedIndex, setHighlightedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const debouncedQuery = useDebounce(query, 300);
// Load recent patients on mount
useEffect(() => {
fetch('/api/patients/recent')
.then(res => res.json())
.then(data => setRecentPatients(data.patients || []));
}, []);
// Search patients when query changes
useEffect(() => {
if (debouncedQuery.length < 2) {
setPatients([]);
return;
}
setIsLoading(true);
fetch(`/api/patients/search?q=${encodeURIComponent(debouncedQuery)}`)
.then(res => res.json())
.then(data => {
setPatients(data.patients || []);
setHighlightedIndex(0);
})
.finally(() => setIsLoading(false));
}, [debouncedQuery]);
// Keyboard navigation
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
const items = query.length >= 2 ? patients : recentPatients;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setHighlightedIndex(i => Math.min(i + 1, items.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
setHighlightedIndex(i => Math.max(i - 1, 0));
break;
case 'Enter':
e.preventDefault();
if (items[highlightedIndex]) {
selectPatient(items[highlightedIndex]);
}
break;
case 'Escape':
setIsOpen(false);
break;
}
}, [query, patients, recentPatients, highlightedIndex]);
const selectPatient = (patient: Patient) => {
setSelectedPatient(patient);
onChange(patient.patient_id);
setIsOpen(false);
setQuery('');
};
const clearSelection = () => {
setSelectedPatient(null);
onChange('');
setQuery('');
inputRef.current?.focus();
};
const formatPatientName = (p: Patient) =>
`${p.name_given?.join(' ') || ''} ${p.name_family}`.trim();
const calculateAge = (birthDate: string) => {
const birth = new Date(birthDate);
const today = new Date();
let age = today.getFullYear() - birth.getFullYear();
if (today.getMonth() < birth.getMonth() ||
(today.getMonth() === birth.getMonth() && today.getDate() < birth.getDate())) {
age--;
}
return age;
};
// Render selected patient chip
if (selectedPatient) {
return (
<div className={cn(
"flex items-center gap-2 px-3 py-2 border rounded-md bg-slate-50",
error && "border-red-500"
)}>
<span className="flex-1">{formatPatientName(selectedPatient)}</span>
<button
type="button"
onClick={clearSelection}
className="p-1 hover:bg-slate-200 rounded"
>
<X className="h-4 w-4" />
</button>
</div>
);
}
const displayItems = query.length >= 2 ? patients : recentPatients;
const showRecent = query.length < 2 && recentPatients.length > 0;
return (
<div className="relative">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setIsOpen(true);
}}
onFocus={() => setIsOpen(true)}
onKeyDown={handleKeyDown}
placeholder="Zoek patient (naam of BSN)..."
className={cn(
"w-full pl-10 pr-10 py-2 border rounded-md",
"focus:outline-none focus:ring-2 focus:ring-teal-500",
error && "border-red-500"
)}
/>
{isLoading && (
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-slate-400" />
)}
</div>
{error && (
<p className="text-sm text-red-500 mt-1">{error}</p>
)}
{/* Dropdown */}
{isOpen && (displayItems.length > 0 || query.length >= 2) && (
<div className="absolute z-50 w-full mt-1 bg-white border rounded-md shadow-lg max-h-64 overflow-auto">
{showRecent && (
<div className="px-3 py-1.5 text-xs font-medium text-slate-500 bg-slate-50">
Recente patiënten
</div>
)}
{query.length >= 2 && patients.length === 0 && !isLoading && (
<div className="px-3 py-2 text-sm text-slate-500">
Geen patiënt gevonden
</div>
)}
{displayItems.map((patient, index) => (
<button
key={patient.patient_id}
type="button"
onClick={() => selectPatient(patient)}
className={cn(
"w-full px-3 py-2 text-left hover:bg-slate-50",
index === highlightedIndex && "bg-slate-100"
)}
>
<div className="font-medium">
{formatPatientName(patient)}
</div>
<div className="text-sm text-slate-500">
{patient.gender === 'male' ? '♂' : '♀'}{' '}
{new Date(patient.birth_date).toLocaleDateString('nl-NL')}{' '}
({calculateAge(patient.birth_date)} jaar)
</div>
</button>
))}
</div>
)}
</div>
);
}
7. Security & Compliance
🎯 Doel: Beschrijf security maatregelen en compliance vereisten.
7.1 Security Checklist
| Maatregel | Implementatie | Status |
|---|---|---|
| Authentication | Supabase Auth (bestaand) | ✅ |
| Authorization | Row Level Security (RLS) policies | 🔧 Toe te voegen |
| Data Encryption | At rest (PostgreSQL), in transit (HTTPS) | ✅ |
| Input Validation | Zod schemas op alle endpoints | ✅ |
| CORS | Next.js default (same-origin) | ✅ |
| CSRF | SameSite cookies | ✅ |
| Rate Limiting | Vercel Edge (basic) | ✅ |
7.2 Row Level Security Policies
-- Encounters: Users can only see/modify their own appointments
CREATE POLICY "Users can view own encounters"
ON encounters FOR SELECT
USING (
practitioner_id IN (
SELECT practitioner_id FROM practitioners
WHERE user_id = auth.uid()
)
);
CREATE POLICY "Users can insert own encounters"
ON encounters FOR INSERT
WITH CHECK (
practitioner_id IN (
SELECT practitioner_id FROM practitioners
WHERE user_id = auth.uid()
)
);
CREATE POLICY "Users can update own encounters"
ON encounters FOR UPDATE
USING (
practitioner_id IN (
SELECT practitioner_id FROM practitioners
WHERE user_id = auth.uid()
)
);
-- No DELETE policy - we use soft delete (status = 'cancelled')
7.3 Data Privacy (AVG/GDPR)
| Vereiste | Implementatie |
|---|---|
| Data minimalisatie | Alleen noodzakelijke velden in encounters |
| Doelbinding | Data alleen voor afspraakbeheer |
| Bewaartermijn | Conform zorgsector: 15 jaar (post-MVP) |
| Inzagerecht | Via patient dossier (bestaand) |
| Verwijderrecht | Soft delete, anonimisatie (post-MVP) |
7.4 Audit Logging (Post-MVP)
// Future: Audit log structure
interface AuditLog {
id: string;
timestamp: Date;
user_id: string;
action: 'create' | 'update' | 'delete' | 'view';
entity_type: 'encounter';
entity_id: string;
changes?: Record<string, { old: unknown; new: unknown }>;
ip_address?: string;
}
8. Performance & Scalability
🎯 Doel: Hoe performt en schaalt het systeem?
8.1 Performance Targets
| Metric | Target | Meting |
|---|---|---|
| Page load (FCP) | < 1.5s | Vercel Analytics |
| Calendar render | < 500ms | Console timing |
| API response | < 200ms | Server logs |
| Patient search | < 300ms | User perception |
| Conflict check | < 100ms | Inline, non-blocking |
8.2 Optimization Strategies
Database:
-- Indexes voor snelle queries
CREATE INDEX idx_encounters_period ON encounters(period_start, period_end);
CREATE INDEX idx_encounters_practitioner_period
ON encounters(practitioner_id, period_start);
CREATE INDEX idx_patients_name ON patients(name_family, name_given);
Frontend:
- FullCalendar lazy loading per view
- SWR caching met stale-while-revalidate
- Optimistic updates voor instant feedback
- Skeleton loading states
Data Fetching:
// SWR configuration
const { data, mutate } = useSWR(
`/api/appointments?start=${start}&end=${end}`,
fetcher,
{
revalidateOnFocus: false,
dedupingInterval: 5000,
keepPreviousData: true, // Smooth transitions
}
);
8.3 Caching Strategy
| Data | Cache | TTL | Invalidation |
|---|---|---|---|
| Appointments (week) | SWR | 5 min | On mutation |
| Patient search | None | - | Real-time |
| Recent patients | LocalStorage | 1 hour | On new appointment |
| Calendar config | Static | ∞ | Build time |
9. Deployment & CI/CD
🎯 Doel: Hoe wordt het systeem gedeployed?
9.1 Deployment Pipeline
Git Push (feature branch)
│
▼
GitHub Actions
│
├── Lint (ESLint)
├── Type Check (tsc)
├── Build (next build)
│
▼
Vercel Preview Deploy
│
▼
PR Review + Merge to main
│
▼
Vercel Production Deploy
│
▼
Supabase Migrations (manual or via CLI)
9.2 Environment Variables
# Bestaande variabelen (geen wijziging nodig)
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
# Geen nieuwe env vars nodig voor agenda module
9.3 Database Migrations
# E0: Add encounter_id to reports
supabase migration new add_encounter_id_to_reports
# Migration file content:
ALTER TABLE reports ADD COLUMN encounter_id UUID REFERENCES encounters(id);
CREATE INDEX idx_reports_encounter ON reports(encounter_id);
# Apply migration
supabase db push
9.4 Pre-deployment Checklist
- Database migration applied
- TypeScript types regenerated (
pnpm types:generate) - All tests passing
- Preview deployment tested
- Performance acceptable (Lighthouse > 90)
- Mobile responsive verified
10. Monitoring & Logging
🎯 Doel: Hoe monitoren we het systeem?
10.1 Monitoring Stack (Bestaand)
| Tool | Gebruik |
|---|---|
| Vercel Analytics | Traffic, Web Vitals |
| Vercel Logs | Server-side errors |
| Console | Client-side debugging |
| Supabase Dashboard | Database metrics |
10.2 Key Metrics voor Agenda
| Metric | Alert threshold | Monitoring |
|---|---|---|
| API error rate | > 5% | Vercel Logs |
| Page load time | > 3s | Web Vitals |
| Calendar render errors | Any | Console + Sentry (post-MVP) |
| Database query time | > 1s | Supabase logs |
10.3 Logging Strategy
// Server-side logging
console.log('[AGENDA] Creating appointment:', { patient_id, period_start });
console.error('[AGENDA] Error creating appointment:', error);
// Client-side (development only)
if (process.env.NODE_ENV === 'development') {
console.log('[Calendar] Events loaded:', events.length);
}
11. Risico's & Technische Mitigatie
🎯 Doel: Technische risico's vroegtijdig identificeren.
11.1 Risico Matrix
| Risico | Impact | Kans | Mitigatie |
|---|---|---|---|
| FullCalendar styling conflicts | Middel | Middel | CSS isolation, early spike, fallback naar simpel grid |
| Timezone bugs | Hoog | Middel | TIMESTAMPTZ in DB, date-fns-tz, test zomertijd |
| Performance bij veel events | Middel | Laag | Pagination, lazy loading, date range queries |
| Drag-drop mobile issues | Laag | Hoog | Mobile: disable drag, use edit button |
| Concurrent edit conflicts | Middel | Laag | Optimistic locking met updated_at check |
| Browser compatibility | Laag | Laag | FullCalendar supports all modern browsers |
11.2 Fallback Strategies
FullCalendar fallback: Als FullCalendar te complex blijkt:
- Simpele CSS Grid kalender met custom events
- Agenda-lijst view als alternatief
- Week view prioriteit, dag view stretch
Timezone fallback:
// Always store UTC, display local
const displayTime = formatInTimeZone(
appointment.period_start,
'Europe/Amsterdam',
'HH:mm'
);
12. Testing Strategy
🎯 Doel: Hoe testen we de implementatie?
12.1 Test Types
| Type | Coverage | Tools |
|---|---|---|
| Unit tests | Utils, transformers | Vitest |
| Component tests | Modal, search | Testing Library |
| Integration tests | API routes | Vitest + MSW |
| E2E tests | Core flows | Playwright (post-MVP) |
12.2 Critical Test Cases
// Conflict detection tests
describe('checkConflicts', () => {
it('detects overlap at start', () => {
// Existing: 14:00-15:00
// New: 13:30-14:30 → should conflict
});
it('detects overlap at end', () => {
// Existing: 14:00-15:00
// New: 14:30-15:30 → should conflict
});
it('allows adjacent appointments', () => {
// Existing: 14:00-15:00
// New: 15:00-16:00 → no conflict
});
it('excludes cancelled appointments', () => {
// Cancelled at 14:00
// New: 14:00 → no conflict
});
});
// Date handling tests
describe('appointment utils', () => {
it('calculates end time correctly', () => {
const start = new Date('2024-12-05T14:00:00Z');
const end = calculateEndTime(start, 60);
expect(end.toISOString()).toBe('2024-12-05T15:00:00.000Z');
});
it('handles timezone correctly', () => {
// Test Amsterdam timezone edge cases
});
});
13. Implementation Epics
🎯 Doel: High-level implementatie volgorde.
Epic Overview
| Epic | Naam | Story Points | Dependency |
|---|---|---|---|
| E0 | Database & Types | 3 | - |
| E1 | Calendar Views | 8 | E0 |
| E2 | Appointment CRUD | 8 | E0 |
| E3 | Patient Integration | 5 | E2 |
| E4 | EPD Linking | 5 | E2, E3 |
| E5 | Polish & Testing | 5 | E1-E4 |
| Total | 34 |
E0: Database & Types (Day 1)
- Migration: Add encounter_id to reports
- Regenerate TypeScript types
- Create lib/types/encounter.ts
- Create lib/types/appointment.ts with Zod schemas
E1: Calendar Views (Days 2-4)
- Install FullCalendar packages
- Create calendar-view.tsx component
- Implement day/week/workdays views
- Add toolbar navigation
- Style calendar with Tailwind
- Add loading/error states
E2: Appointment CRUD (Days 5-7)
- Create server actions (create, update, cancel)
- Implement appointment modal
- Add conflict detection
- Implement drag-drop reschedule
- Add confirmation dialogs
E3: Patient Integration (Days 8-9)
- Create patient search component
- Implement autocomplete with debounce
- Add recent patients feature
- Integrate with appointment modal
E4: EPD Linking (Days 10-11)
- Create appointment detail popup
- Add "Maak verslag" navigation
- Pass encounter_id to rapportage
- Show report icon on appointments
E5: Polish & Testing (Days 12-14)
- Mobile responsiveness
- Keyboard shortcuts
- Edge case testing
- Performance optimization
- Documentation
14. Bijlagen & Referenties
14.1 Project Documenten
| Document | Locatie |
|---|---|
| PRD Agenda Module | docs/specs/agenda/prd-agenda-module-v1_0.md |
| FO Agenda Module | docs/specs/agenda/fo-agenda-module-v1_0.md |
| Database Types | lib/supabase/database.types.ts |
14.2 Technische Documentatie
| Resource | URL |
|---|---|
| Next.js 14 | https://nextjs.org/docs |
| FullCalendar React | https://fullcalendar.io/docs/react |
| Supabase | https://supabase.com/docs |
| shadcn/ui | https://ui.shadcn.com/docs |
| date-fns | https://date-fns.org/docs |
| SWR | https://swr.vercel.app |
14.3 Bestaande Codebase Referenties
| Pattern | Locatie | Hergebruik |
|---|---|---|
| Server Actions | app/epd/patients/[id]/intakes/actions.ts |
CRUD pattern |
| Form Validation | lib/types/intake.ts |
Zod schemas |
| API Routes | app/api/intakes/route.ts |
Error handling |
| Modal Dialogs | components/ui/dialog.tsx |
UI component |
| Patient Types | lib/types/client.ts |
Helper functions |
Changelog
| Versie | Datum | Auteur | Wijziging |
|---|---|---|---|
| v1.0 | 02-12-2024 | Colin | Initieel TO op basis van PRD v1.0 en FO v1.1 |
Document Status: ✅ Klaar voor review Volgende stap: → E0 Database Migration implementeren