250 lines
8.2 KiB
TypeScript
250 lines
8.2 KiB
TypeScript
import Link from "next/link";
|
|
import { addMonths, getYear, getMonth, format } from "date-fns";
|
|
import { de } from "date-fns/locale";
|
|
import { TerminType, UserRole } from "@prisma/client";
|
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
|
|
import { requireRole } from "@/lib/auth-utils";
|
|
import { prisma } from "@/lib/prisma";
|
|
import { getWorkingDaysOfMonth } from "@/lib/date-utils";
|
|
import { PlanView } from "./plan-view";
|
|
import { SettingsModal } from "./_components/settings-modal";
|
|
|
|
export const metadata = { title: "Plan-Generierung · Kita-Planer" };
|
|
|
|
export default async function PlanungsZentralePage({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<{ year?: string; month?: string }>;
|
|
}) {
|
|
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
|
const kitaId = session.user.kitaId!;
|
|
|
|
const resolvedSearchParams = await searchParams;
|
|
const now = new Date();
|
|
const nextMonth = addMonths(now, 1);
|
|
const targetYear = resolvedSearchParams.year ? parseInt(resolvedSearchParams.year, 10) : getYear(nextMonth);
|
|
const targetMonth = resolvedSearchParams.month ? parseInt(resolvedSearchParams.month, 10) : (getMonth(nextMonth) + 1);
|
|
|
|
const monthDate = new Date(targetYear, targetMonth - 1, 1);
|
|
const monthName = format(monthDate, "MMMM", { locale: de });
|
|
|
|
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
|
|
const monthStart = new Date(targetYear, targetMonth - 1, 1);
|
|
const monthEnd = new Date(targetYear, targetMonth, 1);
|
|
|
|
const [plan, families, availabilities, closureCount, kita] = await Promise.all([
|
|
prisma.notdienstPlan.findUnique({
|
|
where: {
|
|
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
|
|
},
|
|
select: {
|
|
id: true,
|
|
status: true,
|
|
assignments: {
|
|
select: {
|
|
id: true,
|
|
childId: true,
|
|
date: true,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
prisma.family.findMany({
|
|
where: {
|
|
kitaId,
|
|
children: { some: { active: true } },
|
|
},
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
children: {
|
|
where: { active: true },
|
|
select: { id: true },
|
|
},
|
|
},
|
|
orderBy: { name: "asc" },
|
|
}),
|
|
prisma.notdienstAvailability.findMany({
|
|
where: {
|
|
kitaId,
|
|
date: {
|
|
gte: monthStart,
|
|
lt: monthEnd,
|
|
},
|
|
},
|
|
select: {
|
|
date: true,
|
|
child: {
|
|
select: {
|
|
familyId: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: { date: "asc" },
|
|
}),
|
|
prisma.termin.count({
|
|
where: {
|
|
kitaId,
|
|
type: TerminType.SCHLIESSTAG,
|
|
startDate: {
|
|
gte: monthStart,
|
|
lt: monthEnd,
|
|
},
|
|
},
|
|
}),
|
|
prisma.kita.findUniqueOrThrow({
|
|
where: { id: kitaId },
|
|
select: {
|
|
notdienstMinPerChildPerMonth: true,
|
|
notdienstReminderDaysBefore: true,
|
|
},
|
|
}),
|
|
]);
|
|
|
|
// Wir laden auch alle aktiven Kinder (inkl. Eltern-Namen) für das manuelle Dropdown.
|
|
// Idealerweise gruppiert man das nach "Verfügbar an diesem Tag" und "Alle",
|
|
// aber für MVP reichen alle aktiven Kinder.
|
|
const allChildrenRaw = await prisma.child.findMany({
|
|
where: { kitaId, active: true },
|
|
select: {
|
|
id: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
family: {
|
|
select: {
|
|
name: true,
|
|
users: {
|
|
select: {
|
|
firstName: true,
|
|
lastName: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
orderBy: { lastName: "asc" },
|
|
});
|
|
|
|
const allChildren = allChildrenRaw.map((c) => {
|
|
const parentName =
|
|
c.family.users
|
|
.map((parent) => `${parent.firstName} ${parent.lastName}`)
|
|
.join(", ") || c.family.name;
|
|
return {
|
|
id: c.id,
|
|
name: `${c.firstName} ${c.lastName}`,
|
|
parentName,
|
|
};
|
|
});
|
|
|
|
const availabilityDatesByFamily = new Map<string, Set<string>>();
|
|
availabilities.forEach((availability) => {
|
|
const familyId = availability.child.familyId;
|
|
const dates = availabilityDatesByFamily.get(familyId) ?? new Set<string>();
|
|
dates.add(availability.date.toISOString());
|
|
availabilityDatesByFamily.set(familyId, dates);
|
|
});
|
|
|
|
const familyStatuses = families.map((family) => {
|
|
const dates = Array.from(availabilityDatesByFamily.get(family.id) ?? []);
|
|
return {
|
|
id: family.id,
|
|
name: family.name,
|
|
selectedDays: dates.map((date) => ({
|
|
iso: date,
|
|
label: new Intl.DateTimeFormat("de-DE", {
|
|
weekday: "short",
|
|
day: "2-digit",
|
|
month: "2-digit",
|
|
}).format(new Date(date)),
|
|
})),
|
|
childCount: family.children.length,
|
|
};
|
|
});
|
|
|
|
const respondedFamilies = familyStatuses.filter(
|
|
(family) => family.selectedDays.length > 0,
|
|
).length;
|
|
const openFamilies = Math.max(familyStatuses.length - respondedFamilies, 0);
|
|
const totalAvailabilitySlots = availabilities.length;
|
|
const requiredDays = workingDays.length;
|
|
const coverageFactor =
|
|
requiredDays > 0 ? totalAvailabilitySlots / requiredDays : 0;
|
|
|
|
// Wir mappen die Assignments auf die Werktage
|
|
const daysWithAssignments = workingDays.map((day) => {
|
|
const assignment = plan?.assignments.find(
|
|
(a) => a.date.getTime() === day.getTime()
|
|
);
|
|
return {
|
|
date: day.toISOString(),
|
|
assignmentId: assignment?.id || null,
|
|
childId: assignment?.childId || null,
|
|
};
|
|
});
|
|
|
|
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]">
|
|
NOTDIENST · PLANUNG
|
|
</p>
|
|
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
|
|
Notdienst{" "}
|
|
<em className="font-normal italic text-[var(--accent-deep)]">
|
|
zuweisen
|
|
</em>
|
|
</h1>
|
|
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
|
|
Koordiniere und generiere den Notdienst-Plan.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="print:hidden flex shrink-0 items-center gap-2">
|
|
<div className="inline-flex overflow-hidden rounded-full border border-[var(--hairline)] bg-[var(--surface)]">
|
|
<Link
|
|
href={`/dashboard/notdienst/plan?year=${getYear(addMonths(monthDate, -1))}&month=${getMonth(addMonths(monthDate, -1)) + 1}`}
|
|
className="inline-flex h-9 w-9 items-center justify-center border-r border-[var(--hairline-soft)] text-[var(--ink-soft)] hover:bg-[var(--surface-2)] hover:text-[var(--ink)] transition"
|
|
>
|
|
<ChevronLeft className="h-4 w-4 [stroke-width:1.7]" />
|
|
</Link>
|
|
<div className="flex h-9 min-w-36 items-center justify-center px-4 text-[13px] font-semibold text-[var(--ink)]">
|
|
{monthName} {targetYear}
|
|
</div>
|
|
<Link
|
|
href={`/dashboard/notdienst/plan?year=${getYear(addMonths(monthDate, 1))}&month=${getMonth(addMonths(monthDate, 1)) + 1}`}
|
|
className="inline-flex h-9 w-9 items-center justify-center border-l border-[var(--hairline-soft)] text-[var(--ink-soft)] hover:bg-[var(--surface-2)] hover:text-[var(--ink)] transition"
|
|
>
|
|
<ChevronRight className="h-4 w-4 [stroke-width:1.7]" />
|
|
</Link>
|
|
</div>
|
|
<SettingsModal
|
|
initialMinPerChild={kita.notdienstMinPerChildPerMonth}
|
|
initialReminderDays={kita.notdienstReminderDaysBefore}
|
|
/>
|
|
</div>
|
|
</header>
|
|
|
|
<PlanView
|
|
planId={plan?.id}
|
|
status={plan?.status}
|
|
days={daysWithAssignments}
|
|
allChildren={allChildren}
|
|
familyStatuses={familyStatuses}
|
|
requiredDays={requiredDays}
|
|
respondedFamilies={respondedFamilies}
|
|
openFamilies={openFamilies}
|
|
totalFamilies={familyStatuses.length}
|
|
totalAvailabilitySlots={totalAvailabilitySlots}
|
|
coverageFactor={coverageFactor}
|
|
closureCount={closureCount}
|
|
monthName={monthName}
|
|
year={targetYear}
|
|
month={targetMonth}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|