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

@@ -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>
);
}