330 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|