Files
kita-planer/src/app/dashboard/page.tsx
T
2026-05-20 22:47:52 +02:00

330 lines
10 KiB
TypeScript

import Link from "next/link";
import type { ComponentType } from "react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import {
CalendarCheck2,
CalendarClock,
ChevronRight,
Clock3,
ListTodo,
Users,
type LucideProps,
} from "lucide-react";
import { UserRole } from "@prisma/client";
import { requireKitaSession } from "@/lib/auth-utils";
import { getTargetMonthData } from "@/lib/date-utils";
import { prisma } from "@/lib/prisma";
import { Stat } from "@/components/ui/stat";
import { OnboardingDialog } from "@/components/OnboardingDialog";
import { AbsenceCard, UpcomingAbsencesCard } from "./absence-card";
export const metadata = { title: "Übersicht · Kita-Planer" };
export default async function DashboardPage() {
const session = await requireKitaSession();
const now = new Date();
const today = new Date(now);
today.setHours(0, 0, 0, 0);
const targetData = getTargetMonthData();
const targetMonthStart = new Date(
Date.UTC(targetData.targetYear, targetData.targetMonth - 1, 1),
);
const targetMonthEnd = new Date(
Date.UTC(targetData.targetYear, targetData.targetMonth, 1),
);
const kita = await prisma.kita.findUniqueOrThrow({
where: { id: session.user.kitaId },
select: {
name: true,
notdienstModuleEnabled: true,
notdienstMinPerChildPerMonth: true,
terminModuleEnabled: true,
adressbuchModuleEnabled: true,
},
});
const [
familyCount,
childCount,
todayAbsenceCount,
notdienstAvailabilityCount,
onboardingUser,
absenceChildren,
upcomingAbsences,
] = await Promise.all([
prisma.family.count({
where: { kitaId: session.user.kitaId },
}),
prisma.child.count({
where: { kitaId: session.user.kitaId, active: true },
}),
prisma.absence.count({
where: {
kitaId: session.user.kitaId,
startDate: { lte: today },
endDate: { gte: today },
},
}),
prisma.notdienstAvailability.count({
where: {
kitaId: session.user.kitaId,
date: {
gte: targetMonthStart,
lt: targetMonthEnd,
},
},
}),
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([]),
]);
const canReportAbsences = session.user.role !== UserRole.ERZIEHER;
const requiredNotdienstDays = Math.max(
childCount * kita.notdienstMinPerChildPerMonth,
1,
);
const openNotdienstDays = Math.max(
requiredNotdienstDays - notdienstAvailabilityCount,
0,
);
const todayPresentCount = Math.max(childCount - todayAbsenceCount, 0);
const firstName =
session.user.name?.split(" ")[0] ??
session.user.email?.split("@")[0] ??
"zusammen";
return (
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<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
}
/>
<header className="mb-9 flex items-start justify-between gap-8">
<div>
<p className="text-[11px] font-semibold uppercase text-[var(--accent-deep)] [letter-spacing:0.18em]">
ÜBERSICHT · {kita.name}
</p>
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Willkommen{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
zurück
</em>
, {firstName}.
</h1>
<p className="mt-3 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
Heute, {format(now, "EEEE, d. MMMM yyyy", { locale: de })}. Hier
siehst du auf einen Blick, was bei {kita.name} ansteht und kannst
dein Kind schnell für Abwesenheiten melden.
</p>
</div>
<div className="mt-9 inline-flex shrink-0 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-4 py-2 text-[13px] font-semibold text-[var(--ink-soft)]">
<Clock3 className="h-3.5 w-3.5 [stroke-width:1.7]" />
{format(now, "EEE, d. MMM yyyy", { locale: de })}
</div>
</header>
<div className="grid grid-cols-[minmax(0,1fr)_360px] gap-5 max-[1100px]:grid-cols-1">
<div className="min-w-0">
{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"),
}))}
/>
) : null}
</div>
<aside>
<UpcomingAbsencesCard
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"),
}))}
/>
</aside>
</div>
<section className="mt-5 grid grid-cols-4 gap-3.5 max-[1100px]:grid-cols-2 max-[640px]:grid-cols-1">
<Stat label="Familien" value={familyCount} hint="registrierte Familien" />
<Stat label="Kinder" value={childCount} hint="in der Einrichtung" />
<Stat
label={`Notdienst ${targetData.monthName}`}
value={notdienstAvailabilityCount}
suffix={`/ ${requiredNotdienstDays}`}
hint={`Tage abgedeckt · ${openNotdienstDays} offen`}
/>
<Stat
label="Heute anwesend"
value={todayPresentCount}
hint="Kinder in der Kita"
/>
</section>
<section className="mt-7">
<h2 className="text-lg font-medium tracking-[-0.02em] text-[var(--ink)]">
Schnellstart
</h2>
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
Häufige Aktionen für Vorstand und Koordination.
</p>
<div className="mt-4 grid grid-cols-3 gap-3 max-[1100px]:grid-cols-1">
<QuickCard
href="/dashboard/families"
icon={Users}
title="Familien verwalten"
sub="Familien anlegen, Kinder zuordnen, Kontakte pflegen."
/>
{kita.terminModuleEnabled ? (
<QuickCard
href="/dashboard/kalender"
icon={CalendarClock}
title="Terminkalender"
sub="Schließtage, Elternabende und Feste planen."
/>
) : null}
{kita.notdienstModuleEnabled ? (
<QuickCard
href="/dashboard/notdienst/plan"
icon={ListTodo}
title="Notdienst-Planung"
sub="Verfügbarkeiten überblicken und Dienste zuweisen."
/>
) : null}
{kita.adressbuchModuleEnabled ? (
<QuickCard
href="/dashboard/adressbuch"
icon={CalendarCheck2}
title="Adressbuch"
sub="Kontakte der Kita-Gemeinschaft schnell finden."
/>
) : null}
</div>
</section>
</div>
);
}
function QuickCard({
href,
icon: Icon,
title,
sub,
}: {
href: string;
icon: ComponentType<LucideProps>;
title: string;
sub: string;
}) {
return (
<Link
href={href}
className="group flex min-h-[86px] items-center gap-4 rounded-[14px] border border-[var(--hairline)] bg-[var(--surface)] p-4 transition hover:border-[var(--accent-mid)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Icon className="h-4.5 w-4.5 [stroke-width:1.7]" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-[var(--ink)]">
{title}
</p>
<p className="mt-1 line-clamp-2 text-[13px] leading-5 text-[var(--ink-soft)]">
{sub}
</p>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--ink-faint)] transition group-hover:translate-x-1 group-hover:text-[var(--accent-deep)]" />
</Link>
);
}