Files
kita-planer/src/app/dashboard/admin/news/[id]/page.tsx
T

320 lines
12 KiB
TypeScript

import Link from "next/link";
import { notFound } from "next/navigation";
import { format } from "date-fns";
import { ArrowLeft, FileText, Mail } from "lucide-react";
import { requireRole } from "@/lib/auth-utils";
import { prisma } from "@/lib/prisma";
import { announcementAudienceLabels } from "@/lib/announcement-access";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { EditNewsForm, RetryNewsEmails } from "../edit-news-form";
export const metadata = { title: "Mitteilung bearbeiten · Kita-Planer" };
export default async function EditNewsPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ page?: string }>;
}) {
const session = await requireRole(["ADMIN"]);
const { id } = await params;
const announcement = await prisma.announcement.findFirst({
where: { id, kitaId: session.user.kitaId! },
include: {
attachments: { select: { id: true, fileName: true, fileUrl: true } },
},
});
if (!announcement) notFound();
const rawPage = Number((await searchParams).page ?? 1);
const page =
Number.isSafeInteger(rawPage) && rawPage > 0
? Math.min(rawPage, 100000)
: 1;
const where = {
kitaId: session.user.kitaId!,
announcementId: id,
revision: announcement.revision,
};
const [statuses, emails, older] = await Promise.all([
prisma.announcementEmail.findMany({
where,
select: { sentAt: true, skippedAt: true, failed: true },
}),
prisma.announcementEmail.findMany({
where,
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
take: 51,
skip: (page - 1) * 50,
select: {
id: true,
sentAt: true,
skippedAt: true,
failed: true,
attempts: true,
leaseUntil: true,
lastError: true,
user: { select: { firstName: true, lastName: true, email: true } },
},
}),
prisma.announcementEmail.findMany({
where: {
kitaId: session.user.kitaId!,
announcementId: id,
revision: { lt: announcement.revision },
},
select: { revision: true, sentAt: true, skippedAt: true, failed: true },
}),
]);
const history = new Map<
number,
{ sent: number; skipped: number; failed: number }
>();
for (const job of older) {
const summary = history.get(job.revision) ?? {
sent: 0,
skipped: 0,
failed: 0,
};
if (job.sentAt) summary.sent++;
else if (job.skippedAt) summary.skipped++;
else if (job.failed) summary.failed++;
history.set(job.revision, summary);
}
const backgroundConfigured =
(process.env.NOTDIENST_JOB_SECRET?.length ?? 0) >= 32;
const sent = statuses.filter((job) => job.sentAt).length;
const skipped = statuses.filter((job) => job.skippedAt).length;
const failed = statuses.filter(
(job) => !job.sentAt && !job.skippedAt && job.failed,
).length;
const pending = statuses.length - sent - skipped - failed;
return (
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
<Button asChild variant="ghost" size="sm" className="mb-5">
<Link href="/dashboard/admin/news#beitraege">
<ArrowLeft className="h-4 w-4" />
Alle Beiträge
</Link>
</Button>
<header className="mb-7">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--accent-deep)]">
SCHWARZES BRETT · BEITRAG
</p>
<h1 className="mt-2 text-[36px] tracking-[-0.025em]">
Mitteilung{" "}
<em className="font-normal text-[var(--accent-deep)]">bearbeiten</em>
</h1>
<p className="mt-2 text-sm text-[var(--ink-soft)]">
Veröffentlicht am {format(announcement.createdAt, "dd.MM.yyyy HH:mm")}
{announcement.editedAt &&
` · Aktualisiert am ${format(announcement.editedAt, "dd.MM.yyyy HH:mm")}`}
</p>
</header>
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,0.8fr)]">
<Card>
<CardHeader>
<CardTitle>Inhalt korrigieren</CardTitle>
<CardDescription>
Empfängergruppe und bestehende Anhänge bleiben bei einer Korrektur
erhalten.
</CardDescription>
<Badge variant="outline" className="mt-2 w-fit">
{announcementAudienceLabels[announcement.audience]}
</Badge>
</CardHeader>
<CardContent>
<EditNewsForm
key={announcement.revision}
announcement={{
id,
revision: announcement.revision,
title: announcement.title,
content: announcement.content,
}}
/>
{announcement.attachments.length > 0 && (
<div className="mt-6 border-t border-[var(--hairline)] pt-4">
<h2 className="mb-2 text-sm font-medium">Anhänge</h2>
<div className="grid gap-2">
{announcement.attachments.map((a) => (
<a
key={a.id}
href={a.fileUrl}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 break-all text-sm text-[var(--accent-deep)] underline"
>
<FileText className="h-4 w-4 shrink-0" />
{a.fileName}
</a>
))}
</div>
</div>
)}
</CardContent>
</Card>
<Card id="versand">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="h-4 w-4" />
E-Mail-Versand
</CardTitle>
<CardDescription>
Status der aktuellen Fassung. „Gesendet“ bedeutet, dass der
Maildienst die Nachricht angenommen hat.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4">
<div className="flex flex-wrap gap-2">
<Badge variant="success">{sent} gesendet</Badge>
<Badge variant="secondary">{pending} ausstehend</Badge>
<Badge variant="outline">{failed} fehlgeschlagen</Badge>
{skipped > 0 && (
<Badge variant="outline">{skipped} übersprungen</Badge>
)}
</div>
{!backgroundConfigured && (
<p className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-3 text-sm leading-6">
Automatische Wiederholungen sind noch nicht eingerichtet. Bitte
wende dich an die Hosting-Administration; fehlgeschlagene
E-Mails kannst du hier selbst erneut versuchen.
</p>
)}
{!announcement.emailReady && (
<p className="text-sm text-[var(--ink-soft)]">
Die Anhänge werden noch vorbereitet. Der Versand beginnt erst,
wenn alle Dateien gespeichert sind.
</p>
)}
<p className="text-xs leading-5 text-[var(--ink-soft)]">
Bei eingerichteter Automatik werden fehlgeschlagene Sendungen
erneut versucht. Deaktivierte Konten und Personen, die nicht mehr
zur Empfängergruppe gehören, werden übersprungen.
</p>
{failed > 0 && (
<div className="[&_button]:h-auto [&_button]:whitespace-normal">
<RetryNewsEmails
id={id}
revision={announcement.revision}
failed={failed}
/>
</div>
)}
<Button
asChild
variant="ghost"
size="sm"
className="justify-self-start"
>
<Link href={`/dashboard/admin/news/${id}?page=${page}#versand`}>
Status aktualisieren
</Link>
</Button>
{statuses.length === 0 && (
<p className="rounded-lg bg-[var(--surface-2)] p-4 text-sm leading-6 text-[var(--ink-soft)]">
Für diese Fassung sind keine E-Mails vorgemerkt. Bei Beiträgen
aus der Zeit vor Einführung dieser Übersicht liegt kein
damaliger Versandstatus vor. Über das Formular kannst du die
aktuelle Fassung einmalig versenden.
</p>
)}
<div className="grid gap-2">
{emails.slice(0, 50).map((job) => (
<div
key={job.id}
className="min-w-0 rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-3"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<p className="text-sm font-medium">
{job.user.firstName} {job.user.lastName}
</p>
<Badge variant={job.sentAt ? "success" : "outline"}>
{job.sentAt
? "Gesendet"
: job.skippedAt
? "Übersprungen"
: job.failed
? "Fehlgeschlagen"
: job.leaseUntil && job.leaseUntil > new Date()
? "Wird versendet"
: "Ausstehend"}
</Badge>
</div>
<p className="mt-1 break-all text-xs text-[var(--ink-muted)]">
{job.user.email}
</p>
{job.sentAt && (
<p className="mt-1 text-xs text-[var(--ink-muted)]">
{format(job.sentAt, "dd.MM.yyyy HH:mm")}
</p>
)}
{job.lastError && (
<p className="mt-2 text-xs leading-5 text-[var(--ink-soft)]">
{job.lastError}
</p>
)}
</div>
))}
</div>
{history.size > 0 && (
<details className="rounded-lg border border-[var(--hairline)] p-3">
<summary className="cursor-pointer text-sm font-medium">
Versand früherer Fassungen
</summary>
<div className="mt-3 grid gap-2">
{[...history]
.sort(([a], [b]) => b - a)
.map(([revision, summary]) => (
<p
key={revision}
className="text-xs leading-5 text-[var(--ink-soft)]"
>
Fassung {revision}: {summary.sent} gesendet ·{" "}
{summary.skipped} übersprungen · {summary.failed}{" "}
fehlgeschlagen
</p>
))}
</div>
<p className="mt-3 text-xs leading-5 text-[var(--ink-muted)]">
Ungesendete E-Mails älterer Fassungen wurden durch die
Korrektur ersetzt.
</p>
</details>
)}
{(page > 1 || emails.length > 50) && (
<nav
aria-label="Empfänger durchblättern"
className="flex flex-wrap justify-between gap-3"
>
<div>
{page > 1 && (
<Button asChild variant="outline" size="sm">
<Link href={`?page=${page - 1}#versand`}>Zurück</Link>
</Button>
)}
</div>
<span className="text-sm">Seite {page}</span>
<div>
{emails.length > 50 && (
<Button asChild variant="outline" size="sm">
<Link href={`?page=${page + 1}#versand`}>Weitere</Link>
</Button>
)}
</div>
</nav>
)}
</CardContent>
</Card>
</div>
</div>
);
}