feat(agenda): Epic 1 - Calendar views with FullCalendar

- E1.S1: FullCalendar setup with React wrapper
- E1.S2: Day view (timeGridDay) with hourly slots
- E1.S3: Week view (timeGridWeek) with 7-day grid
- E1.S4: Workweek view (timeGridWorkWeek, Mon-Fri)

Features:
- View switcher toolbar (Dag/Week/Werkweek)
- Date navigation (prev/next/today)
- Dutch localization (nl)
- Business hours highlighting (08:00-18:00)
- Color-coded appointments by type
- Drag-and-drop rescheduling support
- Server actions for encounter CRUD

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
colinislit
2025-12-02 17:13:17 +01:00
parent 43c7cb898b
commit e661c97491
10 changed files with 878 additions and 27 deletions

182
app/epd/agenda/actions.ts Normal file
View File

@@ -0,0 +1,182 @@
'use server';
/**
* Agenda Server Actions
*
* Server-side data fetching and mutations for the agenda module.
*/
import { createClient } from '@/lib/auth/server';
import { revalidatePath } from 'next/cache';
import type { CalendarEvent, AppointmentTypeCode, APPOINTMENT_TYPE_COLORS } from './types';
const TYPE_COLORS: Record<string, { bg: string; border: string; text: string }> = {
intake: { bg: '#dbeafe', border: '#3b82f6', text: '#1e40af' },
behandeling: { bg: '#dcfce7', border: '#22c55e', text: '#166534' },
'follow-up': { bg: '#f3e8ff', border: '#a855f7', text: '#6b21a8' },
telefonisch: { bg: '#fef3c7', border: '#f59e0b', text: '#92400e' },
huisbezoek: { bg: '#ffedd5', border: '#f97316', text: '#9a3412' },
online: { bg: '#e0e7ff', border: '#6366f1', text: '#3730a3' },
crisis: { bg: '#fee2e2', border: '#ef4444', text: '#991b1b' },
overig: { bg: '#f1f5f9', border: '#64748b', text: '#334155' },
};
interface GetEncountersParams {
startDate: string;
endDate: string;
practitionerId?: string;
}
export async function getEncounters({
startDate,
endDate,
practitionerId,
}: GetEncountersParams): Promise<CalendarEvent[]> {
const supabase = await createClient();
let query = supabase
.from('encounters')
.select(`
*,
patients:patient_id (
id,
name_family,
name_given,
birth_date
)
`)
.gte('period_start', startDate)
.lte('period_start', endDate)
.neq('status', 'cancelled')
.order('period_start', { ascending: true });
if (practitionerId) {
query = query.eq('practitioner_id', practitionerId);
}
const { data, error } = await query;
if (error) {
console.error('Error fetching encounters:', error);
return [];
}
return (data || []).map((encounter) => {
const patient = encounter.patients as {
id: string;
name_family: string;
name_given: string[];
birth_date: string;
} | null;
const patientName = patient
? `${patient.name_given?.[0] || ''} ${patient.name_family}`.trim()
: 'Onbekende patiënt';
const typeCode = encounter.type_code as AppointmentTypeCode;
const colors = TYPE_COLORS[typeCode] || TYPE_COLORS.overig;
return {
id: encounter.id,
title: patientName,
start: encounter.period_start,
end: encounter.period_end || undefined,
backgroundColor: colors.bg,
borderColor: colors.border,
textColor: colors.text,
extendedProps: {
encounter,
patient: patient || undefined,
},
} as CalendarEvent;
});
}
interface CreateEncounterParams {
patientId: string;
practitionerId: string;
periodStart: string;
periodEnd?: string;
typeCode: string;
typeDisplay: string;
classCode: string;
classDisplay: string;
notes?: string;
}
export async function createEncounter(params: CreateEncounterParams) {
const supabase = await createClient();
const { data, error } = await supabase
.from('encounters')
.insert({
patient_id: params.patientId,
practitioner_id: params.practitionerId,
period_start: params.periodStart,
period_end: params.periodEnd,
type_code: params.typeCode,
type_display: params.typeDisplay,
class_code: params.classCode,
class_display: params.classDisplay,
notes: params.notes,
status: 'planned',
})
.select()
.single();
if (error) {
console.error('Error creating encounter:', error);
return { success: false, error: error.message };
}
revalidatePath('/epd/agenda');
return { success: true, data };
}
export async function updateEncounter(
encounterId: string,
updates: {
periodStart?: string;
periodEnd?: string;
status?: string;
notes?: string;
}
) {
const supabase = await createClient();
const updateData: Record<string, unknown> = {};
if (updates.periodStart) updateData.period_start = updates.periodStart;
if (updates.periodEnd) updateData.period_end = updates.periodEnd;
if (updates.status) updateData.status = updates.status;
if (updates.notes !== undefined) updateData.notes = updates.notes;
const { data, error } = await supabase
.from('encounters')
.update(updateData)
.eq('id', encounterId)
.select()
.single();
if (error) {
console.error('Error updating encounter:', error);
return { success: false, error: error.message };
}
revalidatePath('/epd/agenda');
return { success: true, data };
}
export async function cancelEncounter(encounterId: string) {
return updateEncounter(encounterId, { status: 'cancelled' });
}
export async function rescheduleEncounter(
encounterId: string,
newStart: string,
newEnd: string | null
) {
return updateEncounter(encounterId, {
periodStart: newStart,
periodEnd: newEnd || undefined,
});
}

