542 lines
16 KiB
TypeScript
542 lines
16 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 { canRequestNotdienstSwap } from "@/lib/notdienst-swaps";
|
|
import { getRsvpEvents } from "@/lib/rsvp-events";
|
|
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 [rsvpEventRows, confirmedTermineRows] = await Promise.all([
|
|
getRsvpEvents(session),
|
|
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,
|
|
rsvpMode: true,
|
|
rsvpDeadline: true,
|
|
_count: {
|
|
select: { participations: true, familyParticipations: true },
|
|
},
|
|
familyParticipations: {
|
|
where: { familyId: familyIdForParticipation },
|
|
select: { status: 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,
|
|
rsvpMode: true,
|
|
rsvpDeadline: true,
|
|
_count: { select: { participations: true, familyParticipations: true } },
|
|
familyParticipations: {
|
|
where: { familyId: familyIdForParticipation },
|
|
select: { status: 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 =
|
|
session.user.familyId && canRequestNotdienstSwap(session.user.role, 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 rsvpEvents = new Map(rsvpEventRows.map((event) => [event.id, event]));
|
|
const mapTermin = (
|
|
termin: (typeof confirmedTermineRows)[number],
|
|
): CalendarListItemDto => {
|
|
const showParticipantInfo =
|
|
isAdmin ||
|
|
termin.familyParticipations.some(
|
|
(row) => row.status === EventParticipationStatus.ATTENDING,
|
|
) ||
|
|
termin.participations.some(
|
|
(row) => row.status === EventParticipationStatus.ATTENDING,
|
|
);
|
|
const { _count, familyParticipations, ...data } = termin;
|
|
void familyParticipations;
|
|
return {
|
|
...data,
|
|
kind: "termin",
|
|
showParticipantInfo,
|
|
participantInfo: showParticipantInfo ? termin.participantInfo : null,
|
|
hasRsvpResponses: _count.participations + _count.familyParticipations > 0,
|
|
rsvpEvent: rsvpEvents.get(termin.id),
|
|
};
|
|
};
|
|
const allUserTermine: CalendarListItemDto[] = [
|
|
...confirmedTermineRows.map(mapTermin),
|
|
...myPendingTermineRows.map(mapTermin),
|
|
...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 =
|
|
canRequestNotdienstSwap(session.user.role, session.user.familyId) &&
|
|
!isOwn &&
|
|
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>
|
|
);
|
|
}
|