320 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|