409 lines
17 KiB
TypeScript
409 lines
17 KiB
TypeScript
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>
|
||
);
|
||
}
|