Files
kita-planer/src/app/dashboard/notdienst/alarm/page.tsx
T

409 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}