Files
kita-planer/src/app/dashboard/kalender/page.tsx
T
2026-05-20 22:47:52 +02:00

450 lines
12 KiB
TypeScript

import {
DutyAssignmentStatus,
EventParticipationStatus,
TerminStatus,
TerminType,
UserRole,
} from "@prisma/client";
import { HelpCircle, ListFilter, Search } from "lucide-react";
import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { Input } from "@/components/ui/input";
import { AdminTerminModal } from "./_components/admin-termin-modal";
import { PendingAnfragen, type PendingTerminDto } from "./_components/pending-anfragen";
import { TerminList, type CalendarListItemDto } from "./_components/termin-list";
import { TerminRequestModal } from "./_components/termin-request-modal";
export const metadata = { title: "Terminkalender · Kita-Planer" };
export default async function KalenderPage({
searchParams,
}: {
searchParams: Promise<{ tab?: string }>;
}) {
const session = await requireKitaSession();
const isAdmin =
session.user.role === UserRole.ADMIN ||
session.user.role === UserRole.KOORDINATOR;
const familyIdForParticipation = session.user.familyId ?? "__no_family__";
const resolvedSearchParams = await searchParams;
const currentTab = resolvedSearchParams.tab || "übersicht";
const now = new Date();
const confirmedTermineRows = await prisma.termin.findMany({
where: {
kitaId: session.user.kitaId,
status: TerminStatus.CONFIRMED,
},
select: {
id: true,
title: true,
description: true,
type: true,
status: true,
startDate: true,
endDate: true,
allDay: true,
mitbringselListEnabled: true,
requiresRsvp: true,
participantInfo: true,
mitbringselItems: {
select: {
id: true,
userId: true,
content: true,
user: {
select: {
firstName: true,
lastName: true,
},
},
},
},
participations: {
where: {
child: {
familyId: familyIdForParticipation,
},
},
select: {
childId: true,
status: true,
},
},
},
orderBy: { startDate: "asc" },
});
const myPendingTermineRows = await prisma.termin.findMany({
where: {
kitaId: session.user.kitaId,
status: TerminStatus.PENDING,
createdById: session.user.id,
},
select: {
id: true,
title: true,
description: true,
type: true,
status: true,
startDate: true,
endDate: true,
allDay: true,
mitbringselListEnabled: true,
requiresRsvp: true,
participantInfo: true,
mitbringselItems: {
select: {
id: true,
userId: true,
content: true,
user: {
select: {
firstName: true,
lastName: true,
},
},
},
},
participations: {
where: {
child: {
familyId: familyIdForParticipation,
},
},
select: {
childId: true,
status: true,
},
},
},
orderBy: { startDate: "asc" },
});
const dutyAssignmentRows = await prisma.dutyAssignment.findMany({
where: {
kitaId: session.user.kitaId,
status: DutyAssignmentStatus.PLANNED,
endDate: { gte: now },
},
select: {
id: true,
startDate: true,
endDate: true,
status: true,
dutyType: {
select: {
name: true,
},
},
family: {
select: {
name: true,
},
},
},
orderBy: { startDate: "asc" },
});
const allUserTermine: CalendarListItemDto[] = [
...confirmedTermineRows.map((termin) => ({
...termin,
kind: "termin" as const,
showParticipantInfo:
isAdmin ||
termin.participations.some(
(participation) =>
participation.status === EventParticipationStatus.ATTENDING,
),
})),
...myPendingTermineRows.map((termin) => ({
...termin,
kind: "termin" as const,
showParticipantInfo:
isAdmin ||
termin.participations.some(
(participation) =>
participation.status === EventParticipationStatus.ATTENDING,
),
})),
...dutyAssignmentRows.map((assignment) => ({
kind: "duty" as const,
id: assignment.id,
title: assignment.dutyType.name,
familyName: assignment.family.name,
status: assignment.status,
startDate: assignment.startDate,
endDate: assignment.endDate,
})),
].sort((a, b) => a.startDate.getTime() - b.startDate.getTime());
let allPendingTermine: PendingTerminDto[] = [];
if (isAdmin) {
const pendingRows = await prisma.termin.findMany({
where: {
kitaId: session.user.kitaId,
status: TerminStatus.PENDING,
},
select: {
id: true,
title: true,
startDate: true,
createdAt: true,
type: true,
allDay: true,
createdBy: {
select: {
firstName: true,
lastName: true,
},
},
},
orderBy: { createdAt: "desc" },
});
allPendingTermine = pendingRows.map((termin) => ({
...termin,
ageInDays: Math.floor(
(now.getTime() - termin.createdAt.getTime()) / (1000 * 60 * 60 * 24),
),
}));
}
return (
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<header className="mb-7 flex items-end justify-between gap-8 max-[900px]:items-start max-[900px]:flex-col">
<div>
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
TERMINKALENDER
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Was steht{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
an
</em>
?
</h1>
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Alle anstehenden Termine, Feste, Dienste und Schließtage im
Überblick.
</p>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
<TerminRequestModal />
<span className="group relative inline-flex h-9 w-9 items-center justify-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] text-[var(--ink-muted)]">
<HelpCircle className="h-4 w-4 [stroke-width:1.7]" />
<span className="pointer-events-none absolute right-0 top-11 z-30 hidden w-72 rounded-lg bg-[oklch(0.22_0.015_45)] px-3 py-2 text-left text-xs leading-5 text-white shadow-lg group-hover:block">
Anfragen müssen vom Vorstand bestätigt werden, direkte Anlage
erscheint sofort im Kalender.
</span>
</span>
{isAdmin && <AdminTerminModal />}
</div>
</header>
{isAdmin ? (
<div className="flex flex-col gap-5">
<TabBar
currentTab={currentTab}
overviewCount={allUserTermine.length}
pendingCount={allPendingTermine.length}
/>
<div>
{currentTab === "übersicht" ? (
<CalendarOverview
termine={allUserTermine}
userId={session.user.id}
isAdmin={isAdmin}
/>
) : currentTab === "anfragen" ? (
<PendingAnfragen termine={allPendingTermine} />
) : (
<CalendarOverview
termine={[]}
userId={session.user.id}
isAdmin={isAdmin}
/>
)}
</div>
</div>
) : (
<CalendarOverview
termine={allUserTermine}
userId={session.user.id}
isAdmin={isAdmin}
/>
)}
</div>
);
}
function CalendarOverview({
termine,
userId,
isAdmin,
}: {
termine: CalendarListItemDto[];
userId: string;
isAdmin: boolean;
}) {
return (
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
{["Liste", "Monat", "Agenda"].map((label, index) => (
<button
key={label}
type="button"
className={[
"h-9 px-4 text-[13px] font-semibold transition",
index > 0 ? "border-l border-[var(--hairline-soft)]" : "",
index === 0
? "bg-[var(--accent-soft)] text-[var(--accent-deep)]"
: "text-[var(--ink-soft)] hover:bg-[var(--surface-2)] hover:text-[var(--ink)]",
].join(" ")}
>
{label}
</button>
))}
</div>
<div className="flex flex-1 flex-wrap items-center justify-end gap-2">
<CategoryChips />
<div className="relative min-w-56">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--ink-muted)] [stroke-width:1.7]" />
<Input placeholder="Termine suchen" className="h-9 pl-9" />
</div>
</div>
</div>
<TerminList termine={termine} userId={userId} isAdmin={isAdmin} />
</div>
);
}
function TabBar({
currentTab,
overviewCount,
pendingCount,
}: {
currentTab: string;
overviewCount: number;
pendingCount: number;
}) {
return (
<div className="flex border-b border-[var(--hairline)]">
<TabLink
href="/dashboard/kalender?tab=übersicht"
active={currentTab === "übersicht"}
count={overviewCount}
>
Übersicht
</TabLink>
<TabLink
href="/dashboard/kalender?tab=anfragen"
active={currentTab === "anfragen"}
count={pendingCount}
attention={pendingCount > 0}
>
Ausstehende Anfragen
</TabLink>
<TabLink
href="/dashboard/kalender?tab=vergangen"
active={currentTab === "vergangen"}
>
Vergangene
</TabLink>
</div>
);
}
function TabLink({
href,
active,
count,
attention,
children,
}: {
href: string;
active: boolean;
count?: number;
attention?: boolean;
children: React.ReactNode;
}) {
return (
<a
href={href}
className={[
"-mb-px inline-flex h-11 items-center gap-2 border-b-2 px-1.5 text-sm transition first:pl-0",
active
? "border-[var(--accent)] font-semibold text-[var(--ink)]"
: "border-transparent font-medium text-[var(--ink-soft)] hover:text-[var(--ink)]",
].join(" ")}
>
{children}
{typeof count === "number" && (
<span
className={[
"rounded-full px-2 py-0.5 text-[11px] font-semibold tabular-nums",
attention
? "bg-[var(--accent-deep)] text-[oklch(0.98_0.005_45)]"
: "border border-[var(--hairline)] bg-[var(--surface-2)] text-[var(--ink-soft)]",
].join(" ")}
>
{count}
</span>
)}
</a>
);
}
function CategoryChips() {
const categories = [
["Schließtag", TerminType.SCHLIESSTAG],
["Teamtag", TerminType.TEAMTAG],
["Elternabend", TerminType.ELTERNABEND],
["Fest", TerminType.KITA_FEST],
["Geburtstag", TerminType.GEBURTSTAG_INTERN],
["Sonstiges", TerminType.SONSTIGES],
] as const;
return (
<div className="flex flex-wrap gap-1.5">
<span className="inline-flex h-9 items-center gap-1.5 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-medium text-[var(--ink-soft)]">
<ListFilter className="h-3.5 w-3.5 [stroke-width:1.7]" />
Filter
</span>
{categories.map(([label, type]) => (
<span
key={label}
className="inline-flex h-9 items-center gap-1.5 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-3 text-[13px] font-medium text-[var(--ink-soft)]"
>
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: getCategoryColor(type) }}
/>
{label}
</span>
))}
</div>
);
}
function getCategoryColor(type: TerminType) {
switch (type) {
case TerminType.SCHLIESSTAG:
case TerminType.TEAMTAG:
return "var(--danger)";
case TerminType.ELTERNABEND:
return "oklch(0.50 0.10 280)";
case TerminType.KITA_FEST:
return "var(--good)";
case TerminType.GEBURTSTAG_INTERN:
return "oklch(0.50 0.10 320)";
default:
return "var(--ink-muted)";
}
}