View File

@@ -0,0 +1,211 @@
'use client';
/**
* Agenda Calendar Component
*
* FullCalendar wrapper with day/week/workweek views.
*/
import { useCallback, useRef } 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, CalendarApi } from '@fullcalendar/core';
import nlLocale from '@fullcalendar/core/locales/nl';
import type { CalendarEvent, CalendarView } from '../types';
interface AgendaCalendarProps {
events: CalendarEvent[];
initialView?: CalendarView;
onEventClick?: (event: CalendarEvent) => void;
onDateSelect?: (start: Date, end: Date) => void;
onEventDrop?: (eventId: string, newStart: Date, newEnd: Date | null) => void;
onDateChange?: (start: Date, end: Date) => void;
calendarRef?: React.RefObject<CalendarApi | null>;
}
export function AgendaCalendar({
events,
initialView = 'timeGridWeek',
onEventClick,
onDateSelect,
onEventDrop,
onDateChange,
calendarRef: externalRef,
}: AgendaCalendarProps) {
const internalRef = useRef<FullCalendar>(null);
const handleEventClick = useCallback((info: EventClickArg) => {
if (onEventClick) {
const event = info.event;
const calendarEvent: CalendarEvent = {
id: event.id,
title: event.title,
start: event.start!,
end: event.end || undefined,
extendedProps: event.extendedProps as CalendarEvent['extendedProps'],
backgroundColor: event.backgroundColor,
borderColor: event.borderColor,
};
onEventClick(calendarEvent);
}
}, [onEventClick]);
const handleDateSelect = useCallback((info: DateSelectArg) => {
if (onDateSelect) {
onDateSelect(info.start, info.end);
}
}, [onDateSelect]);
const handleEventDrop = useCallback((info: EventDropArg) => {
if (onEventDrop) {
onEventDrop(
info.event.id,
info.event.start!,
info.event.end
);
}
}, [onEventDrop]);
const handleDatesSet = useCallback((dateInfo: { start: Date; end: Date }) => {
if (onDateChange) {
onDateChange(dateInfo.start, dateInfo.end);
}
}, [onDateChange]);
return (
<div className="agenda-calendar h-full">
<FullCalendar
ref={internalRef}
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
initialView={initialView}
locale={nlLocale}
headerToolbar={false}
events={events}
editable={true}
selectable={true}
selectMirror={true}
dayMaxEvents={true}
weekends={true}
slotMinTime="07:00:00"
slotMaxTime="20:00:00"
slotDuration="00:30:00"
slotLabelInterval="01:00:00"
slotLabelFormat={{
hour: '2-digit',
minute: '2-digit',
hour12: false,
}}
eventTimeFormat={{
hour: '2-digit',
minute: '2-digit',
hour12: false,
}}
allDaySlot={false}
nowIndicator={true}
height="100%"
expandRows={true}
stickyHeaderDates={true}
eventClick={handleEventClick}
select={handleDateSelect}
eventDrop={handleEventDrop}
datesSet={handleDatesSet}
views={{
timeGridWorkWeek: {
type: 'timeGrid',
duration: { weeks: 1 },
hiddenDays: [0, 6], // Hide Sunday and Saturday
buttonText: 'Werkweek',
},
}}
businessHours={{
daysOfWeek: [1, 2, 3, 4, 5],
startTime: '08:00',
endTime: '18:00',
}}
eventContent={(eventInfo) => (
<div className="p-1 overflow-hidden h-full">
<div className="font-medium text-xs truncate">
{eventInfo.timeText}
</div>
<div className="font-semibold text-sm truncate">
{eventInfo.event.title}
</div>
{eventInfo.event.extendedProps?.encounter?.type_display && (
<div className="text-xs opacity-80 truncate">
{eventInfo.event.extendedProps.encounter.type_display}
</div>
)}
</div>
)}
/>
<style jsx global>{`
.agenda-calendar .fc {
--fc-border-color: #e2e8f0;
--fc-today-bg-color: #f0fdf4;
--fc-now-indicator-color: #22c55e;
--fc-event-border-color: transparent;
font-family: inherit;
}
.agenda-calendar .fc-theme-standard td,
.agenda-calendar .fc-theme-standard th {
border-color: var(--fc-border-color);
}
.agenda-calendar .fc-timegrid-slot {
height: 2.5rem;
}
.agenda-calendar .fc-timegrid-slot-label {
font-size: 0.75rem;
color: #64748b;
}
.agenda-calendar .fc-col-header-cell {
padding: 0.5rem 0;
font-weight: 600;
color: #334155;
background-color: #f8fafc;
}
.agenda-calendar .fc-col-header-cell-cushion {
padding: 0.25rem 0.5rem;
}
.agenda-calendar .fc-day-today .fc-col-header-cell-cushion {
color: #16a34a;
}
.agenda-calendar .fc-timegrid-event {
border-radius: 0.375rem;
border-width: 2px;
border-left-width: 4px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.agenda-calendar .fc-timegrid-event:hover {
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.agenda-calendar .fc-timegrid-now-indicator-line {
border-width: 2px;
}
.agenda-calendar .fc-timegrid-now-indicator-arrow {
border-width: 6px;
}
.agenda-calendar .fc-highlight {
background-color: #dbeafe;
}
.agenda-calendar .fc-non-business {
background-color: #f8fafc;
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,126 @@
'use client';
/**
* Agenda Toolbar Component
*
* View switcher and date navigation for the calendar.
*/
import { ChevronLeft, ChevronRight, Plus, Calendar } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { format } from 'date-fns';
import { nl } from 'date-fns/locale';
import type { CalendarView } from '../types';
interface AgendaToolbarProps {
currentDate: Date;
currentView: CalendarView;
onViewChange: (view: CalendarView) => void;
onNavigate: (direction: 'prev' | 'next' | 'today') => void;
onNewAppointment: () => void;
}
const VIEW_OPTIONS: { value: CalendarView; label: string }[] = [
{ value: 'timeGridDay', label: 'Dag' },
{ value: 'timeGridWeek', label: 'Week' },
{ value: 'timeGridWorkWeek', label: 'Werkweek' },
];
export function AgendaToolbar({
currentDate,
currentView,
onViewChange,
onNavigate,
onNewAppointment,
}: AgendaToolbarProps) {
const getDateLabel = () => {
if (currentView === 'timeGridDay') {
return format(currentDate, 'EEEE d MMMM yyyy', { locale: nl });
}
// For week views, show the week range
const startOfWeek = new Date(currentDate);
const day = startOfWeek.getDay();
const diff = startOfWeek.getDate() - day + (day === 0 ? -6 : 1);
startOfWeek.setDate(diff);
const endOfWeek = new Date(startOfWeek);
endOfWeek.setDate(endOfWeek.getDate() + (currentView === 'timeGridWorkWeek' ? 4 : 6));
if (startOfWeek.getMonth() === endOfWeek.getMonth()) {
return `${format(startOfWeek, 'd')} - ${format(endOfWeek, 'd MMMM yyyy', { locale: nl })}`;
}
if (startOfWeek.getFullYear() === endOfWeek.getFullYear()) {
return `${format(startOfWeek, 'd MMM', { locale: nl })} - ${format(endOfWeek, 'd MMM yyyy', { locale: nl })}`;
}
return `${format(startOfWeek, 'd MMM yyyy', { locale: nl })} - ${format(endOfWeek, 'd MMM yyyy', { locale: nl })}`;
};
return (
<div className="flex items-center justify-between gap-4 mb-4">
{/* Left: Title and Date */}
<div className="flex items-center gap-4">
<h1 className="text-2xl font-bold text-slate-900">Agenda</h1>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={() => onNavigate('prev')}
className="h-8 w-8"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onNavigate('today')}
className="h-8"
>
<Calendar className="h-4 w-4 mr-1" />
Vandaag
</Button>
<Button
variant="outline"
size="icon"
onClick={() => onNavigate('next')}
className="h-8 w-8"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<span className="text-lg font-medium text-slate-700 capitalize">
{getDateLabel()}
</span>
</div>
{/* Right: View Switcher and New Appointment */}
<div className="flex items-center gap-3">
{/* View Switcher */}
<div className="flex rounded-lg border border-slate-200 bg-slate-100 p-1">
{VIEW_OPTIONS.map((option) => (
<button
key={option.value}
onClick={() => onViewChange(option.value)}
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
currentView === option.value
? 'bg-white text-slate-900 shadow-sm'
: 'text-slate-600 hover:text-slate-900'
}`}
>
{option.label}
</button>
))}
</div>
{/* New Appointment Button */}
<Button onClick={onNewAppointment} className="gap-2">
<Plus className="h-4 w-4" />
Nieuwe Afspraak
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,164 @@
'use client';
/**
* Agenda View Component
*
* Client-side wrapper managing calendar state, view switching, and interactions.
*/
import { useState, useCallback, useRef, useEffect, useTransition } from 'react';
import { startOfWeek, endOfWeek, addDays, format } from 'date-fns';
import { toast } from '@/hooks/use-toast';
import { AgendaCalendar } from './agenda-calendar';
import { AgendaToolbar } from './agenda-toolbar';
import { getEncounters, rescheduleEncounter } from '../actions';
import type { CalendarEvent, CalendarView } from '../types';
interface AgendaViewProps {
initialEvents: CalendarEvent[];
initialDate?: Date;
}
export function AgendaView({ initialEvents, initialDate }: AgendaViewProps) {
const [events, setEvents] = useState<CalendarEvent[]>(initialEvents);
const [currentDate, setCurrentDate] = useState(initialDate || new Date());
const [currentView, setCurrentView] = useState<CalendarView>('timeGridWeek');
const [isPending, startTransition] = useTransition();
const calendarRef = useRef<{ getApi: () => { prev: () => void; next: () => void; today: () => void; changeView: (view: string) => void; getDate: () => Date } } | null>(null);
// Fetch events when date range changes
const fetchEvents = useCallback(async (start: Date, end: Date) => {
startTransition(async () => {
const newEvents = await getEncounters({
startDate: start.toISOString(),
endDate: end.toISOString(),
});
setEvents(newEvents);
});
}, []);
// Handle view change
const handleViewChange = useCallback((view: CalendarView) => {
setCurrentView(view);
}, []);
// Handle navigation
const handleNavigate = useCallback((direction: 'prev' | 'next' | 'today') => {
let newDate = new Date(currentDate);
if (direction === 'today') {
newDate = new Date();
} else {
const days = currentView === 'timeGridDay' ? 1 : 7;
const offset = direction === 'prev' ? -days : days;
newDate = addDays(currentDate, offset);
}
setCurrentDate(newDate);
// Calculate date range for fetching
const start = currentView === 'timeGridDay'
? newDate
: startOfWeek(newDate, { weekStartsOn: 1 });
const end = currentView === 'timeGridDay'
? addDays(newDate, 1)
: endOfWeek(newDate, { weekStartsOn: 1 });
fetchEvents(start, end);
}, [currentDate, currentView, fetchEvents]);
// Handle date range change from calendar
const handleDateChange = useCallback((start: Date, end: Date) => {
setCurrentDate(start);
fetchEvents(start, end);
}, [fetchEvents]);
// Handle event click
const handleEventClick = useCallback((event: CalendarEvent) => {
// TODO: Open appointment details modal
toast({
title: `Afspraak: ${event.title}`,
description: event.extendedProps.encounter.type_display,
});
}, []);
// Handle date selection (for creating new appointment)
const handleDateSelect = useCallback((start: Date, end: Date) => {
// TODO: Open new appointment modal with pre-filled dates
toast({
title: 'Nieuwe afspraak',
description: `${format(start, 'HH:mm')} - ${format(end, 'HH:mm')}`,
});
}, []);
// Handle event drag-and-drop
const handleEventDrop = useCallback(async (
eventId: string,
newStart: Date,
newEnd: Date | null
) => {
const result = await rescheduleEncounter(
eventId,
newStart.toISOString(),
newEnd?.toISOString() || null
);
if (result.success) {
toast({ title: 'Afspraak verzet' });
// Update local state optimistically
setEvents((prev) =>
prev.map((e) =>
e.id === eventId
? { ...e, start: newStart, end: newEnd || undefined }
: e
)
);
} else {
toast({
variant: 'destructive',
title: 'Kon afspraak niet verzetten',
description: result.error,
});
// Refresh events to revert
const start = startOfWeek(currentDate, { weekStartsOn: 1 });
const end = endOfWeek(currentDate, { weekStartsOn: 1 });
fetchEvents(start, end);
}
}, [currentDate, fetchEvents]);
// Handle new appointment button
const handleNewAppointment = useCallback(() => {
// TODO: Open new appointment modal
toast({ title: 'Nieuwe afspraak modal (nog te implementeren)' });
}, []);
return (
<div className="flex flex-col h-[calc(100vh-8rem)]">
<AgendaToolbar
currentDate={currentDate}
currentView={currentView}
onViewChange={handleViewChange}
onNavigate={handleNavigate}
onNewAppointment={handleNewAppointment}
/>
<div className="flex-1 bg-white rounded-lg border border-slate-200 overflow-hidden">
{isPending && (
<div className="absolute inset-0 bg-white/50 flex items-center justify-center z-10">
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
</div>
)}
<AgendaCalendar
events={events}
initialView={currentView}
onEventClick={handleEventClick}
onDateSelect={handleDateSelect}
onEventDrop={handleEventDrop}
onDateChange={handleDateChange}
/>
</div>
</div>
);
}

