502 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|