Mobile Responsiveness: - Viewport meta tags toegevoegd in layout.tsx - EPD header: patient details verborgen op mobiel, zoekbalk responsive - Agenda: auto-switch naar dagview op mobiel, mini-kalender verborgen - Agenda toolbar: verticale stacking, Week/Werkweek knoppen verborgen - Patiëntenlijst: compact icoon-only button op mobiel - Verpleegrapportage: verticale flow i.p.v. twee-koloms layout - Cortex: artifact overlay met slide-in animatie op mobiel - Cortex: "Terug" knop toegevoegd aan artifact panels - Toast feedback bij succesvolle afspraak creatie Code Quality Fixes (KISS/DRY): - Resize listener vervangen door bestaande useMediaQuery hook - Dubbele flex class opgeschoond in epd-header.tsx - userScalable: false verwijderd (accessibility) Type Fixes: - actions.ts: status literal type met 'as const' - agenda-block.tsx: dateRange start/end optioneel - chat-empty-state.tsx: framer-motion ease type - cortex-store.ts: ChatEntities.dateRange consistent met Zod schema 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
316 lines
10 KiB
TypeScript
316 lines
10 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* Agenda View Component
|
|
*
|
|
* Client-side wrapper managing calendar state, view switching, and interactions.
|
|
*/
|
|
|
|
import React, { useState, useCallback, useRef, useTransition, useEffect } from 'react';
|
|
import dynamic from 'next/dynamic';
|
|
import { startOfWeek, endOfWeek, addDays, format } from 'date-fns';
|
|
import { toast } from '@/hooks/use-toast';
|
|
import { useMediaQuery } from '@/hooks/use-media-query';
|
|
|
|
// Lazy load FullCalendar component (~150KB+ savings)
|
|
const AgendaCalendar = dynamic(
|
|
() => import('./agenda-calendar').then((mod) => mod.AgendaCalendar),
|
|
{
|
|
ssr: false,
|
|
loading: () => (
|
|
<div className="h-full bg-slate-50 rounded-lg flex items-center justify-center">
|
|
<div className="flex flex-col items-center gap-3">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-600" />
|
|
<span className="text-sm text-slate-500">Agenda laden...</span>
|
|
</div>
|
|
</div>
|
|
),
|
|
}
|
|
);
|
|
import { AgendaToolbar } from './agenda-toolbar';
|
|
import { AppointmentModal } from './appointment-modal';
|
|
import { RescheduleDialog } from './reschedule-dialog';
|
|
import { MiniCalendar } from './mini-calendar';
|
|
import { getEncounters, rescheduleEncounter } from '../actions';
|
|
import type { CalendarEvent, CalendarView } from '../types';
|
|
|
|
interface PendingReschedule {
|
|
event: CalendarEvent;
|
|
newStart: Date;
|
|
newEnd: Date | null;
|
|
}
|
|
|
|
interface AgendaViewProps {
|
|
initialEvents: CalendarEvent[];
|
|
initialDate?: Date;
|
|
highlightEncounterId?: string;
|
|
}
|
|
|
|
export function AgendaView({ initialEvents, initialDate, highlightEncounterId }: AgendaViewProps) {
|
|
const [events, setEvents] = useState<CalendarEvent[]>(initialEvents);
|
|
const [currentDate, setCurrentDate] = useState(initialDate || new Date());
|
|
const [currentView, setCurrentView] = useState<CalendarView>('timeGridWeek');
|
|
const [isPending, startTransition] = useTransition();
|
|
|
|
// Modal state
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [modalInitialDate, setModalInitialDate] = useState<Date | undefined>();
|
|
const [modalInitialStartTime, setModalInitialStartTime] = useState<string | undefined>();
|
|
const [modalInitialEndTime, setModalInitialEndTime] = useState<string | undefined>();
|
|
const [editingEvent, setEditingEvent] = useState<CalendarEvent | undefined>();
|
|
|
|
// Reschedule dialog state
|
|
const [pendingReschedule, setPendingReschedule] = useState<PendingReschedule | null>(null);
|
|
const [isRescheduling, setIsRescheduling] = useState(false);
|
|
|
|
// Auto-switch to day view on mobile
|
|
const isMobile = useMediaQuery('(max-width: 767px)');
|
|
useEffect(() => {
|
|
if (isMobile) {
|
|
setCurrentView('timeGridDay');
|
|
}
|
|
}, [isMobile]);
|
|
|
|
const calendarRef = useRef<{ getApi: () => { prev: () => void; next: () => void; today: () => void; changeView: (view: string) => void; getDate: () => Date } } | null>(null);
|
|
|
|
// Auto-open appointment modal when navigating from a report
|
|
useEffect(() => {
|
|
if (highlightEncounterId && initialEvents.length > 0) {
|
|
const eventToOpen = initialEvents.find((e) => e.id === highlightEncounterId);
|
|
if (eventToOpen) {
|
|
setEditingEvent(eventToOpen);
|
|
setIsModalOpen(true);
|
|
}
|
|
}
|
|
}, [highlightEncounterId, initialEvents]);
|
|
|
|
// 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((prev) =>
|
|
prev.toDateString() === start.toDateString() ? prev : start
|
|
);
|
|
fetchEvents(start, end);
|
|
}, [fetchEvents]);
|
|
|
|
// Handle event click - open edit modal
|
|
const handleEventClick = useCallback((event: CalendarEvent) => {
|
|
setEditingEvent(event);
|
|
setIsModalOpen(true);
|
|
}, []);
|
|
|
|
// Handle date selection (for creating new appointment)
|
|
const handleDateSelect = useCallback((start: Date, end: Date) => {
|
|
setEditingEvent(undefined);
|
|
setModalInitialDate(start);
|
|
setModalInitialStartTime(format(start, 'HH:mm'));
|
|
setModalInitialEndTime(format(end, 'HH:mm'));
|
|
setIsModalOpen(true);
|
|
}, []);
|
|
|
|
// Handle event drag-and-drop - show confirmation dialog
|
|
const handleEventDrop = useCallback((
|
|
eventId: string,
|
|
newStart: Date,
|
|
newEnd: Date | null
|
|
) => {
|
|
const event = events.find((e) => e.id === eventId);
|
|
if (event) {
|
|
setPendingReschedule({ event, newStart, newEnd });
|
|
}
|
|
}, [events]);
|
|
|
|
// Confirm reschedule
|
|
const confirmReschedule = useCallback(async () => {
|
|
if (!pendingReschedule) return;
|
|
|
|
setIsRescheduling(true);
|
|
const { event, newStart, newEnd } = pendingReschedule;
|
|
|
|
const result = await rescheduleEncounter(
|
|
event.id,
|
|
newStart.toISOString(),
|
|
newEnd?.toISOString() || null
|
|
);
|
|
|
|
if (result.success) {
|
|
toast({ title: 'Afspraak verzet' });
|
|
// Update local state
|
|
setEvents((prev) =>
|
|
prev.map((e) =>
|
|
e.id === event.id
|
|
? { ...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);
|
|
}
|
|
|
|
setIsRescheduling(false);
|
|
setPendingReschedule(null);
|
|
}, [pendingReschedule, currentDate, fetchEvents]);
|
|
|
|
// Cancel reschedule - revert the visual change
|
|
const cancelReschedule = useCallback(() => {
|
|
// Refresh events to revert the visual drag
|
|
const start = startOfWeek(currentDate, { weekStartsOn: 1 });
|
|
const end = endOfWeek(currentDate, { weekStartsOn: 1 });
|
|
fetchEvents(start, end);
|
|
setPendingReschedule(null);
|
|
}, [currentDate, fetchEvents]);
|
|
|
|
// Handle new appointment button
|
|
const handleNewAppointment = useCallback(() => {
|
|
setEditingEvent(undefined);
|
|
setModalInitialDate(currentDate);
|
|
setModalInitialStartTime('09:00');
|
|
setModalInitialEndTime('10:00');
|
|
setIsModalOpen(true);
|
|
}, [currentDate]);
|
|
|
|
// Handle mini calendar date selection
|
|
const handleMiniCalendarDateSelect = useCallback((date: Date) => {
|
|
setCurrentDate(date);
|
|
|
|
// Calculate date range based on current view
|
|
const start = currentView === 'timeGridDay'
|
|
? date
|
|
: startOfWeek(date, { weekStartsOn: 1 });
|
|
const end = currentView === 'timeGridDay'
|
|
? addDays(date, 1)
|
|
: endOfWeek(date, { weekStartsOn: 1 });
|
|
|
|
fetchEvents(start, end);
|
|
}, [currentView, fetchEvents]);
|
|
|
|
// Handle modal success (refresh events)
|
|
const handleModalSuccess = useCallback(() => {
|
|
const start = currentView === 'timeGridDay'
|
|
? currentDate
|
|
: startOfWeek(currentDate, { weekStartsOn: 1 });
|
|
const end = currentView === 'timeGridDay'
|
|
? addDays(currentDate, 1)
|
|
: endOfWeek(currentDate, { weekStartsOn: 1 });
|
|
fetchEvents(start, end);
|
|
}, [currentDate, currentView, fetchEvents]);
|
|
|
|
return (
|
|
<div className="flex flex-col h-[calc(100vh-8rem)]">
|
|
<AgendaToolbar
|
|
currentDate={currentDate}
|
|
currentView={currentView}
|
|
onViewChange={handleViewChange}
|
|
onNavigate={handleNavigate}
|
|
onNewAppointment={handleNewAppointment}
|
|
/>
|
|
|
|
<div className="flex flex-1 gap-4 min-h-0">
|
|
{/* Mini calendar sidebar */}
|
|
<div className="w-64 flex-shrink-0 hidden md:block">
|
|
<MiniCalendar
|
|
selectedDate={currentDate}
|
|
onDateSelect={handleMiniCalendarDateSelect}
|
|
/>
|
|
</div>
|
|
|
|
{/* Main calendar */}
|
|
<div className="flex-1 bg-white rounded-lg border border-slate-200 overflow-hidden relative">
|
|
{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}
|
|
currentView={currentView}
|
|
currentDate={currentDate}
|
|
onEventClick={handleEventClick}
|
|
onDateSelect={handleDateSelect}
|
|
onEventDrop={handleEventDrop}
|
|
onDateChange={handleDateChange}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Appointment Modal */}
|
|
<AppointmentModal
|
|
open={isModalOpen}
|
|
onOpenChange={setIsModalOpen}
|
|
initialDate={modalInitialDate}
|
|
initialStartTime={modalInitialStartTime}
|
|
initialEndTime={modalInitialEndTime}
|
|
editingEvent={editingEvent}
|
|
onSuccess={handleModalSuccess}
|
|
/>
|
|
|
|
{/* Reschedule Confirmation Dialog */}
|
|
{pendingReschedule && (
|
|
<RescheduleDialog
|
|
open={!!pendingReschedule}
|
|
onOpenChange={(open) => {
|
|
if (!open) cancelReschedule();
|
|
}}
|
|
patientName={pendingReschedule.event.title}
|
|
appointmentType={pendingReschedule.event.extendedProps.encounter.type_display}
|
|
oldStart={new Date(pendingReschedule.event.start)}
|
|
oldEnd={pendingReschedule.event.end ? new Date(pendingReschedule.event.end) : null}
|
|
newStart={pendingReschedule.newStart}
|
|
newEnd={pendingReschedule.newEnd}
|
|
onConfirm={confirmReschedule}
|
|
isLoading={isRescheduling}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|