Files
kita-planer/src/app/dashboard/kalender/page.tsx
T

513 lines
14 KiB
TypeScript

import {
DutyAssignmentStatus,
EventParticipationStatus,
NotdienstSwapStatus,
TerminStatus,
UserRole,
} from "@prisma/client";
import { HelpCircle } from "lucide-react";
import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { AdminTerminModal } from "./_components/admin-termin-modal";
import { PendingAnfragen, type PendingTerminDto } from "./_components/pending-anfragen";
import { type CalendarListItemDto, type NotdienstSwapOptionDto } from "./_components/termin-list";
import { TerminRequestModal } from "./_components/termin-request-modal";
import {
CalendarOverviewClient,
type NotdienstSwapRequestDto,
} from "./_components/calendar-overview-client";
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 today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
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,
familyId: true,
dutyType: {
select: {
name: true,
},
},
family: {
select: {
name: true,
},
},
},
orderBy: { startDate: "asc" },
});
const notdienstAssignmentRows = await prisma.notdienstAssignment.findMany({
where: {
kitaId: session.user.kitaId,
plan: {
status: "PUBLISHED",
},
},
select: {
id: true,
date: true,
planId: true,
plan: {
select: {
year: true,
month: true,
},
},
child: {
select: {
firstName: true,
lastName: true,
familyId: true,
family: {
select: {
name: true,
},
},
},
},
},
orderBy: { date: "asc" },
});
const pendingSwapRows =
!isAdmin && session.user.familyId
? await prisma.notdienstSwapRequest.findMany({
where: {
kitaId: session.user.kitaId,
status: NotdienstSwapStatus.PENDING,
OR: [
{ requesterFamilyId: session.user.familyId },
{
targetAssignment: {
child: {
familyId: session.user.familyId,
},
},
},
],
},
select: {
id: true,
requesterFamilyId: true,
message: true,
targetAssignment: {
select: {
date: true,
child: {
select: {
firstName: true,
lastName: true,
family: { select: { name: true } },
},
},
},
},
offeredAssignment: {
select: {
date: true,
child: {
select: {
firstName: true,
lastName: true,
family: { select: { name: true } },
},
},
},
},
},
orderBy: { createdAt: "desc" },
})
: [];
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) => {
const isOwn = assignment.familyId === familyIdForParticipation;
return {
kind: "duty" as const,
id: assignment.id,
title: isOwn
? `Mein Elterndienst (${assignment.dutyType.name})`
: `${assignment.dutyType.name} (${assignment.family.name})`,
familyName: assignment.family.name,
status: assignment.status,
startDate: assignment.startDate,
endDate: assignment.endDate,
isOwn,
};
}),
...notdienstAssignmentRows.map((assignment) => {
const isOwn = assignment.child.familyId === familyIdForParticipation;
const canRequestSwap =
!isAdmin &&
!isOwn &&
!!session.user.familyId &&
assignment.date >= today;
return {
kind: "notdienst" as const,
id: assignment.id,
planId: assignment.planId,
planYear: assignment.plan.year,
planMonth: assignment.plan.month,
title: isOwn
? `Mein Notdienst (${assignment.child.firstName})`
: `Notdienst (${assignment.child.family.name})`,
familyId: assignment.child.familyId,
familyName: assignment.child.family.name,
childName: `${assignment.child.firstName} ${assignment.child.lastName}`,
isOwn,
canRequestSwap,
startDate: assignment.date,
endDate: assignment.date,
};
}),
].sort((a, b) => a.startDate.getTime() - b.startDate.getTime());
const notdienstSwapOptions: NotdienstSwapOptionDto[] = notdienstAssignmentRows
.filter(
(assignment) =>
assignment.child.familyId === familyIdForParticipation &&
assignment.date >= today,
)
.map((assignment) => ({
id: assignment.id,
date: assignment.date,
childName: `${assignment.child.firstName} ${assignment.child.lastName}`,
familyName: assignment.child.family.name,
}));
const pendingSwapRequests: NotdienstSwapRequestDto[] = pendingSwapRows.map(
(request) => ({
id: request.id,
direction:
request.requesterFamilyId === session.user.familyId ? "outgoing" : "incoming",
targetDate: request.targetAssignment.date,
targetFamilyName: request.targetAssignment.child.family.name,
targetChildName: `${request.targetAssignment.child.firstName} ${request.targetAssignment.child.lastName}`,
offeredDate: request.offeredAssignment.date,
offeredFamilyName: request.offeredAssignment.child.family.name,
offeredChildName: `${request.offeredAssignment.child.firstName} ${request.offeredAssignment.child.lastName}`,
message: request.message,
}),
);
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" ? (
<CalendarOverviewClient
termine={allUserTermine}
userId={session.user.id}
isAdmin={isAdmin}
notdienstSwapOptions={notdienstSwapOptions}
pendingSwapRequests={pendingSwapRequests}
/>
) : currentTab === "anfragen" ? (
<PendingAnfragen termine={allPendingTermine} />
) : (
<CalendarOverviewClient
termine={[]}
userId={session.user.id}
isAdmin={isAdmin}
notdienstSwapOptions={notdienstSwapOptions}
pendingSwapRequests={pendingSwapRequests}
/>
)}
</div>
</div>
) : (
<CalendarOverviewClient
termine={allUserTermine}
userId={session.user.id}
isAdmin={isAdmin}
notdienstSwapOptions={notdienstSwapOptions}
pendingSwapRequests={pendingSwapRequests}
/>
)}
</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>
);
}