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
@@ -0,0 +1,149 @@
"use client";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import type { NotdienstEmailStatus } from "@/lib/notdienst-notifications";
import {
retryNotdienstEmailsAction,
sendNotdienstRemindersAction,
} from "../actions";
export function EmailStatusCard({
status,
year,
month,
remindersOpen,
daysBefore,
jobConfigured,
}: {
status: NotdienstEmailStatus;
year: number;
month: number;
remindersOpen: boolean;
daysBefore: number;
jobConfigured: boolean;
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const run = (retry: boolean) =>
startTransition(async () => {
const result = retry
? await retryNotdienstEmailsAction(year, month)
: await sendNotdienstRemindersAction(year, month);
if (result.error) toast.error(result.error);
else if ("failed" in result && result.failed)
toast.warning(
"Einige E-Mails sind noch nicht versendet. Der nächste Versuch erfolgt automatisch oder über diesen Button.",
);
else if ("sent" in result)
toast.success(`${result.sent} E-Mails versendet.`);
router.refresh();
});
const labels = {
sent: "Versendet",
pending: "Wartet auf Versand",
failed: "Versand fehlgeschlagen",
skipped: "Nicht mehr erforderlich",
};
return (
<Card className="mb-5 print:hidden p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-base font-medium text-[var(--ink)]">
E-Mail-Versand
</h2>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
{daysBefore
? `Automatische Erinnerung ab dem ${Math.max(1, 26 - daysBefore)}. um 9 Uhr. Eintragsfrist: der 26.`
: "Automatische Erinnerungen sind ausgeschaltet."}
</p>
{!jobConfigured ? (
<p className="mt-1 text-xs text-[var(--warn)]">
Der Hintergrundversand ist noch nicht konfiguriert. Offene E-Mails
können hier manuell gesendet werden.
</p>
) : null}
</div>
<div className="flex flex-wrap gap-2">
{remindersOpen ? (
<Button
variant="ghost"
size="sm"
disabled={pending}
onClick={() => run(false)}
>
An fehlende Verfügbarkeiten erinnern
</Button>
) : null}
{status.failed + status.pending > 0 ? (
<Button size="sm" disabled={pending} onClick={() => run(true)}>
Offene E-Mails erneut senden
</Button>
) : null}
<Button
variant="ghost"
size="sm"
disabled={pending}
onClick={() => router.refresh()}
>
Aktualisieren
</Button>
</div>
</div>
<p className="mt-3 text-sm text-[var(--ink-soft)]">
{status.sent} versendet · {status.pending} ausstehend · {status.failed}{" "}
fehlgeschlagen · {status.skipped} nicht mehr erforderlich
</p>
{status.entries.length ? (
<details className="mt-3 text-sm">
<summary className="cursor-pointer font-medium text-[var(--accent-deep)]">
Versanddetails ansehen
</summary>
<ul className="mt-3 max-h-64 overflow-y-auto divide-y divide-[var(--hairline-soft)]">
{status.entries.map((entry) => (
<li
key={entry.id}
className="flex flex-wrap justify-between gap-2 py-2"
>
<div>
<p>
{entry.name} · {entry.familyName}
</p>
<p className="text-xs text-[var(--ink-muted)]">
{entry.kind === "REMINDER"
? "Verfügbarkeitserinnerung"
: "Planveröffentlichung"}
</p>
{entry.error ? (
<p className="mt-1 text-xs text-[var(--danger)]">
{entry.error}
</p>
) : null}
</div>
<span
className={
entry.status === "failed"
? "text-[var(--danger)]"
: "text-[var(--ink-soft)]"
}
>
{labels[entry.status]}
</span>
</li>
))}
</ul>
</details>
) : (
<p className="mt-2 text-xs text-[var(--ink-muted)]">
Für diesen Monat wurden noch keine E-Mails vorbereitet.
</p>
)}
<p className="mt-3 text-xs text-[var(--ink-muted)]">
Versendet bedeutet: vom Maildienst angenommen. Die tatsächliche
Zustellung ins Postfach kann hier nicht bestätigt werden.
</p>
</Card>
);
}
@@ -35,8 +35,8 @@ export function SettingsModal({
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (minPerChild < 0 || reminderDays < 0) {
toast.error("Werte dürfen nicht negativ sein.");
if (!Number.isInteger(minPerChild) || minPerChild < 1 || minPerChild > 31 || !Number.isInteger(reminderDays) || reminderDays < 0 || reminderDays > 31) {
toast.error("Bitte ein Soll von 1 bis 31 und eine Erinnerungsfrist von 0 bis 31 Tagen eingeben.");
return;
}
@@ -74,25 +74,26 @@ export function SettingsModal({
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="minPerChild">
Mindesteinteilungen pro Kind/Monat
Verfügbarkeitstage pro Kind/Monat
</Label>
<Input
id="minPerChild"
name="minPerChild"
type="number"
min="0"
min="1"
max="31"
required
value={minPerChild}
onChange={(e) => setMinPerChild(parseInt(e.target.value, 10))}
/>
<p className="text-xs text-[var(--ink-muted)]">
Bestimmt das Richtmaß der automatischen Verteilung für Kinder im jeweiligen Monat.
So viele unterschiedliche Tage müssen Eltern je aktivem Kind als Verfügbarkeit eintragen.
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="reminderDays">
Erinnerungsfrist (Tage vor Monatsende)
Erinnerung (Tage vor Eintragsfrist)
</Label>
<Input
id="reminderDays"
@@ -100,11 +101,12 @@ export function SettingsModal({
type="number"
min="0"
required
max="31"
value={reminderDays}
onChange={(e) => setReminderDays(parseInt(e.target.value, 10))}
/>
<p className="text-xs text-[var(--ink-muted)]">
Frist, nach der Eltern automatisch per Mail an offene Verfügbarkeiten erinnert werden.
Die Eintragsfrist endet am 26. Bei 7 Tagen erfolgt die Erinnerung ab dem 19. um 9 Uhr. 0 deaktiviert automatische Erinnerungen.
</p>
</div>
</div>
+105 -210
View File
@@ -11,9 +11,12 @@ import { de } from "date-fns/locale";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { NotdienstAvailabilityReminderEmail } from "@/emails/NotdienstAvailabilityReminderEmail";
import { getTargetMonthData } from "@/lib/date-utils";
import { loadNotdienstDays } from "@/lib/notdienst-planning";
import { todayKey } from "@/lib/alarms/format";
import { serializableTransaction } from "@/lib/transaction";
import { queueNotdienstPublication, queueNotdienstReminders, deliverDueNotdienstEmails } from "@/lib/notdienst-notifications";
import { sendAppEmail } from "@/lib/mail";
import { NotdienstAssignmentChangeEmail } from "@/emails/NotdienstAssignmentChangeEmail";
import { requireAlarmActor } from "@/lib/alarms/auth";
import {
@@ -42,93 +45,44 @@ export async function generatePlanAction(year?: number, month?: number) {
if (!validPlanMonth(targetYear, targetMonth)) return { error: "Ungültiger Planungsmonat." };
// Prüfen, ob schon ein Plan existiert
const existingPlan = await prisma.notdienstPlan.findUnique({
where: {
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
},
});
if (existingPlan) {
return { error: "Ein Plan für diesen Monat existiert bereits." };
}
// 1. Alle Verfügbarkeiten der Eltern für diesen Monat holen
const availabilities = await prisma.notdienstAvailability.findMany({
where: {
kitaId,
date: {
gte: new Date(Date.UTC(targetYear, targetMonth - 1, 1)),
lt: new Date(Date.UTC(targetYear, targetMonth, 1)),
},
},
include: { child: { select: { familyId: true } } },
});
// 2. Werktage holen
const workingDays = getWorkingDaysOfMonth(targetYear, targetMonth);
// 3. Algorithmus: Wir verteilen die Slots
// Counter für "Gerechtigkeit": wie oft wurde diese Familie schon eingeteilt?
const familyUsageCount: Record<string, number> = {};
const assignmentsToCreate: { childId: string; date: Date }[] = [];
for (const day of workingDays) {
// Alle Verfügbarkeiten für diesen spezifischen Tag
const availForDay = availabilities.filter(
(a) => a.date.getTime() === day.getTime(),
);
if (availForDay.length === 0) {
// Niemand verfügbar -> Bleibt leer ("Unbesetzt")
continue;
}
// Finde die Familie, die am wenigsten oft dran war
// Zufall einbauen bei Gleichstand
const sorted = availForDay.sort((a, b) => {
const countA = familyUsageCount[a.child.familyId] || 0;
const countB = familyUsageCount[b.child.familyId] || 0;
if (countA === countB) {
return Math.random() - 0.5; // Zufallsmischung
}
return countA - countB;
});
const selected = sorted[0];
assignmentsToCreate.push({
childId: selected.childId,
date: day,
});
// Usage-Counter erhöhen
familyUsageCount[selected.child.familyId] =
(familyUsageCount[selected.child.familyId] || 0) + 1;
}
// 4. In der DB speichern
try {
await prisma.$transaction(async (tx) => {
const plan = await tx.notdienstPlan.create({
data: {
kitaId,
year: targetYear,
month: targetMonth,
status: NotdienstPlanStatus.DRAFT,
createdById: session.user.id,
},
await serializableTransaction(async (tx) => {
const existingPlan = await tx.notdienstPlan.findUnique({
where: { kitaId_year_month: { kitaId, year: targetYear, month: targetMonth } },
});
if (existingPlan) throw new UserFacingError("Ein Plan für diesen Monat existiert bereits.");
if (assignmentsToCreate.length > 0) {
await tx.notdienstAssignment.createMany({
data: assignmentsToCreate.map((a) => ({
const [availabilities, workingDays] = await Promise.all([
tx.notdienstAvailability.findMany({
where: {
kitaId,
planId: plan.id,
childId: a.childId,
date: a.date,
})),
child: { kitaId, active: true },
date: {
gte: new Date(Date.UTC(targetYear, targetMonth - 1, 1)),
lt: new Date(Date.UTC(targetYear, targetMonth, 1)),
},
},
include: { child: { select: { familyId: true } } },
}),
loadNotdienstDays(tx, kitaId, targetYear, targetMonth),
]);
const familyUsageCount = new Map<string, number>();
const assignments: { childId: string; date: Date }[] = [];
for (const day of workingDays) {
const candidates = availabilities.filter((a) => a.date.getTime() === day.getTime());
if (!candidates.length) continue;
const minUsage = Math.min(...candidates.map((a) => familyUsageCount.get(a.child.familyId) ?? 0));
const leastUsed = candidates.filter((a) => (familyUsageCount.get(a.child.familyId) ?? 0) === minUsage);
const selected = leastUsed[Math.floor(Math.random() * leastUsed.length)];
assignments.push({ childId: selected.childId, date: day });
familyUsageCount.set(selected.child.familyId, minUsage + 1);
}
const plan = await tx.notdienstPlan.create({
data: { kitaId, year: targetYear, month: targetMonth, status: NotdienstPlanStatus.DRAFT, createdById: session.user.id },
});
if (assignments.length) {
await tx.notdienstAssignment.createMany({
data: assignments.map((a) => ({ ...a, kitaId, planId: plan.id })),
});
}
});
@@ -137,7 +91,7 @@ export async function generatePlanAction(year?: number, month?: number) {
return { success: true };
} catch (error) {
console.error(error);
return { error: "Fehler bei der Generierung des Plans." };
return { error: publicActionError(error, "Fehler bei der Generierung des Plans.") };
}
}
@@ -145,17 +99,35 @@ export async function publishPlanAction(planId: string) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
await prisma.notdienstPlan.update({
where: { id: planId, kitaId },
data: {
status: NotdienstPlanStatus.PUBLISHED,
publishedAt: new Date(),
},
});
revalidatePath("/dashboard/notdienst/plan");
revalidatePath("/dashboard/notdienst");
return { success: true };
try {
await serializableTransaction(async (tx) => {
const plan = await tx.notdienstPlan.findUnique({
where: { id: planId, kitaId },
include: { assignments: { include: { child: { select: { active: true } } } } },
});
if (!plan) throw new UserFacingError("Plan nicht gefunden.");
if (plan.status === NotdienstPlanStatus.PUBLISHED) return;
if (plan.status !== NotdienstPlanStatus.DRAFT) throw new UserFacingError("Nur Entwürfe können veröffentlicht werden.");
const days = await loadNotdienstDays(tx, kitaId, plan.year, plan.month);
const validDates = new Set(days.map((day) => day.getTime()));
if (plan.assignments.some((a) => !validDates.has(a.date.getTime()) || !a.child.active)) {
throw new UserFacingError("Der Entwurf enthält Einteilungen an geschlossenen Tagen, außerhalb des Planungsmonats oder für inaktive Kinder. Bitte zuerst korrigieren.");
}
await tx.notdienstPlan.update({
where: { id: planId, kitaId },
data: { status: NotdienstPlanStatus.PUBLISHED, publishedAt: new Date() },
});
await queueNotdienstPublication(tx, kitaId, planId, plan.year, plan.month);
});
revalidatePath("/dashboard/notdienst/plan");
revalidatePath("/dashboard/notdienst");
revalidatePath("/dashboard/kalender");
const delivery = await deliverDueNotdienstEmails({ kitaId, planId }).catch(() => ({ sent: 0, failed: 0, skipped: 0, deferred: true }));
revalidatePath("/dashboard/notdienst/plan");
return { success: true, ...delivery };
} catch (error) {
return { error: publicActionError(error, "Der Plan konnte nicht veröffentlicht werden.") };
}
}
export async function updateAssignmentAction(
@@ -189,11 +161,19 @@ export async function updateAssignmentAction(
throw new UserFacingError("Plan kann nicht bearbeitet werden.");
}
if (newChildId && (parsedDate.getUTCFullYear() !== plan.year || parsedDate.getUTCMonth() + 1 !== plan.month)) {
throw new UserFacingError("Das Datum gehört nicht zum Planungsmonat.");
}
// Clearing obsolete entries remains possible after a new closure.
if (newChildId) {
const days = await loadNotdienstDays(tx, kitaId, plan.year, plan.month);
if (!days.some((day) => day.getTime() === parsedDate.getTime())) {
throw new UserFacingError("An Wochenenden und Schließtagen kann kein Notdienst eingeteilt werden.");
}
}
if (isPublished) {
const today = new Date();
const todayUtc = new Date(
Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()),
);
const todayUtc = new Date(`${todayKey()}T00:00:00Z`);
if (parsedDate < todayUtc) {
throw new UserFacingError(
"Vergangene Notdienste können nicht geändert werden.",
@@ -202,7 +182,7 @@ export async function updateAssignmentAction(
}
const existingAssignment = await tx.notdienstAssignment.findFirst({
where: { planId, date: parsedDate },
where: { kitaId, planId, date: parsedDate },
include: {
child: {
include: {
@@ -431,118 +411,33 @@ export async function updateKitaNotdienstSettingsAction(
}
}
export async function sendNotdienstRemindersAction(
year: number,
month: number,
) {
export async function sendNotdienstRemindersAction(year: number, month: number) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId;
if (!kitaId) {
return { error: "Kein Mandant zugeordnet." };
}
const kitaId = session.user.kitaId!;
if (!validPlanMonth(year, month)) return { error: "Ungültiger Planungsmonat." };
if (!(await rateLimit(`notdienst-reminders:${kitaId}`, 3, 3600)).allowed) return { error: "Zu viele Erinnerungen. Bitte später erneut versuchen." };
const mailConfigError = getAppEmailConfigError();
if (mailConfigError) {
return { error: mailConfigError };
}
try {
// 1. Get Kita Name
const kita = await prisma.kita.findUnique({
where: { id: kitaId },
select: { name: true },
});
if (!kita) {
return { error: "Kita nicht gefunden." };
}
// 2. Fetch all families in this Kita with at least one active child and retrieve parents
const families = await prisma.family.findMany({
where: {
kitaId,
children: { some: { active: true } },
},
include: {
users: {
where: { role: UserRole.ELTERN, emailVerifiedAt: { not: null }, disabledAt: null, privacyPolicyAcceptedAt: { not: null } },
},
},
});
// 3. For target month, find availability entries
const monthStart = new Date(year, month - 1, 1);
const monthEnd = new Date(year, month, 1);
const respondedFamilies = await prisma.notdienstAvailability.findMany({
where: {
kitaId,
date: {
gte: monthStart,
lt: monthEnd,
},
},
select: {
child: {
select: {
familyId: true,
},
},
},
});
const respondedFamilyIds = new Set(
respondedFamilies.map((a) => a.child.familyId),
);
// 4. Find open families (not responded yet)
const openFamilies = families.filter((f) => !respondedFamilyIds.has(f.id));
if (openFamilies.length === 0) {
return { success: true, count: 0 };
}
// 5. Build emails list and send
const monthDate = new Date(year, month - 1, 1);
const monthLabel = format(monthDate, "MMMM yyyy", { locale: de });
const BASE_URL =
process.env.NEXTAUTH_URL ??
process.env.AUTH_URL ??
"http://localhost:3000";
const link = `${BASE_URL}/dashboard/notdienst`;
let sentCount = 0;
for (const family of openFamilies) {
for (const parent of family.users) {
if (!parent.email) continue;
const result = await sendAppEmail({
to: parent.email,
subject: `Erinnerung: Bitte Verfügbarkeiten für den Notdienst ${monthLabel} eintragen`,
react: createElement(NotdienstAvailabilityReminderEmail, {
parentName: `${parent.firstName} ${parent.lastName}`,
kitaName: kita.name,
monthLabel,
link,
}),
});
if (result.success) {
sentCount++;
} else {
console.error(
`E-Mail konnte nicht an ${parent.email} gesendet werden:`,
result.error,
);
}
}
}
return { success: true, count: sentCount };
const queued = await queueNotdienstReminders(kitaId, year, month);
const delivery = await deliverDueNotdienstEmails({ kitaId, year, month }).catch(() => ({ sent: 0, failed: 0, skipped: 0, deferred: true }));
revalidatePath("/dashboard/notdienst/plan");
return { success: true, queued, count: delivery.sent, ...delivery };
} catch (error) {
console.error("Fehler beim Versenden der Erinnerungen:", error);
return { error: "Fehler beim Versenden der Erinnerungen." };
return { error: publicActionError(error, "Die Erinnerungen konnten nicht vorbereitet werden.") };
}
}
export async function retryNotdienstEmailsAction(year: number, month: number) {
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
const kitaId = session.user.kitaId!;
if (!validPlanMonth(year, month)) return { error: "Ungültiger Planungsmonat." };
if (!(await rateLimit(`notdienst-mail-retry:${kitaId}`, 3, 3600)).allowed) return { error: "Bitte warte vor einem weiteren Versandversuch." };
try {
await prisma.notdienstEmail.updateMany({ where: { kitaId, year, month, failed: true, sentAt: null, skippedAt: null }, data: { nextAttemptAt: new Date() } });
const delivery = await deliverDueNotdienstEmails({ kitaId, year, month });
revalidatePath("/dashboard/notdienst/plan");
return { success: true, ...delivery };
} catch {
return { error: "Der Versand bleibt gespeichert und wird erneut versucht." };
}
}
+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}
+30 -20
View File
@@ -37,6 +37,7 @@ import {
interface PlanViewProps {
planId?: string;
remindersOpen: boolean;
status?: NotdienstPlanStatus;
days: {
date: string;
@@ -45,6 +46,7 @@ interface PlanViewProps {
canAlarm?: boolean;
childId: string | null;
isOriginallyUnassigned?: boolean;
isInvalid?: boolean;
}[];
allChildren: {
id: string;
@@ -56,6 +58,7 @@ interface PlanViewProps {
id: string;
name: string;
childCount: number;
requiredDays: number;
selectedDays: {
iso: string;
label: string;
@@ -75,6 +78,7 @@ interface PlanViewProps {
export function PlanView({
planId,
remindersOpen,
status,
days,
allChildren,
@@ -116,8 +120,9 @@ export function PlanView({
const result = await publishPlanAction(planId);
if ("error" in result && result.error) {
toast.error(result.error as string);
} else {
toast.success("Plan veröffentlicht! Er ist nun fix.");
} else if ("success" in result && result.success) {
if (result.failed || ("deferred" in result && result.deferred)) toast.warning("Plan veröffentlicht. Einige E-Mails stehen noch aus – siehe Versandstatus.");
else toast.success("Plan veröffentlicht. Die Benachrichtigungen wurden vorbereitet.");
}
});
};
@@ -127,10 +132,9 @@ export function PlanView({
const result = await sendNotdienstRemindersAction(year, month);
if ("error" in result && result.error) {
toast.error(result.error as string);
} else if (result.success) {
toast.success(
`${result.count} Erinnerungs-E-Mail(s) erfolgreich versendet.`,
);
} else if ("success" in result && result.success) {
if (result.failed || ("deferred" in result && result.deferred)) toast.warning(`${result.count} E-Mails versendet. Offene Nachrichten bleiben gespeichert – siehe Versandstatus.`);
else toast.success(`${result.count} E-Mails versendet. Bereits heute erinnerte Haushalte erhalten keine zweite Erinnerung.`);
}
});
};
@@ -153,7 +157,7 @@ export function PlanView({
const valueToSet = newChildId === "empty" ? null : newChildId;
startTransition(async () => {
const result = await updateAssignmentAction(planId, date, valueToSet);
const result = await updateAssignmentAction(planId, date.slice(0, 10), valueToSet);
if ("error" in result && result.error) {
toast.error(result.error as string);
} else {
@@ -162,7 +166,7 @@ export function PlanView({
});
};
if (!planId && respondedFamilies === 0) {
if (!planId && totalAvailabilitySlots === 0) {
return (
<Card className="border-dashed">
<div className="flex min-h-[280px] flex-col items-center justify-center p-8 text-center">
@@ -183,7 +187,7 @@ export function PlanView({
<Button
variant="ghost"
onClick={handleSendReminders}
disabled={isPending}
disabled={isPending || !remindersOpen}
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
@@ -211,7 +215,7 @@ export function PlanView({
</h2>
<p className="mt-1 text-[13.5px] leading-5 text-[var(--ink-soft)]">
{respondedFamilies} von {totalFamilies} Familien haben
geantwortet · noch {openFamilies} offen
vollständig geantwortet · noch {openFamilies} offen
</p>
</div>
<span className="text-sm font-semibold tabular-nums text-[var(--ink-soft)]">
@@ -244,7 +248,7 @@ export function PlanView({
{family.childCount === 1 ? "" : "er"}
</p>
</div>
<FamilyStatusPill selectedDays={family.selectedDays.length} />
<FamilyStatusPill selectedDays={family.selectedDays.length} requiredDays={family.requiredDays} />
<div className="pointer-events-none absolute right-6 top-10 z-20 hidden max-w-xs rounded-lg bg-[oklch(0.22_0.015_45)] px-3 py-2 text-xs leading-5 text-white shadow-lg group-hover:block">
{family.selectedDays.length > 0
? family.selectedDays.map((day) => day.label).join(" · ")
@@ -258,7 +262,7 @@ export function PlanView({
<Button
variant="ghost"
onClick={handleSendReminders}
disabled={isPending || openFamilies === 0}
disabled={isPending || openFamilies === 0 || !remindersOpen}
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
@@ -315,7 +319,7 @@ export function PlanView({
</h3>
<p className="mt-1 text-[13.5px] text-[var(--ink-soft)]">
Verteilt {requiredDays} Notdiensttage fair auf{" "}
{respondedFamilies} Familien.
die verfügbaren Familien.
</p>
<details className="mt-3 text-xs text-[var(--ink-soft)]">
<summary className="cursor-pointer font-semibold text-[var(--accent-deep)]">
@@ -447,7 +451,7 @@ export function PlanView({
);
}
function FamilyStatusPill({ selectedDays }: { selectedDays: number }) {
function FamilyStatusPill({ selectedDays, requiredDays }: { selectedDays: number; requiredDays: number }) {
if (selectedDays === 0) {
return (
<span className="rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--warn)]">
@@ -457,8 +461,8 @@ function FamilyStatusPill({ selectedDays }: { selectedDays: number }) {
}
return (
<span className="rounded-full border border-[var(--good)] bg-[var(--good-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--good)]">
{selectedDays} Tage
<span className={selectedDays >= requiredDays ? "rounded-full border border-[var(--good)] bg-[var(--good-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--good)]" : "rounded-full border border-[var(--warn)] bg-[var(--warn-soft)] px-2.5 py-1 text-[11px] font-semibold text-[var(--warn)]"}>
{selectedDays} / {requiredDays} Tage
</span>
);
}
@@ -548,7 +552,7 @@ function PlanPreviewTable({
{format(dateObj, "EEEE", { locale: de })}
</div>
<div className="text-[var(--ink)]">
{assignedChild ? assignedChild.parentName : "Unbesetzt"}
{assignedChild ? assignedChild.parentName : day.childId ? "Inaktives Kind" : "Unbesetzt"}
{assignedChild && (
<span className="ml-1 text-[var(--ink-muted)]">
({assignedChild.name})
@@ -572,8 +576,8 @@ function PlanPreviewTable({
</div>
<div className="italic text-[var(--ink-faint)]">Noch offen</div>
<div>
<Badge variant={assignedChild ? "success" : "destructive"}>
{assignedChild ? "Besetzt" : "Offen"}
<Badge variant={day.isInvalid ? "warning" : assignedChild ? "success" : "destructive"}>
{day.isInvalid ? "Bitte korrigieren" : assignedChild ? "Besetzt" : "Offen"}
</Badge>
</div>
</div>
@@ -649,18 +653,24 @@ function ManualAssignmentTable({
}
>
<option value="empty">-- Unbesetzt --</option>
{day.childId && !allChildren.some((child) => child.id === day.childId) ? (
<option value={day.childId} disabled>Inaktives Kind – bitte Einteilung entfernen</option>
) : null}
{allChildren.map((child) => {
const famId = child.familyId;
const count = famId
? (familyAssignmentCounts.get(famId) ?? 0)
: 0;
return (
<option key={child.id} value={child.id}>
<option key={child.id} value={child.id} disabled={day.isInvalid}>
{child.parentName} ({child.name}) — {count}x
</option>
);
})}
</select>
{day.isInvalid ? (
<span className="text-xs text-[var(--danger)]">Einteilung nicht mehr gültig – bitte entfernen.</span>
) : null}
{!isDraft && (
<span className="text-[11px] font-semibold text-[oklch(0.60_0.18_45)] bg-[oklch(0.95_0.02_45)] border border-[oklch(0.85_0.05_45)] px-2 py-0.5 rounded-md shrink-0">
Veröffentlicht