"use server"; import { createElement } from "react"; import { revalidatePath } from "next/cache"; import { NotdienstPlanStatus, UserRole } from "@prisma/client"; import { format } from "date-fns"; 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 { NotdienstAssignmentEmail } from "@/emails/NotdienstAssignmentEmail"; // ===================================================================== // /dashboard/notdienst/plan · Server Actions // ===================================================================== export async function generatePlanAction(year?: number, month?: number) { const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]); const kitaId = session.user.kitaId!; let targetYear: number; let targetMonth: number; if (year !== undefined && month !== undefined) { targetYear = year; targetMonth = month; } else { const data = getTargetMonthData(); targetYear = data.targetYear; targetMonth = data.targetMonth; } // 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 = {}; 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, }, }); if (assignmentsToCreate.length > 0) { await tx.notdienstAssignment.createMany({ data: assignmentsToCreate.map((a) => ({ kitaId, planId: plan.id, childId: a.childId, date: a.date, })), }); } }); revalidatePath("/dashboard/notdienst/plan"); return { success: true }; } catch (error) { console.error(error); return { error: "Fehler bei der Generierung des Plans." }; } } 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 }; } export async function updateAssignmentAction( planId: string, date: string, newChildId: string | null, ) { const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]); const kitaId = session.user.kitaId!; const [yr, mo, dy] = date.slice(0, 10).split("-").map(Number); 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 plan = await tx.notdienstPlan.findUnique({ where: { id: planId, kitaId }, }); 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 await tx.notdienstAssignment.deleteMany({ where: { planId, date: parsedDate }, }); // Neues anlegen, falls ausgewählt if (newChildId) { const child = await tx.child.findFirst({ where: { id: newChildId, kitaId, active: true, }, include: { family: { include: { users: { where: { role: UserRole.ELTERN }, select: { email: true, firstName: true, lastName: true }, }, }, }, }, }); if (!child) { throw new Error("Kind gehört nicht zu dieser Kita."); } await tx.notdienstAssignment.create({ data: { kitaId, planId, childId: newChildId, 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) { return { error: error instanceof Error ? error.message : "Update fehlgeschlagen.", }; } } export async function updateKitaNotdienstSettingsAction( minPerChild: number, reminderDays: number, ) { const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]); const kitaId = session.user.kitaId; if (!kitaId) { return { error: "Kein Mandant zugeordnet." }; } try { await prisma.kita.update({ where: { id: kitaId }, data: { notdienstMinPerChildPerMonth: minPerChild, notdienstReminderDaysBefore: reminderDays, }, }); revalidatePath("/dashboard/notdienst/plan"); return { success: true }; } catch (error) { console.error(error); return { error: "Fehler beim Speichern der Einstellungen." }; } } 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 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 }, }, }, }); // 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 }; } catch (error) { console.error("Fehler beim Versenden der Erinnerungen:", error); return { error: "Fehler beim Versenden der Erinnerungen." }; } } export async function resetPlanAction(planId: string) { const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]); const kitaId = session.user.kitaId; if (!kitaId) { return { error: "Kein Mandant zugeordnet." }; } try { await prisma.$transaction(async (tx) => { // Ensure the plan belongs to this kita and is draft const plan = await tx.notdienstPlan.findFirst({ where: { id: planId, kitaId }, }); if (!plan) { throw new Error("Plan nicht gefunden."); } if (plan.status !== NotdienstPlanStatus.DRAFT) { throw new Error("Nur Pläne im Entwurfsstatus können zurückgesetzt werden."); } // Delete all assignments await tx.notdienstAssignment.deleteMany({ where: { planId }, }); // Delete the plan itself await tx.notdienstPlan.delete({ where: { id: planId }, }); }); revalidatePath("/dashboard/notdienst/plan"); return { success: true }; } catch (error) { console.error("Fehler beim Zurücksetzen des Plans:", error); return { error: error instanceof Error ? error.message : "Fehler beim Zurücksetzen des Plans." }; } }