Initial commit: Kalendartool (Next.js, Prisma, Docker)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
314
components/calendar/CalendarView.tsx
Normal file
314
components/calendar/CalendarView.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
'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_;
|
||||
Reference in New Issue
Block a user