- /termine page with list/calendar view toggle - AppointmentList: date-grouped list with type/case filtering, summary cards - AppointmentCalendar: month grid with colored type dots, clickable days/appointments - AppointmentModal: create/edit/delete with case linking, type selection, location
161 lines
5.4 KiB
TypeScript
161 lines
5.4 KiB
TypeScript
"use client";
|
|
|
|
import type { Appointment } from "@/lib/types";
|
|
import {
|
|
format,
|
|
startOfMonth,
|
|
endOfMonth,
|
|
startOfWeek,
|
|
endOfWeek,
|
|
eachDayOfInterval,
|
|
isSameMonth,
|
|
isToday,
|
|
parseISO,
|
|
addMonths,
|
|
subMonths,
|
|
} from "date-fns";
|
|
import { de } from "date-fns/locale";
|
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
import { useState, useMemo } from "react";
|
|
|
|
const TYPE_DOT_COLORS: Record<string, string> = {
|
|
hearing: "bg-blue-500",
|
|
meeting: "bg-violet-500",
|
|
consultation: "bg-emerald-500",
|
|
deadline_hearing: "bg-amber-500",
|
|
other: "bg-neutral-400",
|
|
};
|
|
|
|
interface AppointmentCalendarProps {
|
|
appointments: Appointment[];
|
|
onDayClick?: (date: string) => void;
|
|
onAppointmentClick?: (appointment: Appointment) => void;
|
|
}
|
|
|
|
export function AppointmentCalendar({
|
|
appointments,
|
|
onDayClick,
|
|
onAppointmentClick,
|
|
}: AppointmentCalendarProps) {
|
|
const [currentMonth, setCurrentMonth] = useState(new Date());
|
|
|
|
const monthStart = startOfMonth(currentMonth);
|
|
const monthEnd = endOfMonth(currentMonth);
|
|
const calStart = startOfWeek(monthStart, { weekStartsOn: 1 });
|
|
const calEnd = endOfWeek(monthEnd, { weekStartsOn: 1 });
|
|
const days = eachDayOfInterval({ start: calStart, end: calEnd });
|
|
|
|
const appointmentsByDay = useMemo(() => {
|
|
const map = new Map<string, Appointment[]>();
|
|
for (const a of appointments) {
|
|
const key = a.start_at.slice(0, 10);
|
|
const existing = map.get(key) || [];
|
|
existing.push(a);
|
|
map.set(key, existing);
|
|
}
|
|
return map;
|
|
}, [appointments]);
|
|
|
|
const weekDays = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
|
|
|
return (
|
|
<div className="rounded-lg border border-neutral-200 bg-white">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between border-b border-neutral-200 px-4 py-3">
|
|
<button
|
|
onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}
|
|
className="rounded-md p-1 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-600"
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</button>
|
|
<span className="text-sm font-medium text-neutral-900">
|
|
{format(currentMonth, "MMMM yyyy", { locale: de })}
|
|
</span>
|
|
<button
|
|
onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}
|
|
className="rounded-md p-1 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-600"
|
|
>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Weekday labels */}
|
|
<div className="grid grid-cols-7 border-b border-neutral-100">
|
|
{weekDays.map((d) => (
|
|
<div key={d} className="px-2 py-2 text-center text-xs font-medium text-neutral-400">
|
|
{d}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Days grid */}
|
|
<div className="grid grid-cols-7">
|
|
{days.map((day, i) => {
|
|
const key = format(day, "yyyy-MM-dd");
|
|
const dayAppointments = appointmentsByDay.get(key) || [];
|
|
const inMonth = isSameMonth(day, currentMonth);
|
|
const today = isToday(day);
|
|
|
|
return (
|
|
<div
|
|
key={i}
|
|
onClick={() => onDayClick?.(key)}
|
|
className={`min-h-[5rem] cursor-pointer border-b border-r border-neutral-100 p-1.5 transition-colors hover:bg-neutral-50 ${
|
|
!inMonth ? "bg-neutral-50/50" : ""
|
|
}`}
|
|
>
|
|
<div
|
|
className={`mb-1 text-right text-xs ${
|
|
today
|
|
? "font-bold text-neutral-900"
|
|
: inMonth
|
|
? "text-neutral-600"
|
|
: "text-neutral-300"
|
|
}`}
|
|
>
|
|
{today ? (
|
|
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-neutral-900 text-white">
|
|
{format(day, "d")}
|
|
</span>
|
|
) : (
|
|
format(day, "d")
|
|
)}
|
|
</div>
|
|
<div className="space-y-0.5">
|
|
{dayAppointments.slice(0, 3).map((appt) => {
|
|
const dotColor =
|
|
TYPE_DOT_COLORS[appt.appointment_type ?? "other"] ?? TYPE_DOT_COLORS.other;
|
|
return (
|
|
<div
|
|
key={appt.id}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onAppointmentClick?.(appt);
|
|
}}
|
|
className="flex items-center gap-1 truncate rounded px-0.5 hover:bg-neutral-100"
|
|
title={`${format(parseISO(appt.start_at), "HH:mm")} ${appt.title}`}
|
|
>
|
|
<div className={`h-1.5 w-1.5 shrink-0 rounded-full ${dotColor}`} />
|
|
<span className="truncate text-[10px] text-neutral-700">
|
|
<span className="font-medium">
|
|
{format(parseISO(appt.start_at), "HH:mm")}
|
|
</span>{" "}
|
|
{appt.title}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
{dayAppointments.length > 3 && (
|
|
<div className="text-[10px] text-neutral-400">
|
|
+{dayAppointments.length - 3} mehr
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|