feat: implement emergency duty (Notdienst) swapping for parents
This commit is contained in:
@@ -11,7 +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";
|
||||
import { NotdienstAssignmentChangeEmail } from "@/emails/NotdienstAssignmentChangeEmail";
|
||||
|
||||
// =====================================================================
|
||||
// /dashboard/notdienst/plan · Server Actions
|
||||
@@ -160,14 +160,7 @@ export async function updateAssignmentAction(
|
||||
const parsedDate = new Date(Date.UTC(yr, mo - 1, dy));
|
||||
|
||||
try {
|
||||
const { shouldSendEmails, assignedChildInfo } = await prisma.$transaction(async (tx) => {
|
||||
let txShouldSendEmails = false;
|
||||
let txAssignedChildInfo: {
|
||||
parentEmails: string[];
|
||||
parentNames: string[];
|
||||
dateLabel: string;
|
||||
} | null = null;
|
||||
|
||||
const notifications = await prisma.$transaction(async (tx) => {
|
||||
const plan = await tx.notdienstPlan.findUnique({
|
||||
where: { id: planId, kitaId },
|
||||
});
|
||||
@@ -183,27 +176,43 @@ export async function updateAssignmentAction(
|
||||
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.");
|
||||
if (isPublished) {
|
||||
const today = new Date();
|
||||
const todayUtc = new Date(Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()));
|
||||
if (parsedDate < todayUtc) {
|
||||
throw new Error("Vergangene Notdienste können nicht geändert werden.");
|
||||
}
|
||||
}
|
||||
|
||||
// Altes Assignment für diesen Tag löschen
|
||||
await tx.notdienstAssignment.deleteMany({
|
||||
const existingAssignment = await tx.notdienstAssignment.findFirst({
|
||||
where: { planId, date: parsedDate },
|
||||
include: {
|
||||
child: {
|
||||
include: {
|
||||
family: {
|
||||
include: {
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN },
|
||||
select: { email: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Neues anlegen, falls ausgewählt
|
||||
let newChild:
|
||||
| {
|
||||
id: string;
|
||||
family: {
|
||||
users: { email: string; firstName: string; lastName: string }[];
|
||||
};
|
||||
}
|
||||
| null = null;
|
||||
|
||||
if (newChildId) {
|
||||
const child = await tx.child.findFirst({
|
||||
newChild = await tx.child.findFirst({
|
||||
where: {
|
||||
id: newChildId,
|
||||
kitaId,
|
||||
@@ -221,10 +230,27 @@ export async function updateAssignmentAction(
|
||||
},
|
||||
});
|
||||
|
||||
if (!child) {
|
||||
if (!newChild) {
|
||||
throw new Error("Kind gehört nicht zu dieser Kita.");
|
||||
}
|
||||
}
|
||||
|
||||
if (existingAssignment?.childId === newChildId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!newChildId) {
|
||||
if (existingAssignment) {
|
||||
await tx.notdienstAssignment.delete({
|
||||
where: { id: existingAssignment.id },
|
||||
});
|
||||
}
|
||||
} else if (existingAssignment) {
|
||||
await tx.notdienstAssignment.update({
|
||||
where: { id: existingAssignment.id },
|
||||
data: { childId: newChildId },
|
||||
});
|
||||
} else {
|
||||
await tx.notdienstAssignment.create({
|
||||
data: {
|
||||
kitaId,
|
||||
@@ -233,30 +259,75 @@ 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;
|
||||
}
|
||||
if (existingAssignment) {
|
||||
await tx.notdienstSwapRequest.updateMany({
|
||||
where: {
|
||||
kitaId,
|
||||
status: "PENDING",
|
||||
OR: [
|
||||
{ targetAssignmentId: existingAssignment.id },
|
||||
{ offeredAssignmentId: existingAssignment.id },
|
||||
],
|
||||
},
|
||||
data: {
|
||||
status: "CANCELLED",
|
||||
respondedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!isPublished) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dateLabel = format(new Date(yr, mo - 1, dy), "EEEE, dd. MMMM yyyy", { locale: de });
|
||||
const messages: {
|
||||
parentEmails: string[];
|
||||
parentNames: string[];
|
||||
subject: string;
|
||||
heading: string;
|
||||
preview: string;
|
||||
body: string;
|
||||
dateLabel: string;
|
||||
}[] = [];
|
||||
|
||||
if (existingAssignment) {
|
||||
const oldParents = existingAssignment.child.family.users.filter((u) => !!u.email);
|
||||
if (oldParents.length > 0) {
|
||||
messages.push({
|
||||
parentEmails: oldParents.map((p) => p.email),
|
||||
parentNames: oldParents.map((p) => `${p.firstName} ${p.lastName}`),
|
||||
subject: `Notdienst-Einteilung geändert am ${dateLabel}`,
|
||||
heading: "Notdienst wurde geändert",
|
||||
preview: `Deine Notdienst-Einteilung am ${dateLabel} wurde geändert.`,
|
||||
body: "Du bist für diesen Notdienst nicht mehr eingeteilt.",
|
||||
dateLabel,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
shouldSendEmails: txShouldSendEmails,
|
||||
assignedChildInfo: txAssignedChildInfo,
|
||||
};
|
||||
if (newChild && existingAssignment?.childId !== newChild.id) {
|
||||
const newParents = newChild.family.users.filter((u) => !!u.email);
|
||||
if (newParents.length > 0) {
|
||||
messages.push({
|
||||
parentEmails: newParents.map((p) => p.email),
|
||||
parentNames: newParents.map((p) => `${p.firstName} ${p.lastName}`),
|
||||
subject: `Notdienst-Einteilung am ${dateLabel}`,
|
||||
heading: "Neuer Notdienst-Termin zugewiesen",
|
||||
preview: `Du wurdest für den Notdienst am ${dateLabel} eingeteilt.`,
|
||||
body: "Du wurdest für folgenden Tag für den Notdienst eingeteilt.",
|
||||
dateLabel,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
});
|
||||
|
||||
// E-Mails außerhalb der DB-Transaktion senden
|
||||
if (shouldSendEmails && assignedChildInfo) {
|
||||
if (notifications.length > 0) {
|
||||
const kita = await prisma.kita.findUnique({
|
||||
where: { id: kitaId },
|
||||
select: { name: true },
|
||||
@@ -266,25 +337,30 @@ export async function updateAssignmentAction(
|
||||
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];
|
||||
for (const notification of notifications) {
|
||||
for (let i = 0; i < notification.parentEmails.length; i++) {
|
||||
const email = notification.parentEmails[i];
|
||||
const name = notification.parentNames[i];
|
||||
|
||||
await sendAppEmail({
|
||||
to: email,
|
||||
subject: `Notdienst-Einteilung am ${assignedChildInfo.dateLabel}`,
|
||||
react: createElement(NotdienstAssignmentEmail, {
|
||||
parentName: name,
|
||||
kitaName: kita.name,
|
||||
dateLabel: assignedChildInfo.dateLabel,
|
||||
link,
|
||||
}),
|
||||
});
|
||||
await sendAppEmail({
|
||||
to: email,
|
||||
subject: notification.subject,
|
||||
react: createElement(NotdienstAssignmentChangeEmail, {
|
||||
parentName: name,
|
||||
heading: notification.heading,
|
||||
preview: notification.preview,
|
||||
body: notification.body,
|
||||
dateLabel: notification.dateLabel,
|
||||
link,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath("/dashboard/notdienst/plan");
|
||||
revalidatePath("/dashboard/kalender");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -462,4 +538,3 @@ export async function resetPlanAction(planId: string) {
|
||||
return { error: error instanceof Error ? error.message : "Fehler beim Zurücksetzen des Plans." };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -386,7 +386,7 @@ export function PlanView({
|
||||
<span className="text-sm text-[var(--ink-soft)]">
|
||||
{isDraft
|
||||
? "Du kannst die Liste jetzt manuell anpassen."
|
||||
: "Dieser Plan ist verbindlich."}
|
||||
: "Dieser Plan ist veröffentlicht und kann bei Änderungen nachbearbeitet werden."}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -524,7 +524,6 @@ function PlanPreviewTable({
|
||||
const assignedChild = allChildren.find(
|
||||
(child) => child.id === day.childId,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day.date}
|
||||
@@ -604,9 +603,6 @@ function ManualAssignmentTable({
|
||||
{days.map((day) => {
|
||||
const [yr, mo, dy] = day.date.slice(0, 10).split("-").map(Number);
|
||||
const dateObj = new Date(yr, mo - 1, dy);
|
||||
const assignedChild = allChildren.find(
|
||||
(child) => child.id === day.childId,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -616,44 +612,29 @@ function ManualAssignmentTable({
|
||||
<div className="font-medium text-[var(--ink)]">
|
||||
{format(dateObj, "EE, dd.MM.", { locale: de })}
|
||||
</div>
|
||||
<div>
|
||||
{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}{" "}
|
||||
<span className="text-[var(--ink-muted)]">
|
||||
({assignedChild.name})
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-[var(--danger)]">
|
||||
Unbesetzt!
|
||||
<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">
|
||||
Veröffentlicht
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user