Initial commit: Kalendartool (Next.js, Prisma, Docker)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Kühn
2026-10-08 14:38:04 +02:00
commit bc49c3074e
94 changed files with 12812 additions and 0 deletions

View 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_;