feat: add household emergency duty alarms and deadline worker
This commit is contained in:
@@ -1,34 +1,39 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { NotdienstAlertStatus } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { respondToAlarm } from "@/lib/alarms/service";
|
||||
import { deliverAlarmEmails } from "@/lib/alarms/email";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
|
||||
export async function confirmAlertAction(token: string) {
|
||||
export async function respondAlertAction(token: string, approve: boolean) {
|
||||
if (typeof token !== "string" || token.length > 128)
|
||||
return { error: "Ungültiger Rückmeldelink." };
|
||||
if (!rateLimit(`alarm-response:${token}`, 12, 60).allowed)
|
||||
return { error: "Bitte kurz warten und erneut versuchen." };
|
||||
try {
|
||||
const alert = await prisma.notdienstAlert.findUnique({
|
||||
where: { confirmationToken: token },
|
||||
});
|
||||
|
||||
if (!alert) {
|
||||
return { error: "Ungültiger Bestätigungs-Link." };
|
||||
const result = await respondToAlarm(token, approve);
|
||||
// The household response is already committed; transport failure must not undo it.
|
||||
try {
|
||||
await deliverAlarmEmails(result.emailIds);
|
||||
} catch {
|
||||
/* Persistent jobs will retry. */
|
||||
}
|
||||
|
||||
if (alert.status !== NotdienstAlertStatus.PENDING) {
|
||||
return { error: "Dieser Alarm wurde bereits bestätigt oder abgebrochen." };
|
||||
}
|
||||
|
||||
await prisma.notdienstAlert.update({
|
||||
where: { id: alert.id },
|
||||
data: {
|
||||
status: NotdienstAlertStatus.CONFIRMED,
|
||||
confirmedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/alert/[token]", "page");
|
||||
revalidatePath("/dashboard");
|
||||
revalidatePath("/dashboard/notdienst/alarm");
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { error: "Fehler bei der Bestätigung." };
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
!error.message.includes("prisma") &&
|
||||
!error.message.includes("Invalid `")
|
||||
)
|
||||
return { error: error.message };
|
||||
return {
|
||||
error:
|
||||
"Die Rückmeldung konnte nicht gespeichert werden. Bitte den aktuellen Stand prüfen.",
|
||||
};
|
||||
}
|
||||
}
|
||||
export async function confirmAlertAction(token: string) {
|
||||
return respondAlertAction(token, true);
|
||||
}
|
||||
|
||||
@@ -1,37 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { confirmAlertAction } from "./actions";
|
||||
import { respondAlertAction } from "./actions";
|
||||
|
||||
export function AlertConfirmForm({ token }: { token: string }) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleConfirm = () => {
|
||||
startTransition(async () => {
|
||||
const result = await confirmAlertAction(token);
|
||||
if ("error" in result && result.error) {
|
||||
toast.error(result.error as string);
|
||||
} else {
|
||||
toast.success("Erfolgreich bestätigt!");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const [choice, setChoice] = useState<boolean | null>(null);
|
||||
const [pending, start] = useTransition();
|
||||
const [error, setError] = useState("");
|
||||
const router = useRouter();
|
||||
return (
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full sm:w-auto font-semibold text-lg"
|
||||
onClick={handleConfirm}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
) : null}
|
||||
Ich bestätige! (Bin auf dem Weg)
|
||||
</Button>
|
||||
<div className="grid gap-4">
|
||||
{choice === null ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Button onClick={() => setChoice(true)}>Wir übernehmen</Button>
|
||||
<Button variant="outline" onClick={() => setChoice(false)}>
|
||||
Wir können nicht
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 rounded-xl border border-[var(--hairline)] bg-[var(--surface-2)] p-4">
|
||||
<p className="text-sm">
|
||||
{choice
|
||||
? "Wir sagen für unseren Haushalt zu."
|
||||
: "Unser Haushalt kann diesen Einsatz nicht übernehmen."}{" "}
|
||||
Diese Antwort gilt für alle Elternkonten des Haushalts.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
start(async () => {
|
||||
setError("");
|
||||
const result = await respondAlertAction(token, choice);
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
router.refresh();
|
||||
} else router.refresh();
|
||||
})
|
||||
}
|
||||
>
|
||||
{pending ? "Wird gespeichert …" : "Antwort verbindlich speichern"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={pending}
|
||||
onClick={() => {
|
||||
setChoice(null);
|
||||
setError("");
|
||||
}}
|
||||
>
|
||||
Zurück
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-[var(--danger)]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+100
-113
@@ -1,127 +1,114 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { BellRing, ShieldCheck, XCircle } from "lucide-react";
|
||||
import { NotdienstAlertStatus } from "@prisma/client";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BellRing, CheckCircle2 } from "lucide-react";
|
||||
import { getPublicAlarm } from "@/lib/alarms/service";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
alarmLabel,
|
||||
dateKey,
|
||||
durationLabel,
|
||||
instantLabel,
|
||||
} from "@/lib/alarms/format";
|
||||
import { AlertConfirmForm } from "./alert-confirm-form";
|
||||
|
||||
export const metadata = { title: "Notdienst Alarm · Kita-Planer" };
|
||||
|
||||
export const metadata = {
|
||||
title: "Notdienstalarm · Kita-Planer",
|
||||
robots: { index: false, follow: false },
|
||||
referrer: "no-referrer" as const,
|
||||
};
|
||||
export const dynamic = "force-dynamic";
|
||||
export default async function AlertPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ token: string }>;
|
||||
}) {
|
||||
const { token } = await params;
|
||||
|
||||
const alert = await prisma.notdienstAlert.findUnique({
|
||||
where: { confirmationToken: token },
|
||||
select: {
|
||||
status: true,
|
||||
parentUser: {
|
||||
select: {
|
||||
firstName: true,
|
||||
},
|
||||
},
|
||||
kita: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
assignment: {
|
||||
select: {
|
||||
date: true,
|
||||
child: {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!alert) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const isPending = alert.status === NotdienstAlertStatus.PENDING;
|
||||
const isConfirmed = alert.status === NotdienstAlertStatus.CONFIRMED;
|
||||
|
||||
const alarm = await getPublicAlarm(token);
|
||||
if (!alarm) notFound();
|
||||
const now = new Date(),
|
||||
expired = alarm.expiresAt <= now;
|
||||
const canRespond = alarm.status === "PENDING" && !expired;
|
||||
const overdue = alarm.deadline <= now;
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-muted/30 px-4 py-12">
|
||||
<Card className="w-full max-w-md shadow-lg border-primary/20">
|
||||
<CardHeader className="text-center pb-2">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
{isPending ? (
|
||||
<BellRing className="h-8 w-8 animate-pulse text-destructive" />
|
||||
) : isConfirmed ? (
|
||||
<ShieldCheck className="h-8 w-8 text-success" />
|
||||
) : (
|
||||
<XCircle className="h-8 w-8 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="text-2xl">
|
||||
{isPending
|
||||
? "Notdienst Einsatz!"
|
||||
: isConfirmed
|
||||
? "Einsatz bestätigt"
|
||||
: "Alarm abgebrochen"}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-base mt-2">
|
||||
Kita {alert.kita.name}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 pt-4">
|
||||
<div className="rounded-lg bg-background border p-4 text-center space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Datum</p>
|
||||
<p className="font-semibold text-lg">
|
||||
{format(alert.assignment.date, "EEEE, dd. MMMM yyyy", {
|
||||
locale: de,
|
||||
})}
|
||||
</p>
|
||||
<div className="h-2" />
|
||||
<p className="text-sm text-muted-foreground">Eingeteiltes Kind</p>
|
||||
<p className="font-medium">
|
||||
{alert.assignment.child.firstName}{" "}
|
||||
{alert.assignment.child.lastName}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
{isPending ? (
|
||||
<>
|
||||
<p className="mb-6 text-sm text-muted-foreground">
|
||||
Hallo {alert.parentUser.firstName}, eine Fachkraft hat sich
|
||||
krankgemeldet und du bist heute für den Notdienst eingeteilt.
|
||||
Bitte bestätige deinen Einsatz schnellstmöglich!
|
||||
<main className="flex min-h-screen items-center justify-center bg-[var(--bg)] px-5 py-12">
|
||||
<section className="w-full max-w-xl overflow-hidden rounded-2xl border border-[var(--hairline)] bg-[var(--surface)] shadow-sm">
|
||||
<header
|
||||
className={`border-b border-[var(--hairline)] p-6 sm:p-8 ${alarm.status === "CONFIRMED" ? "bg-[var(--accent-soft)]" : "bg-[var(--surface-2)]"}`}
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-[var(--accent-deep)]">
|
||||
Kita-Planer · {alarm.kitaName}
|
||||
</p>
|
||||
{alarm.status === "CONFIRMED" ? (
|
||||
<CheckCircle2 className="mt-6 h-7 w-7 text-[var(--accent-deep)]" />
|
||||
) : (
|
||||
<BellRing className="mt-6 h-7 w-7 text-[var(--danger)]" />
|
||||
)}
|
||||
<h1 className="mt-3 text-3xl text-[var(--ink)]">
|
||||
{alarm.status === "PENDING"
|
||||
? "Könnt ihr den Notdienst übernehmen?"
|
||||
: alarmLabel(alarm)}
|
||||
</h1>
|
||||
<p className="mt-3 text-sm leading-6 text-[var(--ink-soft)]">
|
||||
Hallo {alarm.firstName}, diese Rückmeldung gilt für euren gesamten
|
||||
Haushalt.
|
||||
</p>
|
||||
</header>
|
||||
<div className="grid gap-6 p-6 sm:p-8">
|
||||
<dl className="grid grid-cols-2 gap-5 text-sm">
|
||||
<div>
|
||||
<dt className="text-[var(--ink-muted)]">Datum</dt>
|
||||
<dd className="mt-1 font-semibold">
|
||||
{dateKey(alarm.date).split("-").reverse().join(".")}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[var(--ink-muted)]">Einsatz</dt>
|
||||
<dd className="mt-1 font-semibold">{durationLabel(alarm)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[var(--ink-muted)]">Haushalt</dt>
|
||||
<dd className="mt-1 font-semibold">{alarm.familyName}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[var(--ink-muted)]">Antwortfrist</dt>
|
||||
<dd className="mt-1 font-semibold">
|
||||
{instantLabel(alarm.deadline)} Uhr
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{canRespond ? (
|
||||
<>
|
||||
{overdue && (
|
||||
<p className="rounded-lg bg-[var(--surface-2)] p-4 text-sm leading-6">
|
||||
Die Antwortfrist ist abgelaufen. Ihr könnt noch antworten.
|
||||
Eine verspätete Zusage muss die Koordination freigeben, da
|
||||
möglicherweise bereits Ersatz organisiert wird.
|
||||
</p>
|
||||
<AlertConfirmForm token={token} />
|
||||
</>
|
||||
) : isConfirmed ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Danke, {alert.parentUser.firstName}! Dein Einsatz wurde
|
||||
erfolgreich bestätigt. Die Kita rechnet mit dir.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Dieser Einsatz wurde vom Koordinator abgebrochen. Du musst nicht
|
||||
einspringen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
<AlertConfirmForm token={token} />
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
role="status"
|
||||
className="rounded-xl border border-[var(--hairline)] p-4 text-sm leading-6"
|
||||
>
|
||||
{alarm.status === "CANCELLED"
|
||||
? "Der Einsatz wurde storniert. Ihr müsst für diesen Alarm nicht einspringen."
|
||||
: alarm.status === "CONFIRMED"
|
||||
? "Euer Haushalt hat zugesagt. Die Kita rechnet mit euch. Bei einer Änderung kontaktiert bitte die Koordination."
|
||||
: alarm.status === "LATE_CONFIRMATION"
|
||||
? "Eure verspätete Zusage ist gespeichert. Wartet bitte auf die Freigabe durch die Koordination."
|
||||
: alarm.status === "DECLINED"
|
||||
? "Die Absage eures Haushalts ist gespeichert. Die Koordination kümmert sich um Ersatz."
|
||||
: alarm.status === "REVIEW_REQUIRED"
|
||||
? "Die bisherigen Rückmeldungen müssen von der Koordination geprüft werden. Bitte kontaktiert die Kita."
|
||||
: "Der Einsatz ist beendet. Dieser Rückmeldelink ist abgelaufen."}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs leading-5 text-[var(--ink-muted)]">
|
||||
Den persönlichen Link bitte innerhalb eures Haushalts behalten.
|
||||
Änderungen einer gespeicherten Antwort klärt ihr mit der Kita.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { authorizeAlarmJob, runAlarmJob } from "@/lib/alarms/job";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
export async function POST(request: Request) {
|
||||
const status = authorizeAlarmJob(request.headers.get("authorization"));
|
||||
if (status !== 200)
|
||||
return Response.json(
|
||||
{
|
||||
error:
|
||||
status === 503
|
||||
? "Fristprüfung ist nicht konfiguriert."
|
||||
: "Nicht autorisiert.",
|
||||
},
|
||||
{ status },
|
||||
);
|
||||
try {
|
||||
const result = await runAlarmJob();
|
||||
revalidatePath("/dashboard");
|
||||
revalidatePath("/dashboard/notdienst/alarm");
|
||||
return Response.json(result);
|
||||
} catch {
|
||||
return Response.json(
|
||||
{
|
||||
error:
|
||||
"Fristprüfung fehlgeschlagen; nächster Aufruf versucht es erneut.",
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,123 +1,10 @@
|
||||
"use server";
|
||||
|
||||
import crypto from "crypto";
|
||||
import { createElement } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { NotdienstAlertStatus, UserRole } from "@prisma/client";
|
||||
|
||||
import { AlertEmail } from "@/emails/AlertEmail";
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const BASE_URL =
|
||||
process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
|
||||
|
||||
import { alarmAction } from "./notdienst/alarm/actions";
|
||||
// Existing callers use the shared household workflow and the same guards.
|
||||
export async function triggerAlertAction(assignmentId: string) {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
const kitaId = session.user.kitaId!;
|
||||
|
||||
try {
|
||||
const [existing, assignment] = await Promise.all([
|
||||
// Prüfen ob es schon einen aktiven Alert für dieses Assignment gibt
|
||||
prisma.notdienstAlert.findFirst({
|
||||
where: { assignmentId, kitaId },
|
||||
}),
|
||||
prisma.notdienstAssignment.findFirst({
|
||||
where: { id: assignmentId, kitaId },
|
||||
include: {
|
||||
child: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
family: {
|
||||
select: {
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
if (existing) {
|
||||
return { error: "Für diese Einteilung wurde bereits ein Alarm ausgelöst." };
|
||||
}
|
||||
|
||||
if (!assignment) {
|
||||
return { error: "Notdienst-Einteilung wurde nicht gefunden." };
|
||||
}
|
||||
|
||||
const parents = assignment.child.family.users;
|
||||
|
||||
if (parents.length === 0) {
|
||||
return { error: "Kein Elternteil für diesen Notdienst hinterlegt." };
|
||||
}
|
||||
|
||||
const mailConfigError = getAppEmailConfigError();
|
||||
if (mailConfigError) {
|
||||
return { error: mailConfigError };
|
||||
}
|
||||
|
||||
const dateLabel = format(assignment.date, "EEEE, dd. MMMM yyyy", {
|
||||
locale: de,
|
||||
});
|
||||
const childName = `${assignment.child.firstName} ${assignment.child.lastName}`;
|
||||
const alertJobs: { email: string; token: string }[] = [];
|
||||
|
||||
await prisma.$transaction(
|
||||
parents.map((parent) => {
|
||||
const token = crypto.randomUUID();
|
||||
alertJobs.push({ email: parent.email, token });
|
||||
return prisma.notdienstAlert.create({
|
||||
data: {
|
||||
kitaId,
|
||||
assignmentId,
|
||||
parentUserId: parent.id,
|
||||
triggeredById: session.user.id,
|
||||
status: NotdienstAlertStatus.PENDING,
|
||||
confirmationToken: token,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
for (const job of alertJobs) {
|
||||
const emailResult = await sendAppEmail({
|
||||
to: job.email,
|
||||
subject: `Dringender Notdienst-Alarm für ${dateLabel}`,
|
||||
react: createElement(AlertEmail, {
|
||||
date: dateLabel,
|
||||
childName,
|
||||
confirmLink: `${BASE_URL}/alert/${job.token}`,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!emailResult.success) {
|
||||
revalidatePath("/dashboard");
|
||||
return {
|
||||
error: `Alarm wurde angelegt, aber mindestens eine E-Mail konnte nicht versendet werden: ${emailResult.error}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath("/dashboard");
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return { error: "Fehler beim Auslösen des Alarms." };
|
||||
}
|
||||
return alarmAction("create", {
|
||||
assignmentId,
|
||||
wholeDay: true,
|
||||
replyMinutes: 30,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -51,11 +51,15 @@ const activeNotdienstAlertStatuses = [
|
||||
|
||||
function getTodayUtcDateOnly() {
|
||||
const today = new Date();
|
||||
return new Date(Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()));
|
||||
return new Date(
|
||||
Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()),
|
||||
);
|
||||
}
|
||||
|
||||
function getBaseUrl() {
|
||||
return process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
|
||||
return (
|
||||
process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000"
|
||||
);
|
||||
}
|
||||
|
||||
function formatNotdienstDate(date: Date) {
|
||||
@@ -68,7 +72,7 @@ function revalidateCalendarAndNotdienst() {
|
||||
revalidatePath("/dashboard/notdienst/plan");
|
||||
revalidatePath("/dashboard/notdienst");
|
||||
revalidatePath("/dashboard");
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Ignore if called outside Next.js context
|
||||
}
|
||||
}
|
||||
@@ -94,6 +98,7 @@ type SwapAssignmentWithFamily = {
|
||||
alerts: {
|
||||
id: string;
|
||||
}[];
|
||||
alarms?: { id: string }[];
|
||||
};
|
||||
|
||||
async function getSwapAssignment(
|
||||
@@ -126,7 +131,11 @@ async function getSwapAssignment(
|
||||
},
|
||||
},
|
||||
alerts: {
|
||||
where: { status: { in: activeNotdienstAlertStatuses } },
|
||||
where: { alarmId: null, status: { in: activeNotdienstAlertStatuses } },
|
||||
select: { id: true },
|
||||
},
|
||||
alarms: {
|
||||
where: { activeAssignmentId: { not: null } },
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
@@ -171,7 +180,12 @@ function validateSwapAssignmentPair({
|
||||
return "Du kannst keinen Tausch mit deinem eigenen Haushalt anfragen.";
|
||||
}
|
||||
|
||||
if (targetAssignment.alerts.length > 0 || offeredAssignment.alerts.length > 0) {
|
||||
if (
|
||||
targetAssignment.alerts.length > 0 ||
|
||||
offeredAssignment.alerts.length > 0 ||
|
||||
targetAssignment.alarms?.length ||
|
||||
offeredAssignment.alarms?.length
|
||||
) {
|
||||
return "Notdienste mit aktiver Alarmierung können nicht getauscht werden.";
|
||||
}
|
||||
|
||||
@@ -215,7 +229,10 @@ async function sendSwapMail({
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.error("Notdienst-Tausch-Mail konnte nicht versendet werden:", result.error);
|
||||
console.error(
|
||||
"Notdienst-Tausch-Mail konnte nicht versendet werden:",
|
||||
result.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,7 +281,9 @@ function buildTerminData(data: z.infer<typeof terminSchema>) {
|
||||
now.setSeconds(0, 0);
|
||||
|
||||
if (startDate < now) {
|
||||
return { error: "Der Startzeitpunkt darf nicht in der Vergangenheit liegen." };
|
||||
return {
|
||||
error: "Der Startzeitpunkt darf nicht in der Vergangenheit liegen.",
|
||||
};
|
||||
}
|
||||
|
||||
if (endDate < startDate) {
|
||||
@@ -286,9 +305,7 @@ function buildTerminData(data: z.infer<typeof terminSchema>) {
|
||||
? new Date(data.rsvpDeadline)
|
||||
: null,
|
||||
participantInfo:
|
||||
data.requiresRsvp && data.participantInfo
|
||||
? data.participantInfo
|
||||
: null,
|
||||
data.requiresRsvp && data.participantInfo ? data.participantInfo : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -347,7 +364,10 @@ export async function createNotdienstSwapRequest(rawPayload: unknown) {
|
||||
return { error: "Notdienst nicht gefunden." };
|
||||
}
|
||||
if (existingRequest) {
|
||||
return { error: "Für einen dieser Notdienste gibt es bereits eine offene Tauschanfrage." };
|
||||
return {
|
||||
error:
|
||||
"Für einen dieser Notdienste gibt es bereits eine offene Tauschanfrage.",
|
||||
};
|
||||
}
|
||||
|
||||
await prisma.notdienstSwapRequest.create({
|
||||
@@ -394,128 +414,170 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
|
||||
const today = getTodayUtcDateOnly();
|
||||
|
||||
try {
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const request = await tx.notdienstSwapRequest.findFirst({
|
||||
where: {
|
||||
id: parsed.data,
|
||||
kitaId: session.user.kitaId,
|
||||
status: NotdienstSwapStatus.PENDING,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
requesterFamilyId: true,
|
||||
targetAssignmentId: true,
|
||||
offeredAssignmentId: true,
|
||||
targetAssignment: {
|
||||
select: {
|
||||
id: true,
|
||||
childId: true,
|
||||
date: true,
|
||||
plan: { select: { status: true } },
|
||||
child: {
|
||||
select: {
|
||||
familyId: true,
|
||||
family: {
|
||||
select: {
|
||||
name: true,
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN },
|
||||
select: { email: true, firstName: true, lastName: true },
|
||||
const result = await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const request = await tx.notdienstSwapRequest.findFirst({
|
||||
where: {
|
||||
id: parsed.data,
|
||||
kitaId: session.user.kitaId,
|
||||
status: NotdienstSwapStatus.PENDING,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
requesterFamilyId: true,
|
||||
targetAssignmentId: true,
|
||||
offeredAssignmentId: true,
|
||||
targetAssignment: {
|
||||
select: {
|
||||
id: true,
|
||||
childId: true,
|
||||
date: true,
|
||||
plan: { select: { status: true } },
|
||||
child: {
|
||||
select: {
|
||||
familyId: true,
|
||||
family: {
|
||||
select: {
|
||||
name: true,
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN },
|
||||
select: {
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
alerts: {
|
||||
where: { status: { in: activeNotdienstAlertStatuses } },
|
||||
select: { id: true },
|
||||
alerts: {
|
||||
where: {
|
||||
alarmId: null,
|
||||
status: { in: activeNotdienstAlertStatuses },
|
||||
},
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
offeredAssignment: {
|
||||
select: {
|
||||
id: true,
|
||||
childId: true,
|
||||
date: true,
|
||||
plan: { select: { status: true } },
|
||||
child: {
|
||||
select: {
|
||||
familyId: true,
|
||||
family: {
|
||||
select: {
|
||||
name: true,
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN },
|
||||
select: { email: true, firstName: true, lastName: true },
|
||||
offeredAssignment: {
|
||||
select: {
|
||||
id: true,
|
||||
childId: true,
|
||||
date: true,
|
||||
plan: { select: { status: true } },
|
||||
child: {
|
||||
select: {
|
||||
familyId: true,
|
||||
family: {
|
||||
select: {
|
||||
name: true,
|
||||
users: {
|
||||
where: { role: UserRole.ELTERN },
|
||||
select: {
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
alerts: {
|
||||
where: { status: { in: activeNotdienstAlertStatuses } },
|
||||
select: { id: true },
|
||||
alerts: {
|
||||
where: {
|
||||
alarmId: null,
|
||||
status: { in: activeNotdienstAlertStatuses },
|
||||
},
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
if (!request) {
|
||||
throw new Error("Tauschanfrage nicht gefunden oder nicht mehr offen.");
|
||||
}
|
||||
if (!request) {
|
||||
throw new Error(
|
||||
"Tauschanfrage nicht gefunden oder nicht mehr offen.",
|
||||
);
|
||||
}
|
||||
|
||||
if (request.targetAssignment.child.familyId !== session.user.familyId) {
|
||||
throw new Error("Nur der angefragte Haushalt kann diese Anfrage annehmen.");
|
||||
}
|
||||
if (
|
||||
await tx.notdienstAlarm.count({
|
||||
where: {
|
||||
activeAssignmentId: {
|
||||
in: [request.targetAssignmentId, request.offeredAssignmentId],
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
throw new Error(
|
||||
"Notdienste mit aktiver Alarmierung können nicht getauscht werden.",
|
||||
);
|
||||
|
||||
const validationError = validateSwapAssignmentPair({
|
||||
targetAssignment: request.targetAssignment,
|
||||
offeredAssignment: request.offeredAssignment,
|
||||
requesterFamilyId: request.requesterFamilyId,
|
||||
today,
|
||||
});
|
||||
if (validationError) {
|
||||
throw new Error(validationError);
|
||||
}
|
||||
if (request.targetAssignment.child.familyId !== session.user.familyId) {
|
||||
throw new Error(
|
||||
"Nur der angefragte Haushalt kann diese Anfrage annehmen.",
|
||||
);
|
||||
}
|
||||
|
||||
await tx.notdienstAssignment.update({
|
||||
where: { id: request.targetAssignmentId },
|
||||
data: { childId: request.offeredAssignment.childId },
|
||||
});
|
||||
await tx.notdienstAssignment.update({
|
||||
where: { id: request.offeredAssignmentId },
|
||||
data: { childId: request.targetAssignment.childId },
|
||||
});
|
||||
await tx.notdienstSwapRequest.update({
|
||||
where: { id: request.id },
|
||||
data: {
|
||||
status: NotdienstSwapStatus.ACCEPTED,
|
||||
respondedById: session.user.id,
|
||||
respondedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await tx.notdienstSwapRequest.updateMany({
|
||||
where: {
|
||||
id: { not: request.id },
|
||||
kitaId: session.user.kitaId,
|
||||
status: NotdienstSwapStatus.PENDING,
|
||||
OR: [
|
||||
{ targetAssignmentId: { in: [request.targetAssignmentId, request.offeredAssignmentId] } },
|
||||
{ offeredAssignmentId: { in: [request.targetAssignmentId, request.offeredAssignmentId] } },
|
||||
],
|
||||
},
|
||||
data: {
|
||||
status: NotdienstSwapStatus.CANCELLED,
|
||||
respondedAt: new Date(),
|
||||
},
|
||||
});
|
||||
const validationError = validateSwapAssignmentPair({
|
||||
targetAssignment: request.targetAssignment,
|
||||
offeredAssignment: request.offeredAssignment,
|
||||
requesterFamilyId: request.requesterFamilyId,
|
||||
today,
|
||||
});
|
||||
if (validationError) {
|
||||
throw new Error(validationError);
|
||||
}
|
||||
|
||||
return {
|
||||
targetAssignment: request.targetAssignment,
|
||||
offeredAssignment: request.offeredAssignment,
|
||||
};
|
||||
});
|
||||
await tx.notdienstAssignment.update({
|
||||
where: { id: request.targetAssignmentId },
|
||||
data: { childId: request.offeredAssignment.childId },
|
||||
});
|
||||
await tx.notdienstAssignment.update({
|
||||
where: { id: request.offeredAssignmentId },
|
||||
data: { childId: request.targetAssignment.childId },
|
||||
});
|
||||
await tx.notdienstSwapRequest.update({
|
||||
where: { id: request.id },
|
||||
data: {
|
||||
status: NotdienstSwapStatus.ACCEPTED,
|
||||
respondedById: session.user.id,
|
||||
respondedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await tx.notdienstSwapRequest.updateMany({
|
||||
where: {
|
||||
id: { not: request.id },
|
||||
kitaId: session.user.kitaId,
|
||||
status: NotdienstSwapStatus.PENDING,
|
||||
OR: [
|
||||
{
|
||||
targetAssignmentId: {
|
||||
in: [request.targetAssignmentId, request.offeredAssignmentId],
|
||||
},
|
||||
},
|
||||
{
|
||||
offeredAssignmentId: {
|
||||
in: [request.targetAssignmentId, request.offeredAssignmentId],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
data: {
|
||||
status: NotdienstSwapStatus.CANCELLED,
|
||||
respondedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
targetAssignment: request.targetAssignment,
|
||||
offeredAssignment: request.offeredAssignment,
|
||||
};
|
||||
},
|
||||
{ isolationLevel: "Serializable" },
|
||||
);
|
||||
|
||||
const kita = await prisma.kita.findUnique({
|
||||
where: { id: session.user.kitaId },
|
||||
@@ -550,7 +612,10 @@ export async function acceptNotdienstSwapRequest(requestIdRaw: unknown) {
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : "Tauschanfrage konnte nicht angenommen werden.",
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Tauschanfrage konnte nicht angenommen werden.",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -619,7 +684,9 @@ export async function declineNotdienstSwapRequest(requestIdRaw: unknown) {
|
||||
return { error: "Tauschanfrage nicht gefunden oder nicht mehr offen." };
|
||||
}
|
||||
if (request.targetAssignment.child.familyId !== session.user.familyId) {
|
||||
return { error: "Nur der angefragte Haushalt kann diese Anfrage ablehnen." };
|
||||
return {
|
||||
error: "Nur der angefragte Haushalt kann diese Anfrage ablehnen.",
|
||||
};
|
||||
}
|
||||
|
||||
await prisma.notdienstSwapRequest.update({
|
||||
@@ -706,19 +773,22 @@ export async function cancelNotdienstSwapRequest(requestIdRaw: unknown) {
|
||||
revalidateCalendarAndNotdienst();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Zurückziehen der Notdienst-Tauschanfrage:", error);
|
||||
console.error(
|
||||
"Fehler beim Zurückziehen der Notdienst-Tauschanfrage:",
|
||||
error,
|
||||
);
|
||||
return { error: "Tauschanfrage konnte nicht zurückgezogen werden." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTerminRequest(rawPayload: unknown) {
|
||||
const session = await requireKitaSession();
|
||||
|
||||
|
||||
const parsed = terminSchema.safeParse(rawPayload);
|
||||
if (!parsed.success) {
|
||||
return { error: "Ungültige Eingabedaten." };
|
||||
}
|
||||
|
||||
|
||||
const data = parsed.data;
|
||||
const terminData = buildTerminData(data);
|
||||
|
||||
@@ -755,12 +825,12 @@ export async function createTerminAdmin(rawPayload: unknown) {
|
||||
if (!kitaId) {
|
||||
return { error: "Kein Mandant zugeordnet." };
|
||||
}
|
||||
|
||||
|
||||
const parsed = terminSchema.safeParse(rawPayload);
|
||||
if (!parsed.success) {
|
||||
return { error: "Ungültige Eingabedaten." };
|
||||
}
|
||||
|
||||
|
||||
const data = parsed.data;
|
||||
const terminData = buildTerminData(data);
|
||||
|
||||
@@ -885,7 +955,10 @@ export async function rejectTermin(terminId: string, reason?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleMitbringselList(terminId: string, enabled: boolean) {
|
||||
export async function toggleMitbringselList(
|
||||
terminId: string,
|
||||
enabled: boolean,
|
||||
) {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
const kitaId = session.user.kitaId;
|
||||
if (!kitaId) {
|
||||
@@ -919,12 +992,12 @@ const mitbringselSchema = z.object({
|
||||
|
||||
export async function addMitbringsel(rawPayload: unknown) {
|
||||
const session = await requireKitaSession();
|
||||
|
||||
|
||||
const parsed = mitbringselSchema.safeParse(rawPayload);
|
||||
if (!parsed.success) {
|
||||
return { error: "Ungültige Eingabedaten." };
|
||||
}
|
||||
|
||||
|
||||
const { terminId, content } = parsed.data;
|
||||
|
||||
try {
|
||||
@@ -933,7 +1006,11 @@ export async function addMitbringsel(rawPayload: unknown) {
|
||||
where: { id: terminId, kitaId: session.user.kitaId },
|
||||
});
|
||||
|
||||
if (!termin || termin.status !== TerminStatus.CONFIRMED || !termin.mitbringselListEnabled) {
|
||||
if (
|
||||
!termin ||
|
||||
termin.status !== TerminStatus.CONFIRMED ||
|
||||
!termin.mitbringselListEnabled
|
||||
) {
|
||||
return { error: "Mitbring-Liste ist für diesen Termin nicht aktiv." };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"use server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { requireAlarmActor } from "@/lib/alarms/auth";
|
||||
import { executeAlarmCommand } from "@/lib/alarms/service";
|
||||
import { deliverAlarmEmails } from "@/lib/alarms/email";
|
||||
|
||||
export type AlarmActionResult = {
|
||||
error?: string;
|
||||
warning?: string;
|
||||
success?: boolean;
|
||||
alarmId?: string;
|
||||
};
|
||||
export async function alarmAction(
|
||||
command: string,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<AlarmActionResult> {
|
||||
const actor = await requireAlarmActor(command !== "readNotice");
|
||||
try {
|
||||
const result = await executeAlarmCommand(actor, command, data);
|
||||
let warning: string | undefined;
|
||||
if (result.emailIds.length) {
|
||||
try {
|
||||
const delivery = await deliverAlarmEmails(result.emailIds);
|
||||
if (delivery.failed)
|
||||
warning =
|
||||
"Gespeichert. Einige E-Mails warten noch auf den Versand. Bitte den Versandstand im Alarm prüfen.";
|
||||
else if (
|
||||
process.env.NODE_ENV === "development" &&
|
||||
!process.env.RESEND_API_KEY
|
||||
)
|
||||
warning = "Gespeichert. E-Mail-Versand lokal simuliert.";
|
||||
} catch {
|
||||
warning = "Gespeichert. Der E-Mail-Versand ist noch ausstehend.";
|
||||
}
|
||||
}
|
||||
for (const path of [
|
||||
"/dashboard",
|
||||
"/dashboard/notdienst/alarm",
|
||||
"/dashboard/notdienst/plan",
|
||||
"/dashboard/kalender",
|
||||
])
|
||||
revalidatePath(path);
|
||||
revalidatePath("/alert/[token]", "page");
|
||||
return { success: true, alarmId: result.alarmId, warning };
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError)
|
||||
return {
|
||||
error: error.issues[0]?.message ?? "Bitte die Eingaben prüfen.",
|
||||
};
|
||||
if (
|
||||
error instanceof Error &&
|
||||
!error.message.includes("prisma") &&
|
||||
!error.message.includes("Invalid `")
|
||||
)
|
||||
return { error: error.message };
|
||||
return { error: "Die Alarmaktion konnte nicht gespeichert werden." };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Link from "next/link";
|
||||
import { requireKitaSession } from "@/lib/auth-utils";
|
||||
import { getAlarmFeed, managerRoles } from "@/lib/alarms/service";
|
||||
import {
|
||||
alarmLabel,
|
||||
durationLabel,
|
||||
dateKey,
|
||||
needsClarification,
|
||||
} from "@/lib/alarms/format";
|
||||
import { AlarmRefresh } from "./forms";
|
||||
|
||||
export async function AlarmDashboardHints() {
|
||||
const { user } = await requireKitaSession();
|
||||
if (![...managerRoles, "ELTERN"].includes(user.role)) return null;
|
||||
let alarms;
|
||||
try {
|
||||
alarms = await getAlarmFeed({
|
||||
id: user.id!,
|
||||
kitaId: user.kitaId,
|
||||
role: user.role,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!alarms.length) return null;
|
||||
return (
|
||||
<section className="mb-6 rounded-2xl border border-[var(--accent-mid)] bg-[var(--surface)] p-5">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-semibold">Aktuelle Notdienstalarme</h2>
|
||||
<AlarmRefresh />
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
{alarms.map((a) => (
|
||||
<Link
|
||||
key={a.id}
|
||||
href={`/dashboard/notdienst/alarm?alarm=${a.id}`}
|
||||
className={`flex flex-wrap items-center justify-between gap-3 rounded-lg border p-3 text-sm ${needsClarification(a) ? "border-[var(--danger)] text-[var(--danger)]" : "border-[var(--hairline)]"}`}
|
||||
>
|
||||
<span>
|
||||
{dateKey(a.date).split("-").reverse().join(".")} · {a.familyName}
|
||||
<span className="mt-1 block text-xs text-[var(--ink-soft)]">
|
||||
{durationLabel(a)}
|
||||
</span>
|
||||
</span>
|
||||
<strong>{alarmLabel(a)} →</strong>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
"use client";
|
||||
import { useEffect, useId, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { BellRing } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { clockMinute } from "@/lib/staff/calculations";
|
||||
import { alarmAction } from "./actions";
|
||||
|
||||
const control =
|
||||
"w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-mid)]";
|
||||
export function AlarmRefresh() {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
if (!document.hidden) router.refresh();
|
||||
}, 30000);
|
||||
return () => clearInterval(timer);
|
||||
}, [router]);
|
||||
return (
|
||||
<Button variant="outline" size="sm" onClick={() => router.refresh()}>
|
||||
Stand aktualisieren
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
export type AlarmCandidate = { id: string; label: string; emails: string[] };
|
||||
export function AlarmCreateForm({
|
||||
candidates,
|
||||
educators,
|
||||
selected,
|
||||
}: {
|
||||
candidates: AlarmCandidate[];
|
||||
educators: { id: string; name: string }[];
|
||||
selected?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(!!selected);
|
||||
const [assignmentId, setAssignmentId] = useState(
|
||||
candidates.find((c) => c.id === selected)?.id ?? candidates[0]?.id ?? "",
|
||||
);
|
||||
const [wholeDay, setWholeDay] = useState(true);
|
||||
const [pending, start] = useTransition();
|
||||
const [error, setError] = useState("");
|
||||
const router = useRouter();
|
||||
const prefix = useId();
|
||||
const candidate = candidates.find((c) => c.id === assignmentId);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button disabled={!candidates.length}>
|
||||
<BellRing className="mr-2 h-4 w-4" />
|
||||
Notdienstalarm auslösen
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Notdienstalarm auslösen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Die eingeteilte Familie wird per E-Mail und in der App informiert.
|
||||
Eine Antwort gilt für den Haushalt.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="grid gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
setError("");
|
||||
start(async () => {
|
||||
const result = await alarmAction("create", {
|
||||
assignmentId,
|
||||
wholeDay,
|
||||
replyMinutes: Number(form.get("replyMinutes")),
|
||||
...(wholeDay
|
||||
? {}
|
||||
: {
|
||||
startMinute: clockMinute(String(form.get("start"))),
|
||||
endMinute: clockMinute(String(form.get("end"))),
|
||||
}),
|
||||
...(form.get("educatorId")
|
||||
? { educatorId: String(form.get("educatorId")) }
|
||||
: {}),
|
||||
notes: String(form.get("notes") ?? ""),
|
||||
});
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
if (result.warning) toast.warning(result.warning);
|
||||
else toast.success("Alarm ausgelöst.");
|
||||
setOpen(false);
|
||||
router.push(`/dashboard/notdienst/alarm?alarm=${result.alarmId}`);
|
||||
router.refresh();
|
||||
});
|
||||
}}
|
||||
>
|
||||
<fieldset disabled={pending} className="grid gap-4">
|
||||
<label
|
||||
className="grid gap-1.5 text-sm"
|
||||
htmlFor={`${prefix}-assignment`}
|
||||
>
|
||||
Eingeteilte Familie
|
||||
<select
|
||||
id={`${prefix}-assignment`}
|
||||
className={control}
|
||||
required
|
||||
value={assignmentId}
|
||||
onChange={(event) => setAssignmentId(event.target.value)}
|
||||
>
|
||||
{candidates.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="rounded-lg bg-[var(--surface-2)] p-3 text-sm">
|
||||
<p className="font-medium">Empfänger</p>
|
||||
{candidate?.emails.length ? (
|
||||
<ul className="mt-1 grid gap-1 break-all">
|
||||
{candidate.emails.map((email) => (
|
||||
<li key={email}>{email}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-1 text-[var(--danger)]">
|
||||
Keine Elternkonten hinterlegt. Bitte den Haushalt zuerst
|
||||
vervollständigen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<label
|
||||
className="grid gap-1.5 text-sm"
|
||||
htmlFor={`${prefix}-duration`}
|
||||
>
|
||||
Einsatzdauer
|
||||
<select
|
||||
id={`${prefix}-duration`}
|
||||
className={control}
|
||||
value={wholeDay ? "whole" : "hours"}
|
||||
onChange={(event) =>
|
||||
setWholeDay(event.target.value === "whole")
|
||||
}
|
||||
>
|
||||
<option value="whole">Ganzer Kita-Tag</option>
|
||||
<option value="hours">Bestimmte Uhrzeiten</option>
|
||||
</select>
|
||||
</label>
|
||||
{!wholeDay && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label
|
||||
className="grid gap-1.5 text-sm"
|
||||
htmlFor={`${prefix}-start`}
|
||||
>
|
||||
Beginn
|
||||
<Input
|
||||
id={`${prefix}-start`}
|
||||
type="time"
|
||||
name="start"
|
||||
required
|
||||
defaultValue="08:00"
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
className="grid gap-1.5 text-sm"
|
||||
htmlFor={`${prefix}-end`}
|
||||
>
|
||||
Ende
|
||||
<Input
|
||||
id={`${prefix}-end`}
|
||||
type="time"
|
||||
name="end"
|
||||
required
|
||||
defaultValue="16:00"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<label
|
||||
className="grid gap-1.5 text-sm"
|
||||
htmlFor={`${prefix}-minutes`}
|
||||
>
|
||||
Antwortfrist in Minuten
|
||||
<Input
|
||||
id={`${prefix}-minutes`}
|
||||
name="replyMinutes"
|
||||
type="number"
|
||||
min={1}
|
||||
max={1440}
|
||||
step={1}
|
||||
defaultValue={30}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs leading-5 text-[var(--ink-soft)]">
|
||||
Die Frist beginnt beim Auslösen und endet spätestens mit dem
|
||||
Einsatz. Eine Absage erfordert sofort Klärung.
|
||||
</p>
|
||||
<label
|
||||
className="grid gap-1.5 text-sm"
|
||||
htmlFor={`${prefix}-educator`}
|
||||
>
|
||||
Ausgefallene Fachkraft (optional, intern)
|
||||
<select
|
||||
id={`${prefix}-educator`}
|
||||
className={control}
|
||||
name="educatorId"
|
||||
>
|
||||
<option value="">Ohne Zuordnung</option>
|
||||
{educators.map((e) => (
|
||||
<option key={e.id} value={e.id}>
|
||||
{e.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1.5 text-sm" htmlFor={`${prefix}-notes`}>
|
||||
Interne Notiz (optional)
|
||||
<textarea
|
||||
id={`${prefix}-notes`}
|
||||
className={control}
|
||||
name="notes"
|
||||
maxLength={500}
|
||||
rows={2}
|
||||
/>
|
||||
</label>
|
||||
<Button type="submit" disabled={!candidate?.emails.length}>
|
||||
{pending
|
||||
? "Alarm wird gespeichert …"
|
||||
: "Alarm auslösen und Familie informieren"}
|
||||
</Button>
|
||||
</fieldset>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-[var(--danger)]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
export function AlarmCommand({
|
||||
alarmId,
|
||||
command,
|
||||
label,
|
||||
}: {
|
||||
alarmId: string;
|
||||
command: "cancel" | "recordResponse" | "approveLate";
|
||||
label: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false),
|
||||
[pending, start] = useTransition(),
|
||||
[error, setError] = useState("");
|
||||
const router = useRouter(),
|
||||
prefix = useId();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
{label}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{label}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{command === "cancel"
|
||||
? "Alle angeschriebenen Elternkonten erhalten eine Stornierung. Ihre Rückmeldelinks erlauben danach keine weitere Antwort."
|
||||
: "Die Rückmeldung gilt für den gesamten Haushalt. Die Familie und die Koordination werden über den Stand informiert."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="grid gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const data = new FormData(event.currentTarget);
|
||||
setError("");
|
||||
start(async () => {
|
||||
const result = await alarmAction(command, {
|
||||
alarmId,
|
||||
reason: String(data.get("reason")),
|
||||
...(command === "recordResponse"
|
||||
? { approve: data.get("response") === "yes" }
|
||||
: {}),
|
||||
});
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
if (result.warning) toast.warning(result.warning);
|
||||
else toast.success("Gespeichert.");
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
});
|
||||
}}
|
||||
>
|
||||
{command === "recordResponse" && (
|
||||
<label
|
||||
className="grid gap-1.5 text-sm"
|
||||
htmlFor={`${prefix}-response`}
|
||||
>
|
||||
Telefonische Rückmeldung / Korrektur
|
||||
<select
|
||||
id={`${prefix}-response`}
|
||||
name="response"
|
||||
className={control}
|
||||
>
|
||||
<option value="yes">Haushalt übernimmt</option>
|
||||
<option value="no">Haushalt kann nicht übernehmen</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<label className="grid gap-1.5 text-sm" htmlFor={`${prefix}-reason`}>
|
||||
Interne Begründung
|
||||
<textarea
|
||||
id={`${prefix}-reason`}
|
||||
className={control}
|
||||
name="reason"
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-[var(--ink-soft)]">
|
||||
Die Begründung bleibt intern und wird nicht per E-Mail versendet.
|
||||
</p>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-[var(--danger)]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<Button disabled={pending} type="submit">
|
||||
{pending
|
||||
? "Wird gespeichert …"
|
||||
: command === "cancel"
|
||||
? "Alarm stornieren"
|
||||
: "Rückmeldung speichern"}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
export function AlarmSimpleAction({
|
||||
command,
|
||||
data,
|
||||
children,
|
||||
}: {
|
||||
command: "retry" | "readNotice";
|
||||
data: Record<string, unknown>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [pending, start] = useTransition();
|
||||
const router = useRouter();
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
start(async () => {
|
||||
const result = await alarmAction(command, data);
|
||||
if (result.error) toast.error(result.error);
|
||||
else if (result.warning) toast.warning(result.warning);
|
||||
else toast.success("Gespeichert.");
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
>
|
||||
{pending ? "Bitte warten …" : children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
import Link from "next/link";
|
||||
import { BellRing } from "lucide-react";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAlarmActor } from "@/lib/alarms/auth";
|
||||
import { managerRoles, importLegacyAlarms } from "@/lib/alarms/service";
|
||||
import {
|
||||
alarmLabel,
|
||||
dateKey,
|
||||
durationLabel,
|
||||
instantLabel,
|
||||
needsClarification,
|
||||
todayKey,
|
||||
} from "@/lib/alarms/format";
|
||||
import { addDays, dateOnly } from "@/lib/staff/calculations";
|
||||
import {
|
||||
AlarmCreateForm,
|
||||
AlarmCommand,
|
||||
AlarmSimpleAction,
|
||||
AlarmRefresh,
|
||||
} from "./forms";
|
||||
|
||||
export const metadata = { title: "Notdienstalarm · Kita-Planer" };
|
||||
const historyLabels: Record<string, string> = {
|
||||
CONFIRMED: "Übernahme bestätigt",
|
||||
DECLINED: "Absage",
|
||||
LATE_CONFIRMATION: "Verspätete Zusage – Freigabe erforderlich",
|
||||
};
|
||||
export default async function AlarmPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ alarm?: string; assignment?: string }>;
|
||||
}) {
|
||||
const actor = await requireAlarmActor(false),
|
||||
manager = managerRoles.includes(actor.role);
|
||||
const params = await searchParams,
|
||||
now = new Date(),
|
||||
today = todayKey(now);
|
||||
if (manager) await importLegacyAlarms(actor.kitaId);
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: actor.id },
|
||||
select: { familyId: true },
|
||||
});
|
||||
const [kita, alarms, assignments, educators, notifications] =
|
||||
await Promise.all([
|
||||
prisma.kita.findUniqueOrThrow({
|
||||
where: { id: actor.kitaId },
|
||||
select: { name: true, notdienstModuleEnabled: true },
|
||||
}),
|
||||
prisma.notdienstAlarm.findMany({
|
||||
where: {
|
||||
kitaId: actor.kitaId,
|
||||
...(manager ? {} : { familyId: user.familyId ?? "" }),
|
||||
...(params.alarm
|
||||
? { id: params.alarm }
|
||||
: { date: { gte: dateOnly(addDays(today, -30)) } }),
|
||||
},
|
||||
include: {
|
||||
recipients: {
|
||||
include: { user: { select: { email: true, firstName: true } } },
|
||||
},
|
||||
emails: { orderBy: { createdAt: "desc" } },
|
||||
history: manager
|
||||
? { orderBy: { createdAt: "desc" }, take: 20 }
|
||||
: false,
|
||||
},
|
||||
orderBy: [{ date: "desc" }, { triggeredAt: "desc" }],
|
||||
take: 100,
|
||||
}),
|
||||
manager
|
||||
? prisma.notdienstAssignment.findMany({
|
||||
where: {
|
||||
kitaId: actor.kitaId,
|
||||
date: { gte: dateOnly(today), lt: dateOnly(addDays(today, 93)) },
|
||||
plan: { status: "PUBLISHED" },
|
||||
alarms: { none: { activeAssignmentId: { not: null } } },
|
||||
},
|
||||
include: {
|
||||
child: {
|
||||
include: {
|
||||
family: {
|
||||
include: {
|
||||
users: {
|
||||
where: { role: "ELTERN", kitaId: actor.kitaId },
|
||||
select: { email: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { date: "asc" },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
manager
|
||||
? prisma.educator.findMany({
|
||||
where: { kitaId: actor.kitaId, active: true },
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
orderBy: { lastName: "asc" },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
prisma.notdienstAlarmNotification.findMany({
|
||||
where: {
|
||||
userId: actor.id,
|
||||
alarm: {
|
||||
kitaId: actor.kitaId,
|
||||
...(manager ? {} : { familyId: user.familyId ?? "" }),
|
||||
},
|
||||
readAt: null,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 20,
|
||||
}),
|
||||
]);
|
||||
const candidates = assignments.map((a) => ({
|
||||
id: a.id,
|
||||
label: `${dateKey(a.date).split("-").reverse().join(".")} · ${a.child.family.name}`,
|
||||
emails: a.child.family.users.map((u) => u.email),
|
||||
}));
|
||||
return (
|
||||
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
|
||||
<header className="mb-7 flex flex-wrap items-end justify-between gap-5">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--accent-deep)]">
|
||||
NOTDIENST · {kita.name}
|
||||
</p>
|
||||
<h1 className="mt-2 text-4xl tracking-tight text-[var(--ink)]">
|
||||
Notdienst
|
||||
<span className="italic text-[var(--accent-deep)]">alarm</span>
|
||||
</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-6 text-[var(--ink-soft)]">
|
||||
{manager
|
||||
? "Familien anfragen, Rückmeldungen verfolgen und offene Einsätze klären."
|
||||
: "Eure Rückmeldung gilt für den gesamten Haushalt."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<AlarmRefresh />
|
||||
{manager && kita.notdienstModuleEnabled && (
|
||||
<AlarmCreateForm
|
||||
candidates={candidates}
|
||||
educators={educators.map((e) => ({
|
||||
id: e.id,
|
||||
name: `${e.firstName} ${e.lastName}`,
|
||||
}))}
|
||||
selected={params.assignment}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
{manager &&
|
||||
(!process.env.NOTDIENST_JOB_SECRET ||
|
||||
process.env.NOTDIENST_JOB_SECRET.length < 32) && (
|
||||
<p className="mb-6 rounded-xl border border-[var(--hairline)] bg-[var(--surface-2)] p-4 text-sm leading-6">
|
||||
Die automatische Fristprüfung ist noch nicht eingerichtet. Für
|
||||
Erinnerungen bei geschlossener App muss die Hosting-Konfiguration
|
||||
ergänzt werden. Fristabläufe sind hier bereits sichtbar.
|
||||
</p>
|
||||
)}
|
||||
{!kita.notdienstModuleEnabled && (
|
||||
<p className="mb-6 text-sm">
|
||||
Das Notdienstmodul ist deaktiviert; vorhandene Alarme bleiben
|
||||
einsehbar.
|
||||
</p>
|
||||
)}
|
||||
{notifications.length > 0 && (
|
||||
<details className="mb-6 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-4">
|
||||
<summary className="cursor-pointer text-sm font-semibold">
|
||||
{notifications.length} neue Hinweise
|
||||
</summary>
|
||||
<div className="mt-3 grid gap-3">
|
||||
{notifications.map((n) => (
|
||||
<div
|
||||
key={n.id}
|
||||
className="flex flex-wrap items-center justify-between gap-3 border-t border-[var(--hairline-soft)] pt-3"
|
||||
>
|
||||
<Link
|
||||
className="max-w-3xl text-sm underline decoration-[var(--accent-mid)] underline-offset-4"
|
||||
href={`/dashboard/notdienst/alarm?alarm=${n.alarmId}`}
|
||||
>
|
||||
{n.message}
|
||||
</Link>
|
||||
<AlarmSimpleAction command="readNotice" data={{ id: n.id }}>
|
||||
Gelesen
|
||||
</AlarmSimpleAction>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
{params.alarm && (
|
||||
<Link
|
||||
className="mb-5 inline-block text-sm text-[var(--accent-deep)] underline"
|
||||
href="/dashboard/notdienst/alarm"
|
||||
>
|
||||
Alle Alarme anzeigen
|
||||
</Link>
|
||||
)}
|
||||
{manager && !candidates.length && !params.alarm && (
|
||||
<p className="mb-5 text-sm text-[var(--ink-soft)]">
|
||||
Neue Alarme benötigen eine aktuelle oder zukünftige Einteilung in
|
||||
einem veröffentlichten Plan.{" "}
|
||||
<Link href="/dashboard/notdienst/plan" className="underline">
|
||||
Notdienstplan öffnen
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
<div className="grid gap-5">
|
||||
{alarms.length ? (
|
||||
alarms.map((a) => {
|
||||
const clarify = needsClarification(a, now),
|
||||
ended = a.expiresAt <= now;
|
||||
const pending = a.emails.filter((e) => !e.sentAt && !e.skippedAt);
|
||||
const ownRecipient = a.recipients.find(
|
||||
(r) => r.userId === actor.id,
|
||||
);
|
||||
return (
|
||||
<article
|
||||
key={a.id}
|
||||
id={`alarm-${a.id}`}
|
||||
className={`overflow-hidden rounded-2xl border bg-[var(--surface)] ${clarify ? "border-[var(--danger)]" : "border-[var(--hairline)]"}`}
|
||||
>
|
||||
<div
|
||||
className={`flex flex-wrap items-start justify-between gap-4 border-b border-[var(--hairline-soft)] p-5 sm:p-6 ${clarify ? "bg-[var(--surface-2)]" : ""}`}
|
||||
>
|
||||
<div>
|
||||
<p className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-[var(--ink-soft)]">
|
||||
<BellRing className="h-4 w-4" />
|
||||
{dateKey(a.date) === today ? "Heute · " : ""}
|
||||
{dateKey(a.date).split("-").reverse().join(".")}
|
||||
</p>
|
||||
<h2 className="mt-2 text-2xl">{a.familyName}</h2>
|
||||
<p className="mt-1 text-sm text-[var(--ink-soft)]">
|
||||
{durationLabel(a)}
|
||||
{ended ? " · Einsatz beendet" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`max-w-full rounded-full border px-3 py-1.5 text-xs font-semibold ${clarify ? "border-[var(--danger)] text-[var(--danger)]" : "border-[var(--accent-mid)] bg-[var(--accent-soft)] text-[var(--accent-deep)]"}`}
|
||||
>
|
||||
{alarmLabel(a, now)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-5 p-5 sm:p-6">
|
||||
<div className="flex flex-wrap gap-x-8 gap-y-3 text-sm">
|
||||
<p>
|
||||
<span className="text-[var(--ink-muted)]">
|
||||
Antwortfrist
|
||||
</span>
|
||||
<br />
|
||||
<strong>{instantLabel(a.deadline)} Uhr</strong>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-[var(--ink-muted)]">Ausgelöst</span>
|
||||
<br />
|
||||
{instantLabel(a.triggeredAt)} Uhr
|
||||
</p>
|
||||
{a.respondedAt && (
|
||||
<p>
|
||||
<span className="text-[var(--ink-muted)]">
|
||||
Rückmeldung
|
||||
</span>
|
||||
<br />
|
||||
{instantLabel(a.respondedAt)} Uhr ·{" "}
|
||||
{a.responseSource === "PHONE"
|
||||
? "Telefonisch dokumentiert"
|
||||
: a.responseSource === "COORDINATION"
|
||||
? "Freigabe durch Koordination"
|
||||
: a.responseSource === "LEGACY"
|
||||
? "Bisherige Alarmierung"
|
||||
: "Haushaltsantwort"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{manager ? (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{a.status === "LATE_CONFIRMATION" && !ended && (
|
||||
<AlarmCommand
|
||||
command="approveLate"
|
||||
alarmId={a.id}
|
||||
label="Verspätete Zusage freigeben"
|
||||
/>
|
||||
)}
|
||||
{a.status !== "CANCELLED" && !ended && (
|
||||
<AlarmCommand
|
||||
command="recordResponse"
|
||||
alarmId={a.id}
|
||||
label="Telefonische Rückmeldung / Korrektur"
|
||||
/>
|
||||
)}
|
||||
{a.status !== "CANCELLED" && (
|
||||
<AlarmCommand
|
||||
command="cancel"
|
||||
alarmId={a.id}
|
||||
label="Alarm stornieren"
|
||||
/>
|
||||
)}
|
||||
{pending.length > 0 && (
|
||||
<AlarmSimpleAction
|
||||
command="retry"
|
||||
data={{ alarmId: a.id }}
|
||||
>
|
||||
Ausstehende E-Mails erneut senden ({pending.length})
|
||||
</AlarmSimpleAction>
|
||||
)}
|
||||
</div>
|
||||
{clarify && (
|
||||
<p className="rounded-lg bg-[var(--surface-2)] p-3 text-sm leading-6">
|
||||
Bitte den Haushalt kontaktieren oder Ersatz manuell
|
||||
organisieren. Weitere Familien werden nicht
|
||||
automatisch alarmiert.
|
||||
</p>
|
||||
)}
|
||||
<details className="border-t border-[var(--hairline-soft)] pt-4">
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
Versandstand (
|
||||
{a.emails.filter((e) => e.sentAt).length} angenommen,{" "}
|
||||
{pending.length} ausstehend)
|
||||
</summary>
|
||||
<div className="mt-3 grid gap-2 text-sm">
|
||||
{a.emails.map((e) => (
|
||||
<p key={e.id} className="break-all">
|
||||
{a.recipients.find((r) => r.userId === e.userId)
|
||||
?.user.email ?? "Koordination / Admin"}{" "}
|
||||
·{" "}
|
||||
{e.kind === "REQUEST"
|
||||
? "Anfrage"
|
||||
: e.kind === "CANCEL"
|
||||
? "Stornierung"
|
||||
: "Hinweis"}
|
||||
:{" "}
|
||||
{e.sentAt
|
||||
? "Vom Maildienst angenommen"
|
||||
: e.skippedAt
|
||||
? "Nicht mehr erforderlich / Empfänger geändert"
|
||||
: e.failed
|
||||
? "Versand fehlgeschlagen – wiederholbar"
|
||||
: "Versand ausstehend"}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
<details className="border-t border-[var(--hairline-soft)] pt-4">
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
Interne Details und Verlauf
|
||||
</summary>
|
||||
<div className="mt-3 grid gap-2 text-sm">
|
||||
{a.educatorId && (
|
||||
<p>
|
||||
Ausgefallene Fachkraft:{" "}
|
||||
{educators.find((e) => e.id === a.educatorId)
|
||||
?.firstName ??
|
||||
"Ehemalige / inaktive Fachkraft"}{" "}
|
||||
{educators.find((e) => e.id === a.educatorId)
|
||||
?.lastName ?? ""}
|
||||
</p>
|
||||
)}
|
||||
{a.notes && <p>Interne Notiz: {a.notes}</p>}
|
||||
{a.cancelReason && (
|
||||
<p>Stornierungsgrund: {a.cancelReason}</p>
|
||||
)}
|
||||
{a.history.map((h) => (
|
||||
<p key={h.id}>
|
||||
{instantLabel(h.createdAt)} · {h.action}:{" "}
|
||||
{historyLabels[h.details] ?? h.details}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{ownRecipient && a.status === "PENDING" && !ended && (
|
||||
<Link
|
||||
className="inline-flex w-fit rounded-lg bg-[var(--accent-deep)] px-4 py-2 text-sm font-semibold text-white"
|
||||
href={`/alert/${ownRecipient.token}`}
|
||||
>
|
||||
Für unseren Haushalt antworten
|
||||
</Link>
|
||||
)}
|
||||
<p className="text-sm text-[var(--ink-soft)]">
|
||||
{a.status === "LATE_CONFIRMATION"
|
||||
? "Eure verspätete Zusage wartet auf Freigabe durch die Koordination."
|
||||
: a.status === "CONFIRMED"
|
||||
? "Die Kita rechnet mit euch. Änderungen bitte mit der Koordination klären."
|
||||
: a.status === "CANCELLED"
|
||||
? "Ihr müsst für diesen Alarm nicht einspringen."
|
||||
: a.status === "DECLINED"
|
||||
? "Eure Absage ist gespeichert. Die Koordination organisiert Ersatz."
|
||||
: "Eine Antwort eines Elternteils gilt für euren ganzen Haushalt."}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="rounded-2xl border border-[var(--hairline)] bg-[var(--surface)] p-8 text-sm text-[var(--ink-soft)]">
|
||||
{params.alarm
|
||||
? "Kein zugänglicher Alarm gefunden."
|
||||
: "Noch keine Notdienstalarme in diesem Zeitraum."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,11 @@ import { getTargetMonthData, getWorkingDaysOfMonth } from "@/lib/date-utils";
|
||||
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
|
||||
import { NotdienstAvailabilityReminderEmail } from "@/emails/NotdienstAvailabilityReminderEmail";
|
||||
import { NotdienstAssignmentChangeEmail } from "@/emails/NotdienstAssignmentChangeEmail";
|
||||
import { requireAlarmActor } from "@/lib/alarms/auth";
|
||||
import {
|
||||
validateAlarmActor,
|
||||
assertAssignmentNotAlarmed,
|
||||
} from "@/lib/alarms/service";
|
||||
|
||||
// =====================================================================
|
||||
// /dashboard/notdienst/plan · Server Actions
|
||||
@@ -153,41 +158,75 @@ export async function updateAssignmentAction(
|
||||
date: string,
|
||||
newChildId: string | null,
|
||||
) {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
const kitaId = session.user.kitaId!;
|
||||
const actor = await requireAlarmActor();
|
||||
const kitaId = actor.kitaId;
|
||||
|
||||
const [yr, mo, dy] = date.slice(0, 10).split("-").map(Number);
|
||||
const parsedDate = new Date(Date.UTC(yr, mo - 1, dy));
|
||||
|
||||
try {
|
||||
const notifications = await prisma.$transaction(async (tx) => {
|
||||
const plan = await tx.notdienstPlan.findUnique({
|
||||
where: { id: planId, kitaId },
|
||||
});
|
||||
const notifications = await prisma.$transaction(
|
||||
async (tx) => {
|
||||
await validateAlarmActor(tx, actor);
|
||||
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.");
|
||||
}
|
||||
|
||||
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.");
|
||||
if (!plan) {
|
||||
throw new Error("Plan nicht gefunden.");
|
||||
}
|
||||
}
|
||||
|
||||
const existingAssignment = await tx.notdienstAssignment.findFirst({
|
||||
where: { planId, date: parsedDate },
|
||||
include: {
|
||||
child: {
|
||||
const isDraft = plan.status === NotdienstPlanStatus.DRAFT;
|
||||
const isPublished = plan.status === NotdienstPlanStatus.PUBLISHED;
|
||||
|
||||
if (!isDraft && !isPublished) {
|
||||
throw new Error("Plan kann nicht bearbeitet 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.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let newChild: {
|
||||
id: string;
|
||||
family: {
|
||||
users: { email: string; firstName: string; lastName: string }[];
|
||||
};
|
||||
} | null = null;
|
||||
|
||||
if (newChildId) {
|
||||
newChild = await tx.child.findFirst({
|
||||
where: {
|
||||
id: newChildId,
|
||||
kitaId,
|
||||
active: true,
|
||||
},
|
||||
include: {
|
||||
family: {
|
||||
include: {
|
||||
@@ -198,133 +237,118 @@ export async function updateAssignmentAction(
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
let newChild:
|
||||
| {
|
||||
id: string;
|
||||
family: {
|
||||
users: { email: string; firstName: string; lastName: string }[];
|
||||
};
|
||||
if (!newChild) {
|
||||
throw new Error("Kind gehört nicht zu dieser Kita.");
|
||||
}
|
||||
| null = null;
|
||||
|
||||
if (newChildId) {
|
||||
newChild = 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 (!newChild) {
|
||||
throw new Error("Kind gehört nicht zu dieser Kita.");
|
||||
}
|
||||
}
|
||||
|
||||
if (existingAssignment?.childId === newChildId) {
|
||||
return [];
|
||||
}
|
||||
if (existingAssignment?.childId === newChildId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!newChildId) {
|
||||
if (existingAssignment) {
|
||||
await tx.notdienstAssignment.delete({
|
||||
if (existingAssignment)
|
||||
await assertAssignmentNotAlarmed(tx, existingAssignment.id, kitaId);
|
||||
|
||||
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,
|
||||
planId,
|
||||
childId: newChildId,
|
||||
date: parsedDate,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (existingAssignment) {
|
||||
await tx.notdienstAssignment.update({
|
||||
where: { id: existingAssignment.id },
|
||||
data: { childId: newChildId },
|
||||
});
|
||||
} else {
|
||||
await tx.notdienstAssignment.create({
|
||||
data: {
|
||||
kitaId,
|
||||
planId,
|
||||
childId: newChildId,
|
||||
date: parsedDate,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
if (existingAssignment) {
|
||||
await tx.notdienstSwapRequest.updateMany({
|
||||
where: {
|
||||
kitaId,
|
||||
status: "PENDING",
|
||||
OR: [
|
||||
{ targetAssignmentId: existingAssignment.id },
|
||||
{ offeredAssignmentId: existingAssignment.id },
|
||||
],
|
||||
},
|
||||
data: {
|
||||
status: "CANCELLED",
|
||||
respondedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
if (!isPublished) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
});
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
},
|
||||
{ isolationLevel: "Serializable" },
|
||||
);
|
||||
|
||||
// E-Mails außerhalb der DB-Transaktion senden
|
||||
if (notifications.length > 0) {
|
||||
@@ -334,7 +358,10 @@ export async function updateAssignmentAction(
|
||||
});
|
||||
|
||||
if (kita) {
|
||||
const BASE_URL = process.env.NEXTAUTH_URL ?? process.env.AUTH_URL ?? "http://localhost:3000";
|
||||
const BASE_URL =
|
||||
process.env.NEXTAUTH_URL ??
|
||||
process.env.AUTH_URL ??
|
||||
"http://localhost:3000";
|
||||
const link = `${BASE_URL}/dashboard/notdienst`;
|
||||
|
||||
for (const notification of notifications) {
|
||||
@@ -396,7 +423,10 @@ 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) {
|
||||
@@ -452,7 +482,9 @@ export async function sendNotdienstRemindersAction(year: number, month: number)
|
||||
},
|
||||
});
|
||||
|
||||
const respondedFamilyIds = new Set(respondedFamilies.map((a) => a.child.familyId));
|
||||
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));
|
||||
@@ -464,7 +496,10 @@ export async function sendNotdienstRemindersAction(year: number, month: number)
|
||||
// 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 BASE_URL =
|
||||
process.env.NEXTAUTH_URL ??
|
||||
process.env.AUTH_URL ??
|
||||
"http://localhost:3000";
|
||||
const link = `${BASE_URL}/dashboard/notdienst`;
|
||||
|
||||
let sentCount = 0;
|
||||
@@ -486,7 +521,10 @@ export async function sendNotdienstRemindersAction(year: number, month: number)
|
||||
if (result.success) {
|
||||
sentCount++;
|
||||
} else {
|
||||
console.error(`E-Mail konnte nicht an ${parent.email} gesendet werden:`, result.error);
|
||||
console.error(
|
||||
`E-Mail konnte nicht an ${parent.email} gesendet werden:`,
|
||||
result.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -517,7 +555,9 @@ export async function resetPlanAction(planId: string) {
|
||||
}
|
||||
|
||||
if (plan.status !== NotdienstPlanStatus.DRAFT) {
|
||||
throw new Error("Nur Pläne im Entwurfsstatus können zurückgesetzt werden.");
|
||||
throw new Error(
|
||||
"Nur Pläne im Entwurfsstatus können zurückgesetzt werden.",
|
||||
);
|
||||
}
|
||||
|
||||
// Delete all assignments
|
||||
@@ -535,6 +575,11 @@ export async function resetPlanAction(planId: string) {
|
||||
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." };
|
||||
return {
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Fehler beim Zurücksetzen des Plans.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import Link from "next/link";
|
||||
import { addMonths, getYear, getMonth, format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { TerminType, UserRole } from "@prisma/client";
|
||||
import { TerminType } from "@prisma/client";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
import { requireRole } from "@/lib/auth-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getWorkingDaysOfMonth } from "@/lib/date-utils";
|
||||
import { PlanView } from "./plan-view";
|
||||
import { SettingsModal } from "./_components/settings-modal";
|
||||
import { requireAlarmActor } from "@/lib/alarms/auth";
|
||||
import { todayKey } from "@/lib/alarms/format";
|
||||
|
||||
export const metadata = { title: "Plan-Generierung · Kita-Planer" };
|
||||
|
||||
@@ -17,14 +18,18 @@ export default async function PlanungsZentralePage({
|
||||
}: {
|
||||
searchParams: Promise<{ year?: string; month?: string }>;
|
||||
}) {
|
||||
const session = await requireRole([UserRole.ADMIN, UserRole.KOORDINATOR]);
|
||||
const kitaId = session.user.kitaId!;
|
||||
const actor = await requireAlarmActor();
|
||||
const kitaId = actor.kitaId;
|
||||
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const now = new Date();
|
||||
const nextMonth = addMonths(now, 1);
|
||||
const targetYear = resolvedSearchParams.year ? parseInt(resolvedSearchParams.year, 10) : getYear(nextMonth);
|
||||
const targetMonth = resolvedSearchParams.month ? parseInt(resolvedSearchParams.month, 10) : (getMonth(nextMonth) + 1);
|
||||
const targetYear = resolvedSearchParams.year
|
||||
? parseInt(resolvedSearchParams.year, 10)
|
||||
: getYear(nextMonth);
|
||||
const targetMonth = resolvedSearchParams.month
|
||||
? parseInt(resolvedSearchParams.month, 10)
|
||||
: getMonth(nextMonth) + 1;
|
||||
|
||||
const monthDate = new Date(targetYear, targetMonth - 1, 1);
|
||||
const monthName = format(monthDate, "MMMM", { locale: de });
|
||||
@@ -33,79 +38,84 @@ export default async function PlanungsZentralePage({
|
||||
const monthStart = new Date(targetYear, targetMonth - 1, 1);
|
||||
const monthEnd = new Date(targetYear, targetMonth, 1);
|
||||
|
||||
const [plan, families, availabilities, closureCount, kita] = await Promise.all([
|
||||
prisma.notdienstPlan.findUnique({
|
||||
where: {
|
||||
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
publishedAt: true,
|
||||
assignments: {
|
||||
select: {
|
||||
id: true,
|
||||
childId: true,
|
||||
date: true,
|
||||
createdAt: true,
|
||||
const [plan, families, availabilities, closureCount, kita] =
|
||||
await Promise.all([
|
||||
prisma.notdienstPlan.findUnique({
|
||||
where: {
|
||||
kitaId_year_month: { kitaId, year: targetYear, month: targetMonth },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
publishedAt: true,
|
||||
assignments: {
|
||||
select: {
|
||||
id: true,
|
||||
childId: true,
|
||||
date: true,
|
||||
createdAt: true,
|
||||
alarms: {
|
||||
where: { activeAssignmentId: { not: null } },
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.family.findMany({
|
||||
where: {
|
||||
kitaId,
|
||||
children: { some: { active: true } },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
children: {
|
||||
where: { active: true },
|
||||
select: { id: true },
|
||||
}),
|
||||
prisma.family.findMany({
|
||||
where: {
|
||||
kitaId,
|
||||
children: { some: { active: true } },
|
||||
},
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
prisma.notdienstAvailability.findMany({
|
||||
where: {
|
||||
kitaId,
|
||||
date: {
|
||||
gte: monthStart,
|
||||
lt: monthEnd,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
date: true,
|
||||
child: {
|
||||
select: {
|
||||
familyId: true,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
children: {
|
||||
where: { active: true },
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { date: "asc" },
|
||||
}),
|
||||
prisma.termin.count({
|
||||
where: {
|
||||
kitaId,
|
||||
type: TerminType.SCHLIESSTAG,
|
||||
startDate: {
|
||||
gte: monthStart,
|
||||
lt: monthEnd,
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
prisma.notdienstAvailability.findMany({
|
||||
where: {
|
||||
kitaId,
|
||||
date: {
|
||||
gte: monthStart,
|
||||
lt: monthEnd,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.kita.findUniqueOrThrow({
|
||||
where: { id: kitaId },
|
||||
select: {
|
||||
notdienstMinPerChildPerMonth: true,
|
||||
notdienstReminderDaysBefore: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
select: {
|
||||
date: true,
|
||||
child: {
|
||||
select: {
|
||||
familyId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { date: "asc" },
|
||||
}),
|
||||
prisma.termin.count({
|
||||
where: {
|
||||
kitaId,
|
||||
type: TerminType.SCHLIESSTAG,
|
||||
startDate: {
|
||||
gte: monthStart,
|
||||
lt: monthEnd,
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.kita.findUniqueOrThrow({
|
||||
where: { id: kitaId },
|
||||
select: {
|
||||
notdienstMinPerChildPerMonth: true,
|
||||
notdienstReminderDaysBefore: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Wir laden auch alle aktiven Kinder (inkl. Eltern-Namen) für das manuelle Dropdown.
|
||||
// Idealerweise gruppiert man das nach "Verfügbar an diesem Tag" und "Alle",
|
||||
// Idealerweise gruppiert man das nach "Verfügbar an diesem Tag" und "Alle",
|
||||
// aber für MVP reichen alle aktiven Kinder.
|
||||
const allChildrenRaw = await prisma.child.findMany({
|
||||
where: { kitaId, active: true },
|
||||
@@ -179,7 +189,7 @@ export default async function PlanungsZentralePage({
|
||||
// Wir mappen die Assignments auf die Werktage
|
||||
const daysWithAssignments = workingDays.map((day) => {
|
||||
const assignment = plan?.assignments.find(
|
||||
(a) => a.date.getTime() === day.getTime()
|
||||
(a) => a.date.getTime() === day.getTime(),
|
||||
);
|
||||
const isOriginallyUnassigned =
|
||||
!assignment ||
|
||||
@@ -190,6 +200,11 @@ export default async function PlanungsZentralePage({
|
||||
assignmentId: assignment?.id || null,
|
||||
childId: assignment?.childId || null,
|
||||
isOriginallyUnassigned: !!isOriginallyUnassigned,
|
||||
activeAlarmId: assignment?.alarms[0]?.id ?? null,
|
||||
canAlarm:
|
||||
!!assignment &&
|
||||
plan?.status === "PUBLISHED" &&
|
||||
day.toISOString().slice(0, 10) >= todayKey(),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { NotdienstPlanStatus } from "@prisma/client";
|
||||
@@ -40,6 +41,8 @@ interface PlanViewProps {
|
||||
days: {
|
||||
date: string;
|
||||
assignmentId: string | null;
|
||||
activeAlarmId?: string | null;
|
||||
canAlarm?: boolean;
|
||||
childId: string | null;
|
||||
isOriginallyUnassigned?: boolean;
|
||||
}[];
|
||||
@@ -92,7 +95,9 @@ export function PlanView({
|
||||
const [resetDialogOpen, setResetDialogOpen] = useState(false);
|
||||
|
||||
const responsePercent =
|
||||
totalFamilies > 0 ? Math.round((respondedFamilies / totalFamilies) * 100) : 0;
|
||||
totalFamilies > 0
|
||||
? Math.round((respondedFamilies / totalFamilies) * 100)
|
||||
: 0;
|
||||
|
||||
const handleGenerate = () => {
|
||||
startTransition(async () => {
|
||||
@@ -123,7 +128,9 @@ export function PlanView({
|
||||
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.`);
|
||||
toast.success(
|
||||
`${result.count} Erinnerungs-E-Mail(s) erfolgreich versendet.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -173,7 +180,11 @@ export function PlanView({
|
||||
<Button disabled={isPending} onClick={handleGenerate}>
|
||||
Trotzdem generieren
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={handleSendReminders} disabled={isPending}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleSendReminders}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin [stroke-width:1.7]" />
|
||||
) : (
|
||||
@@ -408,7 +419,8 @@ export function PlanView({
|
||||
<DialogHeader>
|
||||
<DialogTitle>Plan zurücksetzen?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Möchtest du den Plan wirklich zurücksetzen? Alle Zuteilungen für diesen Monat gehen unwiderruflich verloren.
|
||||
Möchtest du den Plan wirklich zurücksetzen? Alle Zuteilungen für
|
||||
diesen Monat gehen unwiderruflich verloren.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -542,6 +554,21 @@ function PlanPreviewTable({
|
||||
({assignedChild.name})
|
||||
</span>
|
||||
)}
|
||||
{day.activeAlarmId ? (
|
||||
<Link
|
||||
className="mt-2 block text-xs font-semibold text-[var(--accent-deep)] hover:underline"
|
||||
href={`/dashboard/notdienst/alarm?alarm=${day.activeAlarmId}`}
|
||||
>
|
||||
Aktiven Alarm öffnen
|
||||
</Link>
|
||||
) : day.canAlarm && day.assignmentId ? (
|
||||
<Link
|
||||
className="mt-2 block text-xs font-semibold text-[var(--accent-deep)] hover:underline"
|
||||
href={`/dashboard/notdienst/alarm?assignment=${day.assignmentId}`}
|
||||
>
|
||||
Notdienstalarm auslösen
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="italic text-[var(--ink-faint)]">Noch offen</div>
|
||||
<div>
|
||||
@@ -616,7 +643,7 @@ function ManualAssignmentTable({
|
||||
<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}
|
||||
disabled={isPending || !!day.activeAlarmId}
|
||||
onChange={(event) =>
|
||||
onAssignmentChange(day.date, event.target.value)
|
||||
}
|
||||
@@ -624,7 +651,9 @@ function ManualAssignmentTable({
|
||||
<option value="empty">-- Unbesetzt --</option>
|
||||
{allChildren.map((child) => {
|
||||
const famId = child.familyId;
|
||||
const count = famId ? (familyAssignmentCounts.get(famId) ?? 0) : 0;
|
||||
const count = famId
|
||||
? (familyAssignmentCounts.get(famId) ?? 0)
|
||||
: 0;
|
||||
return (
|
||||
<option key={child.id} value={child.id}>
|
||||
{child.parentName} ({child.name}) — {count}x
|
||||
@@ -637,6 +666,20 @@ function ManualAssignmentTable({
|
||||
Veröffentlicht
|
||||
</span>
|
||||
)}
|
||||
{day.assignmentId && day.canAlarm && (
|
||||
<Link
|
||||
className="text-xs font-semibold text-[var(--accent-deep)] underline underline-offset-4"
|
||||
href={
|
||||
day.activeAlarmId
|
||||
? `/dashboard/notdienst/alarm?alarm=${day.activeAlarmId}`
|
||||
: `/dashboard/notdienst/alarm?assignment=${day.assignmentId}`
|
||||
}
|
||||
>
|
||||
{day.activeAlarmId
|
||||
? "Aktiven Alarm öffnen"
|
||||
: "Notdienstalarm auslösen"}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ import { prisma } from "@/lib/prisma";
|
||||
import { Stat } from "@/components/ui/stat";
|
||||
import { OnboardingDialog } from "@/components/OnboardingDialog";
|
||||
import { AbsenceCard, UpcomingAbsencesCard } from "./absence-card";
|
||||
import { AlarmDashboardHints } from "./notdienst/alarm/dashboard-hints";
|
||||
|
||||
export const metadata = { title: "Übersicht · Kita-Planer" };
|
||||
|
||||
@@ -200,6 +201,7 @@ export default async function DashboardPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<AlarmDashboardHints />
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_360px] gap-5 max-[1100px]:grid-cols-1">
|
||||
<div className="min-w-0">
|
||||
{session.user.role === UserRole.ERZIEHER && (
|
||||
|
||||
Reference in New Issue
Block a user