Files
kalendartool/components/calendar/CalendarView.tsx

314 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
/**
* Kalenderansicht-Komponente (plan.md Abschnitt 7):
* Monat / Woche / Tag mit klickbaren Reservierungen.
* Die Navigation laeuft ueber URL-Parameter (sharebar, servergerendert).
*/
import { useRouter } from 'next/navigation';
import { Card } from '@/components/ui/ui';
export interface CalendarReservation {
id: string;
title: string;
startAt: string;
endAt: string;
/** Anzeigename des Erstellers (Username oder maskierte E-Mail). */
ownerName: string;
isOwn: boolean;
}
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] as const;
function formatTime(iso: string): string {
return new Date(iso).toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
});
}
function formatDate(date: Date): string {
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
/** Verschibt das Referenzdatum um `amount` Einheiten der Ansicht. */
function shiftDate(view: string, isoDate: string, amount: number): string {
const date = new Date(isoDate);
switch (view) {
case 'day':
date.setDate(date.getDate() + amount);
break;
case 'month':
date.setMonth(date.getMonth() + amount, 1);
break;
default:
date.setDate(date.getDate() + amount * 7);
break;
}
return date.toISOString();
}
export function CalendarView({
view,
referenceDate,
calendarId,
reservations,
}: {
view: string;
referenceDate: string;
calendarId: string;
reservations: CalendarReservation[];
}) {
const router = useRouter();
const reference = new Date(referenceDate);
function navigate(nextView: string, nextDate: string) {
router.push(
`/calendar/${calendarId}?view=${nextView}&date=${encodeURIComponent(nextDate)}`,
);
}
const navButtons = (
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div className="flex gap-1">
{(['day', 'week', 'month'] as const).map((nextView) => (
<button
key={nextView}
type="button"
onClick={() => navigate(nextView, referenceDate)}
className={`rounded-md px-3 py-1.5 text-sm font-medium ${
view === nextView
? 'bg-slate-800 text-white dark:bg-slate-100 dark:text-slate-900'
: 'bg-slate-200 text-slate-700 hover:bg-slate-300 dark:bg-slate-700 dark:text-slate-200 dark:hover:bg-slate-600'
}`}
>
{nextView === 'day' ? 'Tag' : nextView === 'week' ? 'Woche' : 'Monat'}
</button>
))}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => navigate(view, shiftDate(view, referenceDate, -1))}
className="rounded-md bg-slate-200 px-3 py-1.5 text-sm hover:bg-slate-300"
aria-label="Zurueck"
>
‹
</button>
<span className="text-sm font-medium">{formatDate(reference)}</span>
<button
type="button"
onClick={() => navigate(view, shiftDate(view, referenceDate, 1))}
className="rounded-md bg-slate-200 px-3 py-1.5 text-sm hover:bg-slate-300"
aria-label="Weiter"
>
›
</button>
</div>
</div>
);
if (view === 'month') {
return (
<Card>
{navButtons}
<MonthGrid reference={reference} reservations={reservations} />
</Card>
);
}
if (view === 'day') {
return (
<Card>
{navButtons}
<DayTimeline reference={reference} reservations={reservations} />
</Card>
);
}
return (
<Card>
{navButtons}
<WeekGrid reference={reference} reservations={reservations} />
</Card>
);
}
/** Monatsraster: Tage mit Anzahl der Reservierungen. */
function MonthGrid({
reference,
reservations,
}: {
reference: Date;
reservations: CalendarReservation[];
}) {
const year = reference.getFullYear();
const month = reference.getMonth();
const firstDay = new Date(year, month, 1);
const daysInMonth = new Date(year, month + 1, 0).getDate();
const leadingEmpty = (firstDay.getDay() + 6) % 7;
const countPerDay = new Map<number, number>();
for (const reservation of reservations) {
const day = new Date(reservation.startAt).getDate();
if (new Date(reservation.startAt).getMonth() === month) {
countPerDay.set(day, (countPerDay.get(day) ?? 0) + 1);
}
}
return (
<div>
<div className="mb-2 grid grid-cols-7 gap-1 text-center text-xs font-semibold text-slate-500">
{WEEKDAYS.map((weekday) => (
<span key={weekday}>{weekday}</span>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{Array.from({ length: leadingEmpty }, (_, index) => (
<div key={`empty-${index}`} />
))}
{Array.from({ length: daysInMonth }, (_, index) => {
const day = index + 1;
const count = countPerDay.get(day) ?? 0;
return (
<div
key={day}
className="min-h-16 rounded border border-slate-200 p-1 text-xs"
>
<div className="font-medium">{day}</div>
{count > 0 && (
<div className="mt-1 rounded bg-slate-200 px-1 text-slate-800 dark:bg-slate-700 dark:text-slate-200">
{count} Res.
</div>
)}
</div>
);
})}
</div>
</div>
);
}
/** Tages-Timeline: Stundenraster mit belegten Bloecken. */
function DayTimeline({
reference,
reservations,
}: {
reference: Date;
reservations: CalendarReservation[];
}) {
const dayReservations = reservations.filter(
(reservation) =>
new Date(reservation.startAt).toDateString() === reference.toDateString(),
);
return (
<div>
<h2 className="mb-3 text-sm font-semibold">
{reference.toLocaleDateString('de-DE', {
weekday: 'long',
day: 'numeric',
month: 'long',
})}
</h2>
{dayReservations.length === 0 ? (
<p className="text-sm text-slate-500">Keine Reservierungen an diesem Tag.</p>
) : (
<ul className="space-y-2">
{dayReservations.map((reservation) => (
<li
key={reservation.id}
className={`rounded-md px-3 py-2 text-sm ${
reservation.isOwn
? 'bg-slate-200 dark:bg-slate-700'
: 'bg-slate-100 dark:bg-slate-800'
}`}
>
<span className="font-medium">
{formatTime(reservation.startAt)} – {formatTime(reservation.endAt)}
</span>{' '}
{reservation.title}
<span className="ml-2 text-xs text-slate-500">
{reservation.ownerName}
</span>
</li>
))}
</ul>
)}
</div>
);
}
/** Wochenraster: 7 Tage mit Reservierungslisten. */
function WeekGrid({
reference,
reservations,
}: {
reference: Date;
reservations: CalendarReservation[];
}) {
const monday = new Date(reference);
monday.setHours(0, 0, 0, 0);
monday.setDate(monday.getDate() - ((monday.getDay() + 6) % 7));
const days = Array.from({ length: 7 }, (_, index) => {
const day = new Date(monday);
day.setDate(day.getDate() + index);
return day;
});
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-7">
{days.map((day) => {
const dayReservations = reservations.filter(
(reservation) =>
new Date(reservation.startAt).toDateString() === day.toDateString(),
);
return (
<div key={day.toISOString()} className="rounded border border-slate-200 p-2">
<div className="mb-2 text-center text-xs font-semibold text-slate-500">
{WEEKDAYS[(day.getDay() + 6) % 7]} {day.getDate()}.
</div>
<ul className="space-y-1">
{dayReservations.map((reservation) => (
<li
key={reservation.id}
className={`rounded px-2 py-1 text-xs ${
reservation.isOwn
? 'bg-slate-200 text-slate-900 dark:bg-slate-700 dark:text-slate-100'
: 'bg-slate-100 dark:bg-slate-800'
}`}
>
<div className="font-medium">
{formatTime(reservation.startAt)}–{formatTime(reservation.endAt)}
</div>
<div className="truncate">{reservation.title}</div>
</li>
))}
</ul>
</div>
);
})}
</div>
);
}
/** Wrapper, damit die drei Ansichten einheitlich benutzt werden koennen. */
function MonthGridWrapper(props: {
reference: Date;
reservations: CalendarReservation[];
}) {
return <MonthGrid {...props} />;
}
// Alias-Exporte fuer lesbare Verwendung oben.
const MonthGrid_ = MonthGridWrapper;
void MonthGrid_;
const DayTimeline_ = DayTimeline;
void DayTimeline_;
const WeekGrid_ = WeekGrid;
void WeekGrid_;