feat: complete notdienst planning, quick wins and notifications

This commit is contained in:
t.indorf
2026-10-12 15:34:09 +02:00
parent b915857951
commit 00f602a7aa
50 changed files with 2443 additions and 513 deletions
+36 -23
View File
@@ -1,11 +1,13 @@
import Link from "next/link";
import { addMonths, getYear, getMonth, format } from "date-fns";
import { de } from "date-fns/locale";
import { TerminType } from "@prisma/client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { prisma } from "@/lib/prisma";
import { getWorkingDaysOfMonth } from "@/lib/date-utils";
import { formatUtcDateKey, getWorkingDaysOfMonth, getTargetMonthData } from "@/lib/date-utils";
import { loadNotdienstDays } from "@/lib/notdienst-planning";
import { getNotdienstEmailStatus } from "@/lib/notdienst-notifications";
import { EmailStatusCard } from "./_components/email-status-card";
import { PlanView } from "./plan-view";
import { SettingsModal } from "./_components/settings-modal";
import { requireAlarmActor } from "@/lib/alarms/auth";
@@ -23,22 +25,21 @@ export default async function PlanungsZentralePage({
const resolvedSearchParams = await searchParams;
const now = new Date();
const nextMonth = addMonths(now, 1);
const defaultTarget = getTargetMonthData(now);
const targetYear = resolvedSearchParams.year
? parseInt(resolvedSearchParams.year, 10)
: getYear(nextMonth);
: defaultTarget.targetYear;
const targetMonth = resolvedSearchParams.month
? parseInt(resolvedSearchParams.month, 10)
: getMonth(nextMonth) + 1;
: defaultTarget.targetMonth;
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 monthStart = new Date(Date.UTC(targetYear, targetMonth - 1, 1));
const monthEnd = new Date(Date.UTC(targetYear, targetMonth, 1));
const [plan, families, availabilities, closureCount, kita] =
const [plan, families, rawAvailabilities, workingDays, kita] =
await Promise.all([
prisma.notdienstPlan.findUnique({
where: {
@@ -80,6 +81,7 @@ export default async function PlanungsZentralePage({
prisma.notdienstAvailability.findMany({
where: {
kitaId,
child: { kitaId, active: true },
date: {
gte: monthStart,
lt: monthEnd,
@@ -95,16 +97,7 @@ export default async function PlanungsZentralePage({
},
orderBy: { date: "asc" },
}),
prisma.termin.count({
where: {
kitaId,
type: TerminType.SCHLIESSTAG,
startDate: {
gte: monthStart,
lt: monthEnd,
},
},
}),
loadNotdienstDays(prisma, kitaId, targetYear, targetMonth),
prisma.kita.findUniqueOrThrow({
where: { id: kitaId },
select: {
@@ -152,6 +145,10 @@ export default async function PlanungsZentralePage({
};
});
const validDateKeys = new Set(workingDays.map(formatUtcDateKey));
const availabilities = rawAvailabilities.filter((a) => validDateKeys.has(formatUtcDateKey(a.date)));
const closureCount = getWorkingDaysOfMonth(targetYear, targetMonth).length - workingDays.length;
const availabilityDatesByFamily = new Map<string, Set<string>>();
availabilities.forEach((availability) => {
const familyId = availability.child.familyId;
@@ -174,11 +171,12 @@ export default async function PlanungsZentralePage({
}).format(new Date(date)),
})),
childCount: family.children.length,
requiredDays: family.children.length * kita.notdienstMinPerChildPerMonth,
};
});
const respondedFamilies = familyStatuses.filter(
(family) => family.selectedDays.length > 0,
(family) => family.selectedDays.length >= family.requiredDays,
).length;
const openFamilies = Math.max(familyStatuses.length - respondedFamilies, 0);
const totalAvailabilitySlots = availabilities.length;
@@ -187,7 +185,13 @@ export default async function PlanungsZentralePage({
requiredDays > 0 ? totalAvailabilitySlots / requiredDays : 0;
// Wir mappen die Assignments auf die Werktage
const daysWithAssignments = workingDays.map((day) => {
// Keep obsolete assignments visible so the coordinator can remove them.
const displayedDays = new Map(workingDays.map((day) => [formatUtcDateKey(day), day]));
for (const assignment of plan?.assignments ?? []) {
displayedDays.set(formatUtcDateKey(assignment.date), assignment.date);
}
const activeChildIds = new Set(allChildren.map((child) => child.id));
const daysWithAssignments = Array.from(displayedDays.values()).sort((a, b) => a.getTime() - b.getTime()).map((day) => {
const assignment = plan?.assignments.find(
(a) => a.date.getTime() === day.getTime(),
);
@@ -195,19 +199,25 @@ export default async function PlanungsZentralePage({
!assignment ||
(plan?.publishedAt && assignment.createdAt > plan.publishedAt);
const isInvalid = !validDateKeys.has(formatUtcDateKey(day)) || !!assignment && !activeChildIds.has(assignment.childId);
return {
date: day.toISOString(),
isInvalid,
assignmentId: assignment?.id || null,
childId: assignment?.childId || null,
isOriginallyUnassigned: !!isOriginallyUnassigned,
activeAlarmId: assignment?.alarms[0]?.id ?? null,
canAlarm:
!!assignment &&
!!assignment && !isInvalid &&
plan?.status === "PUBLISHED" &&
day.toISOString().slice(0, 10) >= todayKey(),
};
});
const mailStatus = await getNotdienstEmailStatus(kitaId, targetYear, targetMonth);
const target = getTargetMonthData(now);
const remindersOpen = !target.isLocked && target.targetYear === targetYear && target.targetMonth === targetMonth && plan?.status !== "PUBLISHED";
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">
@@ -226,7 +236,7 @@ export default async function PlanungsZentralePage({
</p>
</div>
<div className="print:hidden flex shrink-0 items-center gap-2">
<div className="print:hidden flex flex-wrap 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}`}
@@ -251,8 +261,11 @@ export default async function PlanungsZentralePage({
</div>
</header>
<EmailStatusCard status={mailStatus} year={targetYear} month={targetMonth} remindersOpen={remindersOpen} daysBefore={kita.notdienstReminderDaysBefore} jobConfigured={(process.env.NOTDIENST_JOB_SECRET?.length ?? 0) >= 32} />
<PlanView
planId={plan?.id}
remindersOpen={remindersOpen}
status={plan?.status}
days={daysWithAssignments}
allChildren={allChildren}