feat: show family assignment counts in notdienst planning dropdown and support editing unassigned days in published plans

This commit is contained in:
t.indorf
2026-05-21 14:29:13 +02:00
parent f6acb3d271
commit e9595fab21
9 changed files with 455 additions and 31 deletions
+89 -5
View File
@@ -11,6 +11,7 @@ import { prisma } from "@/lib/prisma";
import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { NotdienstAvailabilityReminderEmail } from "@/emails/NotdienstAvailabilityReminderEmail";
import { NotdienstAssignmentEmail } from "@/emails/NotdienstAssignmentEmail";
// =====================================================================
// /dashboard/notdienst/plan · Server Actions
@@ -159,14 +160,40 @@ export async function updateAssignmentAction(
const parsedDate = new Date(Date.UTC(yr, mo - 1, dy));
try {
await prisma.$transaction(async (tx) => {
// Prüfen, ob Plan noch DRAFT ist
const { shouldSendEmails, assignedChildInfo } = await prisma.$transaction(async (tx) => {
let txShouldSendEmails = false;
let txAssignedChildInfo: {
parentEmails: string[];
parentNames: string[];
dateLabel: string;
} | null = null;
const plan = await tx.notdienstPlan.findUnique({
where: { id: planId, kitaId },
});
if (!plan || plan.status !== NotdienstPlanStatus.DRAFT) {
throw new Error("Plan kann nicht mehr bearbeitet werden.");
if (!plan) {
throw new Error("Plan nicht gefunden.");
}
const isDraft = plan.status === NotdienstPlanStatus.DRAFT;
const isPublished = plan.status === NotdienstPlanStatus.PUBLISHED;
if (!isDraft && !isPublished) {
throw new Error("Plan kann nicht bearbeitet werden.");
}
// Wenn der Plan bereits veröffentlicht ist, dürfen nur unbesetzte Termine geändert werden.
// Ein Termin gilt als "unbesetzt", wenn am Veröffentlichungstag kein Assignment existierte
// bzw. das Assignment erst nach dem Veröffentlichungs-Zeitpunkt erstellt wurde.
const existingAssignment = await tx.notdienstAssignment.findFirst({
where: { planId, date: parsedDate },
});
if (isPublished && existingAssignment) {
if (!plan.publishedAt || existingAssignment.createdAt <= plan.publishedAt) {
throw new Error("In einem bereits veröffentlichten Plan können nur unbesetzte Termine geändert werden.");
}
}
// Altes Assignment für diesen Tag löschen
@@ -182,7 +209,16 @@ export async function updateAssignmentAction(
kitaId,
active: true,
},
select: { id: true },
include: {
family: {
include: {
users: {
where: { role: UserRole.ELTERN },
select: { email: true, firstName: true, lastName: true },
},
},
},
},
});
if (!child) {
@@ -197,9 +233,57 @@ export async function updateAssignmentAction(
date: parsedDate,
},
});
// Wenn der Plan bereits veröffentlicht ist, bereiten wir E-Mail-Benachrichtigungen vor
if (isPublished) {
const parents = child.family.users.filter((u) => !!u.email);
if (parents.length > 0) {
const localFormatDate = new Date(yr, mo - 1, dy);
txAssignedChildInfo = {
parentEmails: parents.map((p) => p.email!),
parentNames: parents.map((p) => `${p.firstName} ${p.lastName}`),
dateLabel: format(localFormatDate, "EEEE, dd. MMMM yyyy", { locale: de }),
};
txShouldSendEmails = true;
}
}
}
return {
shouldSendEmails: txShouldSendEmails,
assignedChildInfo: txAssignedChildInfo,
};
});
// E-Mails außerhalb der DB-Transaktion senden
if (shouldSendEmails && assignedChildInfo) {
const kita = await prisma.kita.findUnique({
where: { id: kitaId },
select: { name: true },
});
if (kita) {
const BASE_URL = process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
const link = `${BASE_URL}/dashboard/notdienst`;
for (let i = 0; i < assignedChildInfo.parentEmails.length; i++) {
const email = assignedChildInfo.parentEmails[i];
const name = assignedChildInfo.parentNames[i];
await sendAppEmail({
to: email,
subject: `Notdienst-Einteilung am ${assignedChildInfo.dateLabel}`,
react: createElement(NotdienstAssignmentEmail, {
parentName: name,
kitaName: kita.name,
dateLabel: assignedChildInfo.dateLabel,
link,
}),
});
}
}
}
revalidatePath("/dashboard/notdienst/plan");
return { success: true };
} catch (error) {
@@ -41,11 +41,13 @@ export default async function PlanungsZentralePage({
select: {
id: true,
status: true,
publishedAt: true,
assignments: {
select: {
id: true,
childId: true,
date: true,
createdAt: true,
},
},
},
@@ -111,6 +113,7 @@ export default async function PlanungsZentralePage({
id: true,
firstName: true,
lastName: true,
familyId: true,
family: {
select: {
name: true,
@@ -135,6 +138,7 @@ export default async function PlanungsZentralePage({
id: c.id,
name: `${c.firstName} ${c.lastName}`,
parentName,
familyId: c.familyId,
};
});
@@ -177,10 +181,15 @@ export default async function PlanungsZentralePage({
const assignment = plan?.assignments.find(
(a) => a.date.getTime() === day.getTime()
);
const isOriginallyUnassigned =
!assignment ||
(plan?.publishedAt && assignment.createdAt > plan.publishedAt);
return {
date: day.toISOString(),
assignmentId: assignment?.id || null,
childId: assignment?.childId || null,
isOriginallyUnassigned: !!isOriginallyUnassigned,
};
});
+56 -18
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useTransition } from "react";
import { useMemo, useState, useTransition } from "react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { NotdienstPlanStatus } from "@prisma/client";
@@ -41,11 +41,13 @@ interface PlanViewProps {
date: string;
assignmentId: string | null;
childId: string | null;
isOriginallyUnassigned?: boolean;
}[];
allChildren: {
id: string;
name: string;
parentName: string;
familyId: string;
}[];
familyStatuses: {
id: string;
@@ -140,7 +142,7 @@ export function PlanView({
};
const handleAssignmentChange = (date: string, newChildId: string) => {
if (!planId || status !== "DRAFT") return;
if (!planId || (status !== "DRAFT" && status !== "PUBLISHED")) return;
const valueToSet = newChildId === "empty" ? null : newChildId;
startTransition(async () => {
@@ -567,6 +569,31 @@ function ManualAssignmentTable({
isPending: boolean;
onAssignmentChange: (date: string, newChildId: string) => void;
}) {
// Map childId to familyId for quick lookup
const childFamilyMap = useMemo(() => {
const map = new Map<string, string>();
allChildren.forEach((child) => {
if (child.familyId) {
map.set(child.id, child.familyId);
}
});
return map;
}, [allChildren]);
// Compute family assignment counts in real-time based on current selections
const familyAssignmentCounts = useMemo(() => {
const counts = new Map<string, number>();
days.forEach((day) => {
if (day.childId) {
const famId = childFamilyMap.get(day.childId);
if (famId) {
counts.set(famId, (counts.get(famId) ?? 0) + 1);
}
}
});
return counts;
}, [days, childFamilyMap]);
return (
<Card className="overflow-hidden p-0">
<div className="grid grid-cols-[1fr_2fr] border-b border-[var(--hairline-soft)] bg-[var(--surface-2)] p-4 text-[11px] font-semibold uppercase text-[var(--ink-muted)] [letter-spacing:0.12em]">
@@ -590,22 +617,33 @@ function ManualAssignmentTable({
{format(dateObj, "EE, dd.MM.", { locale: de })}
</div>
<div>
{isDraft ? (
<select
className="w-full max-w-sm rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-1.5 text-sm text-[var(--ink)] ring-offset-[var(--surface)] hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
value={day.childId || "empty"}
disabled={isPending}
onChange={(event) =>
onAssignmentChange(day.date, event.target.value)
}
>
<option value="empty">-- Unbesetzt --</option>
{allChildren.map((child) => (
<option key={child.id} value={child.id}>
{child.parentName} ({child.name})
</option>
))}
</select>
{isDraft || day.isOriginallyUnassigned ? (
<div className="flex flex-wrap items-center gap-2">
<select
className="w-full max-w-sm rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-1.5 text-sm text-[var(--ink)] ring-offset-[var(--surface)] hover:border-[var(--accent-mid)] focus-visible:border-[var(--accent)] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--focus-ring)]"
value={day.childId || "empty"}
disabled={isPending}
onChange={(event) =>
onAssignmentChange(day.date, event.target.value)
}
>
<option value="empty">-- Unbesetzt --</option>
{allChildren.map((child) => {
const famId = child.familyId;
const count = famId ? (familyAssignmentCounts.get(famId) ?? 0) : 0;
return (
<option key={child.id} value={child.id}>
{child.parentName} ({child.name}) {count}x
</option>
);
})}
</select>
{!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">
Nachträglich änderbar
</span>
)}
</div>
) : assignedChild ? (
<span className="text-sm text-[var(--ink)]">
{assignedChild.parentName}{" "}