feat: add household emergency duty alarms and deadline worker

This commit is contained in:
t.indorf
2026-10-09 11:02:24 +02:00
parent a96ba0fd84
commit 8b32e4a832
34 changed files with 3947 additions and 646 deletions
+408
View File
@@ -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>
);
}