314 lines
8.9 KiB
TypeScript
314 lines
8.9 KiB
TypeScript
'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_; |