Files
kita-planer/src/app/dashboard/page.tsx
T
2026-05-15 21:28:36 +02:00

502 lines
14 KiB
TypeScript

import Link from "next/link";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { Users, CalendarCheck2, ShieldCheck, AlertTriangle, ClipboardList } from "lucide-react";
import { DutyAssignmentStatus, UserRole, NotdienstPlanStatus, TerminStatus } from "@prisma/client";
import { requireKitaSession } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { OnboardingDialog } from "@/components/OnboardingDialog";
import { AlertButton } from "./alert-button";
import { AbsenceCard } from "./absence-card";
import { NewsTicker } from "./news-ticker";
import { RsvpActionCard } from "./rsvp-action-card";
export const metadata = { title: "Übersicht · Kita-Planer" };
export default async function DashboardPage() {
const session = await requireKitaSession();
const now = new Date();
const kita = await prisma.kita.findUniqueOrThrow({
where: { id: session.user.kitaId },
select: {
name: true,
notdienstModuleEnabled: true,
terminModuleEnabled: true,
adressbuchModuleEnabled: true,
},
});
const today = new Date();
today.setHours(0, 0, 0, 0);
// Schnelle Statistiken und persönliche Dienste für die Übersicht.
const [
familyCount,
childCount,
upcomingDuties,
onboardingUser,
absenceChildren,
upcomingAbsences,
latestAnnouncements,
] = await Promise.all([
prisma.family.count({
where: { kitaId: session.user.kitaId },
}),
prisma.child.count({
where: { kitaId: session.user.kitaId },
}),
session.user.familyId
? prisma.dutyAssignment.findMany({
where: {
kitaId: session.user.kitaId,
familyId: session.user.familyId,
status: DutyAssignmentStatus.PLANNED,
endDate: { gte: today },
},
select: {
id: true,
startDate: true,
endDate: true,
dutyType: {
select: {
name: true,
},
},
},
orderBy: { startDate: "asc" },
take: 5,
})
: Promise.resolve([]),
prisma.user.findFirstOrThrow({
where: {
id: session.user.id,
kitaId: session.user.kitaId,
},
select: {
phone: true,
street: true,
postalCode: true,
city: true,
familyId: true,
role: true,
family: {
select: {
_count: {
select: {
children: true,
},
},
},
},
},
}),
session.user.familyId
? prisma.child.findMany({
where: {
kitaId: session.user.kitaId,
familyId: session.user.familyId,
active: true,
},
select: {
id: true,
firstName: true,
lastName: true,
},
orderBy: [{ lastName: "asc" }, { firstName: "asc" }],
})
: Promise.resolve([]),
session.user.familyId
? prisma.absence.findMany({
where: {
kitaId: session.user.kitaId,
endDate: { gte: today },
child: {
familyId: session.user.familyId,
},
},
select: {
id: true,
reason: true,
note: true,
startDate: true,
endDate: true,
child: {
select: {
firstName: true,
lastName: true,
},
},
},
orderBy: [{ startDate: "asc" }],
take: 6,
})
: Promise.resolve([]),
prisma.announcement.findMany({
where: { kitaId: session.user.kitaId },
select: {
id: true,
title: true,
content: true,
createdAt: true,
author: {
select: {
firstName: true,
lastName: true,
},
},
attachments: {
select: {
id: true,
fileName: true,
fileUrl: true,
fileType: true,
},
},
reads: {
where: { userId: session.user.id },
select: { id: true },
take: 1,
},
},
orderBy: { createdAt: "desc" },
take: 3,
}),
]);
const familyChildIds = absenceChildren.map((child) => child.id);
const openRsvpTermine =
session.user.familyId && familyChildIds.length > 0
? await prisma.termin.findMany({
where: {
kitaId: session.user.kitaId,
status: TerminStatus.CONFIRMED,
requiresRsvp: true,
OR: [
{ rsvpDeadline: { gt: now } },
{ rsvpDeadline: null, startDate: { gt: now } },
],
},
select: {
id: true,
title: true,
startDate: true,
rsvpDeadline: true,
participations: {
where: {
childId: { in: familyChildIds },
},
select: {
childId: true,
status: true,
},
},
},
orderBy: { startDate: "asc" },
take: 5,
})
: [];
const rsvpActionEvents = openRsvpTermine
.map((termin) => {
const statusByChildId = new Map(
termin.participations.map((participation) => [
participation.childId,
participation.status,
]),
);
return {
id: termin.id,
title: termin.title,
startDate: termin.startDate.toISOString(),
rsvpDeadline: termin.rsvpDeadline?.toISOString() ?? null,
children: absenceChildren.map((child) => ({
id: child.id,
name: `${child.firstName} ${child.lastName}`,
status: statusByChildId.get(child.id) ?? null,
})),
};
})
.filter((termin) => termin.children.some((child) => !child.status));
const isAdmin =
session.user.role === UserRole.ADMIN ||
session.user.role === UserRole.SUPERADMIN;
const canReportAbsences = session.user.role !== UserRole.ERZIEHER;
return (
<div className="px-8 py-8">
<OnboardingDialog
user={{
phone: onboardingUser.phone,
street: onboardingUser.street,
postalCode: onboardingUser.postalCode,
city: onboardingUser.city,
familyId: onboardingUser.familyId,
role: onboardingUser.role,
}}
family={
onboardingUser.family
? { childrenCount: onboardingUser.family._count.children }
: null
}
/>
<div className="mb-8">
<h1 className="text-2xl font-semibold tracking-tight">
Willkommen, {session.user.name?.split(" ")[0] ?? "zusammen"}!
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Hier ist die Übersicht für <strong>{kita.name}</strong>.
</p>
</div>
<RsvpActionCard events={rsvpActionEvents} />
<NewsTicker
items={latestAnnouncements.map((announcement) => ({
id: announcement.id,
title: announcement.title,
content: announcement.content,
createdAt: format(announcement.createdAt, "dd.MM.yyyy", {
locale: de,
}),
authorName: `${announcement.author.firstName} ${announcement.author.lastName}`,
isUnread: announcement.reads.length === 0,
attachments: announcement.attachments.map((attachment) => ({
id: attachment.id,
fileName: attachment.fileName,
fileUrl: attachment.fileUrl,
fileType: attachment.fileType,
})),
}))}
/>
{/* Heutiger Notdienst (Nur für Admins/Koordinatoren) */}
{isAdmin && (
<div className="mb-8">
<TodaysNotdienstCard kitaId={session.user.kitaId!} />
</div>
)}
{canReportAbsences && (
<AbsenceCard
today={format(today, "yyyy-MM-dd")}
childOptions={absenceChildren.map((child) => ({
id: child.id,
name: `${child.firstName} ${child.lastName}`,
}))}
absences={upcomingAbsences.map((absence) => ({
id: absence.id,
childName: `${absence.child.firstName} ${absence.child.lastName}`,
reason: absence.reason,
note: absence.note,
startDate: format(absence.startDate, "yyyy-MM-dd"),
endDate: format(absence.endDate, "yyyy-MM-dd"),
}))}
/>
)}
{upcomingDuties.length > 0 && (
<Card className="mb-8 border-emerald-200 bg-emerald-50/50">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<ClipboardList className="h-5 w-5 text-emerald-700" />
Deine anstehenden Dienste
</CardTitle>
<CardDescription>
Die nächsten Einteilungen für deinen Haushalt.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{upcomingDuties.map((duty) => (
<div key={duty.id} className="rounded-md border bg-background p-3">
<p className="font-medium">{duty.dutyType.name}</p>
<p className="mt-1 text-sm text-muted-foreground">
{format(duty.startDate, "dd.MM.", { locale: de })} bis{" "}
{format(duty.endDate, "dd.MM.yyyy", { locale: de })}
</p>
</div>
))}
</CardContent>
</Card>
)}
{/* Statistik-Kacheln */}
<div className="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<StatCard
label="Familien"
value={familyCount}
description="registrierte Familien"
icon={<Users className="h-5 w-5 text-muted-foreground" />}
/>
<StatCard
label="Kinder"
value={childCount}
description="in der Einrichtung"
icon={<ShieldCheck className="h-5 w-5 text-muted-foreground" />}
/>
</div>
{/* Schnell-Aktionen für Admins */}
{isAdmin && (
<Card>
<CardHeader>
<CardTitle className="text-base">Schnellstart</CardTitle>
<CardDescription>
Die nächsten Schritte, um deine Kita einzurichten.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-3">
<Button asChild size="sm">
<Link href="/dashboard/families">
<Users className="mr-2 h-4 w-4" />
Familien verwalten
</Link>
</Button>
{kita.terminModuleEnabled && (
<Button asChild size="sm" variant="outline">
<Link href="/dashboard/kalender">
<CalendarCheck2 className="mr-2 h-4 w-4" />
Terminkalender
</Link>
</Button>
)}
</CardContent>
</Card>
)}
</div>
);
}
function StatCard({
label,
value,
description,
icon,
}: {
label: string;
value: number;
description: string;
icon: React.ReactNode;
}) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{label}
</CardTitle>
{icon}
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{value}</div>
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
</CardContent>
</Card>
);
}
async function TodaysNotdienstCard({ kitaId }: { kitaId: string }) {
const today = new Date();
today.setHours(0, 0, 0, 0);
// Wir suchen das Assignment für heute, aber nur wenn der Plan PUBLISHED ist
const assignment = await prisma.notdienstAssignment.findFirst({
where: {
kitaId,
date: today,
plan: { status: NotdienstPlanStatus.PUBLISHED },
},
select: {
id: true,
child: {
select: {
firstName: true,
lastName: true,
family: {
select: {
name: true,
users: {
select: {
firstName: true,
lastName: true,
},
},
},
},
},
},
alerts: {
select: {
status: true,
},
},
},
});
if (!assignment) return null;
const parentNames = assignment.child.family.users
.map((parent) => `${parent.firstName} ${parent.lastName}`)
.join(", ");
const existingAlert = assignment.alerts[0]; // Kann PENDING, CONFIRMED oder CANCELLED sein
return (
<Card className="border-primary/20 bg-primary/5">
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-primary" />
Heutiger Notdienst
</CardTitle>
<CardDescription>
Laut dem veröffentlichten Plan ist heute folgende Familie eingeteilt:
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-medium text-base">
{parentNames || assignment.child.family.name}
</p>
<p className="text-sm text-muted-foreground">
Kind: {assignment.child.firstName} {assignment.child.lastName}
</p>
</div>
<div className="flex items-center gap-3">
{existingAlert ? (
<Badge
variant={
existingAlert.status === "CONFIRMED"
? "success"
: existingAlert.status === "PENDING"
? "warning"
: "destructive"
}
className="px-3 py-1 text-sm"
>
{existingAlert.status === "CONFIRMED"
? "Einsatz bestätigt"
: existingAlert.status === "PENDING"
? "Wartet auf Bestätigung"
: "Abgebrochen"}
</Badge>
) : (
<AlertButton assignmentId={assignment.id} />
)}
</div>
</CardContent>
</Card>
);
}