feat: add household emergency duty alarms and deadline worker
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user