View File

@@ -2,26 +2,29 @@
* Behandelaar Agenda - Level 1
*
* Kalender view met alle afspraken van de behandelaar.
* Supports day, week, and workweek views.
*/
export default function AgendaPage() {
return (
<div>
<h1 className="text-2xl font-bold text-slate-900 mb-6">
Agenda
</h1>
import { startOfWeek, endOfWeek } from 'date-fns';
import { AgendaView } from './components/agenda-view';
import { getEncounters } from './actions';
<div className="bg-slate-100 border-2 border-dashed border-slate-300 rounded-lg p-12 text-center">
<p className="text-slate-600 text-lg mb-2">
Behandelaar Agenda
</p>
<p className="text-slate-500 text-sm">
Kalender view met alle afspraken (alle cliënten)
</p>
<p className="text-xs text-slate-400 mt-4">
Placeholder - Not designed yet
</p>
</div>
</div>
export default async function AgendaPage() {
// Get initial date range (current week)
const now = new Date();
const start = startOfWeek(now, { weekStartsOn: 1 });
const end = endOfWeek(now, { weekStartsOn: 1 });
// Fetch initial events
const initialEvents = await getEncounters({
startDate: start.toISOString(),
endDate: end.toISOString(),
});
return (
<AgendaView
initialEvents={initialEvents}
initialDate={now}
/>
);
}

91
app/epd/agenda/types.ts Normal file
View File

@@ -0,0 +1,91 @@
/**
* Agenda Module Types
*
* Type definitions for the agenda/calendar functionality.
*/
import type { Database } from '@/lib/supabase/database.types';
// Database types
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'];
export type Patient = Database['public']['Tables']['patients']['Row'];
export type Report = Database['public']['Tables']['reports']['Row'];
// Appointment type codes
export const APPOINTMENT_TYPES = {
intake: 'Intakegesprek',
behandeling: 'Behandelsessie',
'follow-up': 'Vervolggesprek',
telefonisch: 'Telefonisch contact',
huisbezoek: 'Huisbezoek',
online: 'Online consult',
crisis: 'Crisiscontact',
overig: 'Overig',
} as const;
export type AppointmentTypeCode = keyof typeof APPOINTMENT_TYPES;
// Location class codes
export const LOCATION_CLASSES = {
AMB: 'Praktijk',
VR: 'Online/Virtueel',
HH: 'Thuis (huisbezoek)',
} as const;
export type LocationClassCode = keyof typeof LOCATION_CLASSES;
// Calendar event for FullCalendar
export interface CalendarEvent {
id: string;
title: string;
start: Date | string;
end?: Date | string;
allDay?: boolean;
extendedProps: {
encounter: Encounter;
patient?: Patient;
linkedReports?: Report[];
};
backgroundColor?: string;
borderColor?: string;
textColor?: string;
classNames?: string[];
}
// Calendar view types
export type CalendarView = 'timeGridDay' | 'timeGridWeek' | 'timeGridWorkWeek';
// Appointment form data
export interface AppointmentFormData {
patientId: string;
date: Date;
startTime: string;
endTime?: string;
typeCode: AppointmentTypeCode;
classCode: LocationClassCode;
practitionerId: string;
notes?: string;
}
// Filter options for calendar
export interface CalendarFilters {
practitionerId?: string;
typeCode?: AppointmentTypeCode[];
status?: EncounterStatus[];
}
// Color mapping for appointment types
export const APPOINTMENT_TYPE_COLORS: Record<AppointmentTypeCode, { bg: string; border: string; text: string }> = {
intake: { bg: '#dbeafe', border: '#3b82f6', text: '#1e40af' },
behandeling: { bg: '#dcfce7', border: '#22c55e', text: '#166534' },
'follow-up': { bg: '#f3e8ff', border: '#a855f7', text: '#6b21a8' },
telefonisch: { bg: '#fef3c7', border: '#f59e0b', text: '#92400e' },
huisbezoek: { bg: '#ffedd5', border: '#f97316', text: '#9a3412' },
online: { bg: '#e0e7ff', border: '#6366f1', text: '#3730a3' },
crisis: { bg: '#fee2e2', border: '#ef4444', text: '#991b1b' },
overig: { bg: '#f1f5f9', border: '#64748b', text: '#334155' },
};