Files
triqura-ecd/docs/specs/agenda/to-agenda-module-v1_0.md
colinislit 43c7cb898b feat(agenda): Epic 0 - Database schema for agenda module
- 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>
2025-12-02 16:44:31 +01:00

58 KiB
Raw Blame History

⚙️ 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:

  1. Simpele CSS Grid kalender met custom events
  2. Agenda-lijst view als alternatief
  3. 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