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
+100 -113
View File
@@ -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>
);
}