git minicalender-view, improved agenda-view
This commit is contained in:
@@ -6,7 +6,7 @@
|
|||||||
* FullCalendar wrapper with day/week/workweek views.
|
* FullCalendar wrapper with day/week/workweek views.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useRef } from 'react';
|
import { useCallback, useRef, useEffect } from 'react';
|
||||||
import FullCalendar from '@fullcalendar/react';
|
import FullCalendar from '@fullcalendar/react';
|
||||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||||
import timeGridPlugin from '@fullcalendar/timegrid';
|
import timeGridPlugin from '@fullcalendar/timegrid';
|
||||||
@@ -19,6 +19,7 @@ import type { CalendarEvent, CalendarView } from '../types';
|
|||||||
interface AgendaCalendarProps {
|
interface AgendaCalendarProps {
|
||||||
events: CalendarEvent[];
|
events: CalendarEvent[];
|
||||||
initialView?: CalendarView;
|
initialView?: CalendarView;
|
||||||
|
currentView?: CalendarView;
|
||||||
onEventClick?: (event: CalendarEvent) => void;
|
onEventClick?: (event: CalendarEvent) => void;
|
||||||
onDateSelect?: (start: Date, end: Date) => void;
|
onDateSelect?: (start: Date, end: Date) => void;
|
||||||
onEventDrop?: (eventId: string, newStart: Date, newEnd: Date | null) => void;
|
onEventDrop?: (eventId: string, newStart: Date, newEnd: Date | null) => void;
|
||||||
@@ -29,6 +30,7 @@ interface AgendaCalendarProps {
|
|||||||
export function AgendaCalendar({
|
export function AgendaCalendar({
|
||||||
events,
|
events,
|
||||||
initialView = 'timeGridWeek',
|
initialView = 'timeGridWeek',
|
||||||
|
currentView,
|
||||||
onEventClick,
|
onEventClick,
|
||||||
onDateSelect,
|
onDateSelect,
|
||||||
onEventDrop,
|
onEventDrop,
|
||||||
@@ -36,6 +38,22 @@ export function AgendaCalendar({
|
|||||||
calendarRef: externalRef,
|
calendarRef: externalRef,
|
||||||
}: AgendaCalendarProps) {
|
}: AgendaCalendarProps) {
|
||||||
const internalRef = useRef<FullCalendar>(null);
|
const internalRef = useRef<FullCalendar>(null);
|
||||||
|
const isChangingViewRef = useRef(false);
|
||||||
|
|
||||||
|
// Sync view when currentView prop changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentView && internalRef.current) {
|
||||||
|
const api = internalRef.current.getApi();
|
||||||
|
if (api.view.type !== currentView) {
|
||||||
|
isChangingViewRef.current = true;
|
||||||
|
api.changeView(currentView);
|
||||||
|
// Reset flag after a short delay to allow the view change to complete
|
||||||
|
setTimeout(() => {
|
||||||
|
isChangingViewRef.current = false;
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [currentView]);
|
||||||
|
|
||||||
const handleEventClick = useCallback((info: EventClickArg) => {
|
const handleEventClick = useCallback((info: EventClickArg) => {
|
||||||
if (onEventClick) {
|
if (onEventClick) {
|
||||||
@@ -70,6 +88,10 @@ export function AgendaCalendar({
|
|||||||
}, [onEventDrop]);
|
}, [onEventDrop]);
|
||||||
|
|
||||||
const handleDatesSet = useCallback((dateInfo: { start: Date; end: Date }) => {
|
const handleDatesSet = useCallback((dateInfo: { start: Date; end: Date }) => {
|
||||||
|
// Skip if we're in the middle of a programmatic view change
|
||||||
|
if (isChangingViewRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (onDateChange) {
|
if (onDateChange) {
|
||||||
onDateChange(dateInfo.start, dateInfo.end);
|
onDateChange(dateInfo.start, dateInfo.end);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { AgendaCalendar } from './agenda-calendar';
|
|||||||
import { AgendaToolbar } from './agenda-toolbar';
|
import { AgendaToolbar } from './agenda-toolbar';
|
||||||
import { AppointmentModal } from './appointment-modal';
|
import { AppointmentModal } from './appointment-modal';
|
||||||
import { RescheduleDialog } from './reschedule-dialog';
|
import { RescheduleDialog } from './reschedule-dialog';
|
||||||
|
import { MiniCalendar } from './mini-calendar';
|
||||||
import { getEncounters, rescheduleEncounter } from '../actions';
|
import { getEncounters, rescheduleEncounter } from '../actions';
|
||||||
import type { CalendarEvent, CalendarView } from '../types';
|
import type { CalendarEvent, CalendarView } from '../types';
|
||||||
|
|
||||||
@@ -190,6 +191,21 @@ export function AgendaView({ initialEvents, initialDate, highlightEncounterId }:
|
|||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
}, [currentDate]);
|
}, [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)
|
// Handle modal success (refresh events)
|
||||||
const handleModalSuccess = useCallback(() => {
|
const handleModalSuccess = useCallback(() => {
|
||||||
const start = currentView === 'timeGridDay'
|
const start = currentView === 'timeGridDay'
|
||||||
@@ -211,20 +227,32 @@ export function AgendaView({ initialEvents, initialDate, highlightEncounterId }:
|
|||||||
onNewAppointment={handleNewAppointment}
|
onNewAppointment={handleNewAppointment}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 bg-white rounded-lg border border-slate-200 overflow-hidden">
|
<div className="flex flex-1 gap-4 min-h-0">
|
||||||
{isPending && (
|
{/* Mini calendar sidebar */}
|
||||||
<div className="absolute inset-0 bg-white/50 flex items-center justify-center z-10">
|
<div className="w-64 flex-shrink-0">
|
||||||
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
|
<MiniCalendar
|
||||||
</div>
|
selectedDate={currentDate}
|
||||||
)}
|
onDateSelect={handleMiniCalendarDateSelect}
|
||||||
<AgendaCalendar
|
/>
|
||||||
events={events}
|
</div>
|
||||||
initialView={currentView}
|
|
||||||
onEventClick={handleEventClick}
|
{/* Main calendar */}
|
||||||
onDateSelect={handleDateSelect}
|
<div className="flex-1 bg-white rounded-lg border border-slate-200 overflow-hidden relative">
|
||||||
onEventDrop={handleEventDrop}
|
{isPending && (
|
||||||
onDateChange={handleDateChange}
|
<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}
|
||||||
|
onEventClick={handleEventClick}
|
||||||
|
onDateSelect={handleDateSelect}
|
||||||
|
onEventDrop={handleEventDrop}
|
||||||
|
onDateChange={handleDateChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Appointment Modal */}
|
{/* Appointment Modal */}
|
||||||
|
|||||||
195
app/epd/agenda/components/mini-calendar.tsx
Normal file
195
app/epd/agenda/components/mini-calendar.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mini Calendar Component
|
||||||
|
*
|
||||||
|
* Compact month view calendar for quick date navigation.
|
||||||
|
* Uses FullCalendar daygrid plugin for consistency with the main calendar.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useRef, useEffect } from 'react';
|
||||||
|
import FullCalendar from '@fullcalendar/react';
|
||||||
|
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||||
|
import interactionPlugin from '@fullcalendar/interaction';
|
||||||
|
import nlLocale from '@fullcalendar/core/locales/nl';
|
||||||
|
import type { DateClickArg } from '@fullcalendar/interaction';
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { nl } from 'date-fns/locale';
|
||||||
|
|
||||||
|
interface MiniCalendarProps {
|
||||||
|
selectedDate: Date;
|
||||||
|
onDateSelect: (date: Date) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MiniCalendar({ selectedDate, onDateSelect }: MiniCalendarProps) {
|
||||||
|
const calendarRef = useRef<FullCalendar>(null);
|
||||||
|
|
||||||
|
// Navigate to month containing selected date
|
||||||
|
useEffect(() => {
|
||||||
|
if (calendarRef.current) {
|
||||||
|
const api = calendarRef.current.getApi();
|
||||||
|
api.gotoDate(selectedDate);
|
||||||
|
}
|
||||||
|
}, [selectedDate]);
|
||||||
|
|
||||||
|
const handleDateClick = useCallback((info: DateClickArg) => {
|
||||||
|
onDateSelect(info.date);
|
||||||
|
}, [onDateSelect]);
|
||||||
|
|
||||||
|
const handlePrevMonth = useCallback(() => {
|
||||||
|
if (calendarRef.current) {
|
||||||
|
calendarRef.current.getApi().prev();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleNextMonth = useCallback(() => {
|
||||||
|
if (calendarRef.current) {
|
||||||
|
calendarRef.current.getApi().next();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToday = useCallback(() => {
|
||||||
|
if (calendarRef.current) {
|
||||||
|
calendarRef.current.getApi().today();
|
||||||
|
}
|
||||||
|
onDateSelect(new Date());
|
||||||
|
}, [onDateSelect]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg border border-slate-200 p-3">
|
||||||
|
{/* Custom header with month/year navigation */}
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={handlePrevMonth}
|
||||||
|
className="h-7 w-7"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
onClick={handleToday}
|
||||||
|
className="text-sm font-semibold text-slate-800 hover:text-teal-600 transition-colors capitalize"
|
||||||
|
>
|
||||||
|
{format(selectedDate, 'MMMM yyyy', { locale: nl })}
|
||||||
|
</button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleNextMonth}
|
||||||
|
className="h-7 w-7"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mini calendar */}
|
||||||
|
<div className="mini-calendar">
|
||||||
|
<FullCalendar
|
||||||
|
ref={calendarRef}
|
||||||
|
plugins={[dayGridPlugin, interactionPlugin]}
|
||||||
|
initialView="dayGridMonth"
|
||||||
|
locale={nlLocale}
|
||||||
|
headerToolbar={false}
|
||||||
|
height="auto"
|
||||||
|
fixedWeekCount={false}
|
||||||
|
showNonCurrentDates={true}
|
||||||
|
dayMaxEvents={0}
|
||||||
|
dateClick={handleDateClick}
|
||||||
|
dayCellClassNames={(arg) => {
|
||||||
|
const isSelected = arg.date.toDateString() === selectedDate.toDateString();
|
||||||
|
return isSelected ? 'mini-cal-selected' : '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style jsx global>{`
|
||||||
|
.mini-calendar .fc {
|
||||||
|
--fc-border-color: #e2e8f0;
|
||||||
|
--fc-today-bg-color: #f0fdf4;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-calendar .fc-theme-standard td,
|
||||||
|
.mini-calendar .fc-theme-standard th {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-calendar .fc-theme-standard .fc-scrollgrid {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-calendar .fc-col-header-cell {
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-calendar .fc-daygrid-day {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-calendar .fc-daygrid-day-frame {
|
||||||
|
min-height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-calendar .fc-daygrid-day-top {
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-calendar .fc-daygrid-day-number {
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-calendar .fc-daygrid-day:hover .fc-daygrid-day-number {
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-calendar .fc-day-today .fc-daygrid-day-number {
|
||||||
|
background-color: #14b8a6;
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-calendar .mini-cal-selected .fc-daygrid-day-number {
|
||||||
|
background-color: #0d9488;
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-calendar .fc-day-today.mini-cal-selected .fc-daygrid-day-number {
|
||||||
|
background-color: #0f766e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-calendar .fc-day-other .fc-daygrid-day-number {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-calendar .fc-daygrid-day-events {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-calendar .fc-daygrid-day-bottom {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user