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

View File

@@ -33,6 +33,7 @@ export async function POST(request: Request) {
const { action, context, patientId, intakeId, reportId, metadata } = payload.data const { action, context, patientId, intakeId, reportId, metadata } = payload.data
// @ts-expect-error speech_usage_events table not in generated types yet
const { error } = await supabase.from('speech_usage_events').insert({ const { error } = await supabase.from('speech_usage_events').insert({
action, action,
context, context,

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

View File

@@ -64,8 +64,8 @@
| Epic ID | Titel | Doel | Status | Stories | | Epic ID | Titel | Doel | Status | Stories |
|---------|-------|------|--------|---------| |---------|-------|------|--------|---------|
| E0 | Database & Types | Schema uitbreiden, types genereren | ⏳ To Do | 3 | | E0 | Database & Types | Schema uitbreiden, types genereren | Done | 3 |
| E1 | Calendar Views | Dag/week/werkdagen weergaven | ⏳ To Do | 4 | | E1 | Calendar Views | Dag/week/werkdagen weergaven | Done | 4 |
| E2 | Afspraak CRUD | Maken, bewerken, annuleren | ⏳ To Do | 5 | | E2 | Afspraak CRUD | Maken, bewerken, annuleren | ⏳ To Do | 5 |
| E3 | Patiënt Integratie | Selectie, zoeken, quick-create | ⏳ To Do | 3 | | E3 | Patiënt Integratie | Selectie, zoeken, quick-create | ⏳ To Do | 3 |
| E4 | EPD Koppeling | Verslag ↔ Afspraak bidirectioneel | ⏳ To Do | 4 | | E4 | EPD Koppeling | Verslag ↔ Afspraak bidirectioneel | ⏳ To Do | 4 |
@@ -81,9 +81,9 @@
| Story ID | Beschrijving | Acceptatiecriteria | Status | Afhankelijkheden | SP | | Story ID | Beschrijving | Acceptatiecriteria | Status | Afhankelijkheden | SP |
|----------|--------------|---------------------|--------|------------------|----| |----------|--------------|---------------------|--------|------------------|----|
| E0.S1 | Migration: encounter_id toevoegen aan reports | `encounter_id` en `intake_id` kolommen bestaan, foreign keys werken | | — | 2 | | E0.S1 | Migration: encounter_id toevoegen aan reports | `encounter_id` en `intake_id` kolommen bestaan, foreign keys werken | | — | 2 |
| E0.S2 | Index toevoegen voor performance | `idx_reports_encounter`, `idx_encounters_period` indices bestaan | | E0.S1 | 1 | | E0.S2 | Index toevoegen voor performance | `idx_reports_encounter`, `idx_encounters_period` indices bestaan | | E0.S1 | 1 |
| E0.S3 | TypeScript types regenereren | `database.types.ts` bevat nieuwe kolommen | | E0.S2 | 1 | | E0.S3 | TypeScript types regenereren | `database.types.ts` bevat nieuwe kolommen | | E0.S2 | 1 |
**Technical Notes:** **Technical Notes:**
```sql ```sql
@@ -107,10 +107,10 @@ CREATE INDEX idx_encounters_practitioner ON encounters(practitioner_id);
| Story ID | Beschrijving | Acceptatiecriteria | Status | Afhankelijkheden | SP | | Story ID | Beschrijving | Acceptatiecriteria | Status | Afhankelijkheden | SP |
|----------|--------------|---------------------|--------|------------------|----| |----------|--------------|---------------------|--------|------------------|----|
| E1.S1 | FullCalendar installatie & setup | Library geïnstalleerd, basis component rendert | | E0.S3 | 2 | | E1.S1 | FullCalendar installatie & setup | Library geïnstalleerd, basis component rendert | | E0.S3 | 2 |
| E1.S2 | Dag view implementeren | Uurblokken 08:00-18:00, afspraken zichtbaar | | E1.S1 | 3 | | E1.S2 | Dag view implementeren | Uurblokken 08:00-18:00, afspraken zichtbaar | | E1.S1 | 3 |
| E1.S3 | Week view implementeren | 7-dagen grid, drag-resize werkt | | E1.S2 | 3 | | E1.S3 | Week view implementeren | 7-dagen grid, drag-resize werkt | | E1.S2 | 3 |
| E1.S4 | Werkdagen view (ma-vr) | Filter voor weekend, business hours highlight | | E1.S3 | 2 | | E1.S4 | Werkdagen view (ma-vr) | Filter voor weekend, business hours highlight | | E1.S3 | 2 |
**Technical Notes:** **Technical Notes:**
```bash ```bash

View File

@@ -13,6 +13,11 @@
}, },
"dependencies": { "dependencies": {
"@deepgram/sdk": "^4.11.2", "@deepgram/sdk": "^4.11.2",
"@fullcalendar/core": "^6.1.19",
"@fullcalendar/daygrid": "^6.1.19",
"@fullcalendar/interaction": "^6.1.19",
"@fullcalendar/react": "^6.1.19",
"@fullcalendar/timegrid": "^6.1.19",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",

68
pnpm-lock.yaml generated
View File

@@ -11,6 +11,21 @@ importers:
'@deepgram/sdk': '@deepgram/sdk':
specifier: ^4.11.2 specifier: ^4.11.2
version: 4.11.2 version: 4.11.2
'@fullcalendar/core':
specifier: ^6.1.19
version: 6.1.19
'@fullcalendar/daygrid':
specifier: ^6.1.19
version: 6.1.19(@fullcalendar/core@6.1.19)
'@fullcalendar/interaction':
specifier: ^6.1.19
version: 6.1.19(@fullcalendar/core@6.1.19)
'@fullcalendar/react':
specifier: ^6.1.19
version: 6.1.19(@fullcalendar/core@6.1.19)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@fullcalendar/timegrid':
specifier: ^6.1.19
version: 6.1.19(@fullcalendar/core@6.1.19)
'@hookform/resolvers': '@hookform/resolvers':
specifier: ^5.2.2 specifier: ^5.2.2
version: 5.2.2(react-hook-form@7.66.1(react@18.3.1)) version: 5.2.2(react-hook-form@7.66.1(react@18.3.1))
@@ -372,6 +387,31 @@ packages:
'@floating-ui/utils@0.2.10': '@floating-ui/utils@0.2.10':
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
'@fullcalendar/core@6.1.19':
resolution: {integrity: sha512-z0aVlO5e4Wah6p6mouM0UEqtRf1MZZPt4mwzEyU6kusaNL+dlWQgAasF2cK23hwT4cmxkEmr4inULXgpyeExdQ==}
'@fullcalendar/daygrid@6.1.19':
resolution: {integrity: sha512-IAAfnMICnVWPjpT4zi87i3FEw0xxSza0avqY/HedKEz+l5MTBYvCDPOWDATpzXoLut3aACsjktIyw9thvIcRYQ==}
peerDependencies:
'@fullcalendar/core': ~6.1.19
'@fullcalendar/interaction@6.1.19':
resolution: {integrity: sha512-GOciy79xe8JMVp+1evAU3ytdwN/7tv35t5i1vFkifiuWcQMLC/JnLg/RA2s4sYmQwoYhTw/p4GLcP0gO5B3X5w==}
peerDependencies:
'@fullcalendar/core': ~6.1.19
'@fullcalendar/react@6.1.19':
resolution: {integrity: sha512-FP78vnyylaL/btZeHig8LQgfHgfwxLaIG6sKbNkzkPkKEACv11UyyBoTSkaavPsHtXvAkcTED1l7TOunAyPEnA==}
peerDependencies:
'@fullcalendar/core': ~6.1.19
react: ^16.7.0 || ^17 || ^18 || ^19
react-dom: ^16.7.0 || ^17 || ^18 || ^19
'@fullcalendar/timegrid@6.1.19':
resolution: {integrity: sha512-OuzpUueyO9wB5OZ8rs7TWIoqvu4v3yEqdDxZ2VcsMldCpYJRiOe7yHWKr4ap5Tb0fs7Rjbserc/b6Nt7ol6BRg==}
peerDependencies:
'@fullcalendar/core': ~6.1.19
'@hookform/resolvers@5.2.2': '@hookform/resolvers@5.2.2':
resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==} resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==}
peerDependencies: peerDependencies:
@@ -2955,6 +2995,9 @@ packages:
potpack@1.0.2: potpack@1.0.2:
resolution: {integrity: sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==} resolution: {integrity: sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==}
preact@10.12.1:
resolution: {integrity: sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==}
prelude-ls@1.2.1: prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -3877,6 +3920,29 @@ snapshots:
'@floating-ui/utils@0.2.10': {} '@floating-ui/utils@0.2.10': {}
'@fullcalendar/core@6.1.19':
dependencies:
preact: 10.12.1
'@fullcalendar/daygrid@6.1.19(@fullcalendar/core@6.1.19)':
dependencies:
'@fullcalendar/core': 6.1.19
'@fullcalendar/interaction@6.1.19(@fullcalendar/core@6.1.19)':
dependencies:
'@fullcalendar/core': 6.1.19
'@fullcalendar/react@6.1.19(@fullcalendar/core@6.1.19)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@fullcalendar/core': 6.1.19
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@fullcalendar/timegrid@6.1.19(@fullcalendar/core@6.1.19)':
dependencies:
'@fullcalendar/core': 6.1.19
'@fullcalendar/daygrid': 6.1.19(@fullcalendar/core@6.1.19)
'@hookform/resolvers@5.2.2(react-hook-form@7.66.1(react@18.3.1))': '@hookform/resolvers@5.2.2(react-hook-form@7.66.1(react@18.3.1))':
dependencies: dependencies:
'@standard-schema/utils': 0.3.0 '@standard-schema/utils': 0.3.0
@@ -6861,6 +6927,8 @@ snapshots:
potpack@1.0.2: {} potpack@1.0.2: {}
preact@10.12.1: {}
prelude-ls@1.2.1: {} prelude-ls@1.2.1: {}
promise-worker-transferable@1.0.4: promise-worker-transferable@1.0.4: