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:
@@ -33,6 +33,7 @@ export async function POST(request: Request) {
|
||||
|
||||
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({
|
||||
action,
|
||||
context,
|
||||
|
||||
182
app/epd/agenda/actions.ts
Normal file
182
app/epd/agenda/actions.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
211
app/epd/agenda/components/agenda-calendar.tsx
Normal file
211
app/epd/agenda/components/agenda-calendar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
126
app/epd/agenda/components/agenda-toolbar.tsx
Normal file
126
app/epd/agenda/components/agenda-toolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
164
app/epd/agenda/components/agenda-view.tsx
Normal file
164
app/epd/agenda/components/agenda-view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
91
app/epd/agenda/types.ts
Normal 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' },
|
||||
};
|
||||
Reference in New Issue
Block a user