450 lines
12 KiB
TypeScript
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)";
|
|
}
|
|
}
|