From e9595fab2199dc7e410fe07a9f8a30f11be56d94 Mon Sep 17 00:00:00 2001 From: "t.indorf" Date: Thu, 21 May 2026 14:29:13 +0200 Subject: [PATCH] feat: show family assignment counts in notdienst planning dropdown and support editing unassigned days in published plans --- .../admin/abwesenheiten/absence-format.ts | 28 +++ .../_components/calendar-overview-client.tsx | 5 - src/app/dashboard/notdienst/plan/actions.ts | 94 ++++++++- src/app/dashboard/notdienst/plan/page.tsx | 9 + .../dashboard/notdienst/plan/plan-view.tsx | 74 +++++-- src/app/login/login-form.tsx | 60 +++++- src/app/login/page.tsx | 14 +- src/emails/NotdienstAssignmentEmail.tsx | 189 ++++++++++++++++++ src/lib/mail.ts | 13 ++ 9 files changed, 455 insertions(+), 31 deletions(-) create mode 100644 src/app/dashboard/admin/abwesenheiten/absence-format.ts create mode 100644 src/emails/NotdienstAssignmentEmail.tsx diff --git a/src/app/dashboard/admin/abwesenheiten/absence-format.ts b/src/app/dashboard/admin/abwesenheiten/absence-format.ts new file mode 100644 index 0000000..7d6d07f --- /dev/null +++ b/src/app/dashboard/admin/abwesenheiten/absence-format.ts @@ -0,0 +1,28 @@ +import { AbsenceReason } from "@prisma/client"; + +export function reasonLabel(reason: AbsenceReason) { + if (reason === AbsenceReason.ILLNESS) return "Krank"; + if (reason === AbsenceReason.VACATION) return "Urlaub"; + return "Sonstiges"; +} + +export function reasonColor(reason: AbsenceReason) { + if (reason === AbsenceReason.ILLNESS) return "var(--danger)"; + if (reason === AbsenceReason.VACATION) return "var(--warn)"; + return "var(--accent)"; +} + +export function formatDateRange(startDate: string, endDate: string) { + const start = new Date(`${startDate}T00:00:00`); + const end = new Date(`${endDate}T00:00:00`); + const startLabel = start.toLocaleDateString("de-DE", { + day: "2-digit", + month: "2-digit", + }); + const endLabel = end.toLocaleDateString("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); + return startDate === endDate ? endLabel : `${startLabel} - ${endLabel}`; +} diff --git a/src/app/dashboard/kalender/_components/calendar-overview-client.tsx b/src/app/dashboard/kalender/_components/calendar-overview-client.tsx index 7ad835e..21df8d0 100644 --- a/src/app/dashboard/kalender/_components/calendar-overview-client.tsx +++ b/src/app/dashboard/kalender/_components/calendar-overview-client.tsx @@ -657,11 +657,6 @@ function MonthView({ > {format(day, "d")} - {dayEvents.length > 0 && ( - - {dayEvents.length} {dayEvents.length === 1 ? "Eintrag" : "Einträge"} - - )} {/* Event indicators/snippets */} diff --git a/src/app/dashboard/notdienst/plan/actions.ts b/src/app/dashboard/notdienst/plan/actions.ts index 11288ca..9af8398 100644 --- a/src/app/dashboard/notdienst/plan/actions.ts +++ b/src/app/dashboard/notdienst/plan/actions.ts @@ -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) { diff --git a/src/app/dashboard/notdienst/plan/page.tsx b/src/app/dashboard/notdienst/plan/page.tsx index ba15484..fe0852a 100644 --- a/src/app/dashboard/notdienst/plan/page.tsx +++ b/src/app/dashboard/notdienst/plan/page.tsx @@ -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, }; }); diff --git a/src/app/dashboard/notdienst/plan/plan-view.tsx b/src/app/dashboard/notdienst/plan/plan-view.tsx index fadaf6a..3854908 100644 --- a/src/app/dashboard/notdienst/plan/plan-view.tsx +++ b/src/app/dashboard/notdienst/plan/plan-view.tsx @@ -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(); + 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(); + 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 (
@@ -590,22 +617,33 @@ function ManualAssignmentTable({ {format(dateObj, "EE, dd.MM.", { locale: de })}
- {isDraft ? ( - + {isDraft || day.isOriginallyUnassigned ? ( +
+ + {!isDraft && ( + + Nachträglich änderbar + + )} +
) : assignedChild ? ( {assignedChild.parentName}{" "} diff --git a/src/app/login/login-form.tsx b/src/app/login/login-form.tsx index e299c65..5b885f8 100644 --- a/src/app/login/login-form.tsx +++ b/src/app/login/login-form.tsx @@ -1,6 +1,6 @@ "use client"; -import { useActionState } from "react"; +import { useActionState, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -10,8 +10,24 @@ import { loginAction, type LoginState } from "./actions"; const initialState: LoginState = {}; -export function LoginForm({ callbackUrl }: { callbackUrl: string }) { +export function LoginForm({ + callbackUrl, + devAdminEmail, + devAdminPassword, + devParentEmail, + devParentPassword, +}: { + callbackUrl: string; + devAdminEmail?: string; + devAdminPassword?: string; + devParentEmail?: string; + devParentPassword?: string; +}) { const [state, formAction, pending] = useActionState(loginAction, initialState); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + + const showDevHelpers = !!(devAdminEmail || devParentEmail); return (
@@ -26,6 +42,8 @@ export function LoginForm({ callbackUrl }: { callbackUrl: string }) { autoComplete="email" required aria-invalid={!!state.errors?.email} + value={email} + onChange={(e) => setEmail(e.target.value)} /> {state.errors?.email?.[0] && (

{state.errors.email[0]}

@@ -41,6 +59,8 @@ export function LoginForm({ callbackUrl }: { callbackUrl: string }) { autoComplete="current-password" required aria-invalid={!!state.errors?.password} + value={password} + onChange={(e) => setPassword(e.target.value)} /> {state.errors?.password?.[0] && (

{state.errors.password[0]}

@@ -56,6 +76,42 @@ export function LoginForm({ callbackUrl }: { callbackUrl: string }) { + + {showDevHelpers && ( +
+

Dev-Schnellzugriff (nur lokal):

+
+ {devAdminEmail && ( + + )} + {devParentEmail && ( + + )} +
+
+ )}
); } diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 3ada0b6..0fac909 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -36,6 +36,12 @@ export default async function LoginPage({ searchParams }: LoginPageProps) { const resolvedSearchParams = await searchParams; const callbackUrl = getSafeCallbackUrl(resolvedSearchParams?.callbackUrl); + const isDev = process.env.NODE_ENV === "development"; + const devAdminEmail = isDev ? process.env.DEV_LOGIN_ADMIN_EMAIL : undefined; + const devAdminPassword = isDev ? process.env.DEV_LOGIN_ADMIN_PASSWORD : undefined; + const devParentEmail = isDev ? process.env.DEV_LOGIN_PARENT_EMAIL : undefined; + const devParentPassword = isDev ? process.env.DEV_LOGIN_PARENT_PASSWORD : undefined; + return (
@@ -44,7 +50,13 @@ export default async function LoginPage({ searchParams }: LoginPageProps) { Willkommen zurück. - +

Noch keinen Account?{" "} diff --git a/src/emails/NotdienstAssignmentEmail.tsx b/src/emails/NotdienstAssignmentEmail.tsx new file mode 100644 index 0000000..56841b9 --- /dev/null +++ b/src/emails/NotdienstAssignmentEmail.tsx @@ -0,0 +1,189 @@ +import { + Body, + Button, + Container, + Head, + Heading, + Html, + Preview, + Section, + Text, +} from "@react-email/components"; + +import { baseBodyStyle, colors, fontStack } from "./_styles"; + +type NotdienstAssignmentEmailProps = { + parentName: string; + kitaName: string; + dateLabel: string; + link: string; +}; + +export function NotdienstAssignmentEmail({ + parentName, + kitaName, + dateLabel, + link, +}: NotdienstAssignmentEmailProps) { + return ( + + + Aktualisierung Notdienst-Plan: Zuteilung für {dateLabel} + + +

+
+ Kita-Planer +
+ Notdienst-Einteilung + Neuer Notdienst-Termin zugewiesen + + Hallo {parentName}, + + + + in der Kita {kitaName} gab es eine Änderung an der Notdienst-Planung. + Du wurdest für folgenden Tag für den Notdienst eingeteilt: + + +
+ {dateLabel} +
+ + + Bitte merke dir diesen Termin vor. Du kannst den vollständigen Plan auch jederzeit online im Kita-Planer einsehen. + + + + + + Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser: + + {link} +
+ + + Diese Nachricht wurde automatisch vom Kita-Planer deiner Kita versendet. + + + + + ); +} + +const styles = { + body: baseBodyStyle, + container: { + width: "100%", + maxWidth: "600px", + margin: "0 auto", + padding: "32px 20px", + fontFamily: fontStack, + }, + card: { + padding: "0 28px 28px", + backgroundColor: colors.cardBg, + borderRadius: "10px", + border: `1px solid ${colors.border}`, + boxShadow: "0 10px 26px rgba(31, 59, 45, 0.08)", + overflow: "hidden", + fontFamily: fontStack, + }, + brandBar: { + margin: "0 -28px 28px", + padding: "14px 28px", + backgroundColor: colors.brandGreen, + fontFamily: fontStack, + }, + brand: { + margin: 0, + color: colors.eyebrow, + fontSize: "12px", + fontWeight: 800, + letterSpacing: "0.08em", + textTransform: "uppercase" as const, + fontFamily: fontStack, + }, + kicker: { + margin: "0 0 10px", + color: colors.brandGreenAccent, + fontSize: "12px", + fontWeight: 800, + letterSpacing: "0.06em", + textTransform: "uppercase" as const, + fontFamily: fontStack, + }, + heading: { + margin: "0 0 14px", + color: colors.textPrimary, + fontSize: "28px", + lineHeight: "34px", + fontWeight: 800, + fontFamily: fontStack, + }, + lead: { + margin: "0 0 22px", + color: colors.textBody, + fontSize: "16px", + lineHeight: "24px", + fontFamily: fontStack, + }, + text: { + margin: "0 0 24px", + color: colors.textBody, + fontSize: "15px", + lineHeight: "24px", + fontFamily: fontStack, + }, + dateBadge: { + padding: "16px", + backgroundColor: "#f7f5ef", + borderLeft: `4px solid ${colors.brandGreen}`, + borderRadius: "4px", + margin: "0 0 24px", + fontFamily: fontStack, + }, + dateText: { + margin: 0, + fontSize: "18px", + fontWeight: 700, + color: colors.textPrimary, + fontFamily: fontStack, + }, + button: { + display: "inline-block", + padding: "14px 20px", + backgroundColor: colors.brandGreen, + color: "#ffffff", + borderRadius: "6px", + fontSize: "15px", + fontWeight: 700, + textDecoration: "none", + fontFamily: fontStack, + }, + fallbackText: { + margin: "28px 0 8px", + color: colors.textMuted, + fontSize: "13px", + lineHeight: "20px", + fontFamily: fontStack, + }, + linkText: { + margin: 0, + color: colors.link, + fontSize: "13px", + lineHeight: "20px", + wordBreak: "break-all" as const, + fontFamily: fontStack, + }, + footer: { + margin: "20px 0 0", + color: colors.textFaint, + fontSize: "12px", + lineHeight: "18px", + textAlign: "center" as const, + fontFamily: fontStack, + }, +}; diff --git a/src/lib/mail.ts b/src/lib/mail.ts index 8b5d8b5..4758529 100644 --- a/src/lib/mail.ts +++ b/src/lib/mail.ts @@ -16,6 +16,9 @@ type SendAppEmailResult = | { success: false; error: string }; export function getAppEmailConfigError() { + if (process.env.NODE_ENV === "development") { + return null; + } if (!process.env.RESEND_API_KEY) { return "RESEND_API_KEY ist nicht konfiguriert."; } @@ -35,6 +38,16 @@ export async function sendAppEmail({ replyTo, }: SendAppEmailInput): Promise { if (!process.env.RESEND_API_KEY) { + if (process.env.NODE_ENV === "development") { + console.log("\n✉️ [DEV EMAIL SIMULATION]"); + console.log(`From: ${from || "not-configured"}`); + console.log(`To: ${Array.isArray(to) ? to.join(", ") : to}`); + console.log(`Subject: ${subject}`); + console.log("-----------------------------------------"); + console.log("Email body react element is rendered."); + console.log("=========================================\n"); + return { success: true, id: "dev-simulated-id" }; + } return { success: false, error: "RESEND_API_KEY ist nicht konfiguriert.",