Add announcement editing and reliable per-recipient mail delivery

This commit is contained in:
t.indorf
2026-10-13 08:53:22 +02:00
parent 661776e609
commit 1472779781
22 changed files with 1655 additions and 190 deletions
+19 -3
View File
@@ -3,6 +3,8 @@ import { authorizeAlarmJob, runAlarmJob } from "@/lib/alarms/job";
import { runNotdienstNotificationJob } from "@/lib/notdienst-notifications";
import { deliverDueAnnouncementEmails } from "@/lib/announcement-delivery";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
@@ -18,12 +20,26 @@ export async function POST(request: Request) {
{ status },
);
try {
const [alarm, planning] = await Promise.allSettled([runAlarmJob(), runNotdienstNotificationJob()]);
if (alarm.status === "rejected" || planning.status === "rejected") throw new Error("Ein Hintergrundlauf ist fehlgeschlagen.");
const result = { ...alarm.value, planning: planning.value };
const [alarm, planning, announcements] = await Promise.allSettled([
runAlarmJob(),
runNotdienstNotificationJob(),
deliverDueAnnouncementEmails(),
]);
if (
alarm.status === "rejected" ||
planning.status === "rejected" ||
announcements.status === "rejected"
)
throw new Error("Ein Hintergrundlauf ist fehlgeschlagen.");
const result = {
...alarm.value,
planning: planning.value,
announcements: announcements.value,
};
revalidatePath("/dashboard");
revalidatePath("/dashboard/notdienst/alarm");
revalidatePath("/dashboard/notdienst/plan");
revalidatePath("/dashboard/admin/news");
return Response.json(result);
} catch {
return Response.json(
+319
View File
@@ -0,0 +1,319 @@
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>
);
}
@@ -0,0 +1,174 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Loader2, RotateCw, Save } from "lucide-react";
import {
updateAnnouncement,
retryAnnouncementEmails,
} from "@/actions/announcements";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { MarkdownContent } from "@/components/markdown-content";
export function EditNewsForm({
announcement,
}: {
announcement: {
id: string;
revision: number;
title: string;
content: string;
};
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [content, setContent] = useState(announcement.content);
const [sendEmail, setSendEmail] = useState(false);
function submit(formData: FormData) {
formData.set("id", announcement.id);
formData.set("revision", String(announcement.revision));
formData.set("sendEmail", sendEmail ? "on" : "off");
startTransition(async () => {
try {
const result = await updateAnnouncement(formData);
if (!("success" in result)) {
toast.error(result.error);
return;
}
toast.success(
result.queued
? `Mitteilung gespeichert. ${result.queued} E-Mails sind zum Versand vorgemerkt.`
: result.changed
? "Mitteilung aktualisiert."
: "Keine Änderungen vorhanden.",
);
setSendEmail(false);
router.refresh();
} catch {
toast.error(
"Mitteilung konnte nicht gespeichert werden. Bitte versuche es erneut.",
);
}
});
}
return (
<form action={submit} className="grid gap-5">
<div className="grid gap-2">
<Label htmlFor="edit-title">Titel</Label>
<Input
id="edit-title"
name="title"
defaultValue={announcement.title}
minLength={3}
maxLength={160}
required
disabled={pending}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-content">Text</Label>
<Textarea
id="edit-content"
name="content"
rows={10}
value={content}
onChange={(event) => setContent(event.target.value)}
minLength={3}
maxLength={20_000}
required
disabled={pending}
/>
<p className="text-xs text-[var(--ink-muted)]">
Markdown ist aktiv. HTML wird nicht interpretiert.
</p>
</div>
<details className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4">
<summary className="cursor-pointer text-sm font-medium">
Vorschau ansehen
</summary>
<div className="mt-4 text-sm">
<MarkdownContent content={content} />
</div>
</details>
<div className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-4">
<div className="flex items-start gap-2">
<Checkbox
id="correction-email"
checked={sendEmail}
disabled={pending}
onCheckedChange={(checked) => setSendEmail(checked === true)}
/>
<Label
htmlFor="correction-email"
className="cursor-pointer leading-5"
>
Aktuelle Fassung auch per E-Mail senden
</Label>
</div>
<p className="mt-2 text-xs leading-5 text-[var(--ink-soft)]">
Bei einer Textänderung erscheint der Beitrag für alle wieder als
ungelesen. Jede Fassung wird je Person höchstens einmal zum Versand
vorgemerkt.
</p>
</div>
<Button type="submit" disabled={pending} className="justify-self-end">
{pending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
Änderungen speichern
</Button>
</form>
);
}
export function RetryNewsEmails({
id,
revision,
failed,
}: {
id: string;
revision: number;
failed: number;
}) {
const [pending, startTransition] = useTransition();
const router = useRouter();
return (
<Button
variant="outline"
disabled={pending || failed === 0}
onClick={() =>
startTransition(async () => {
try {
const result = await retryAnnouncementEmails(id, revision);
if (!("success" in result)) {
toast.error(result.error);
return;
}
toast.success(
result.queued
? `${result.queued} fehlgeschlagene E-Mails werden erneut versucht.`
: "Keine fehlgeschlagenen Sendungen vorhanden.",
);
router.refresh();
} catch {
toast.error("Versand konnte nicht gestartet werden.");
}
})
}
>
{pending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RotateCw className="h-4 w-4" />
)}
Fehlgeschlagene erneut versuchen
</Button>
);
}
+31 -11
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useTransition } from "react";
import { useRef, useState, useTransition } from "react";
import { Loader2, Send, UploadCloud } from "lucide-react";
import { toast } from "sonner";
@@ -20,6 +20,7 @@ import { Textarea } from "@/components/ui/textarea";
import { MarkdownContent } from "@/components/markdown-content";
export function NewsForm() {
const formRef = useRef<HTMLFormElement>(null);
const [isPending, startTransition] = useTransition();
const [sendEmail, setSendEmail] = useState(false);
const [content, setContent] = useState("");
@@ -30,27 +31,43 @@ export function NewsForm() {
formData.set("sendEmail", "on");
}
const result = await createAnnouncement(formData);
if (result.error) {
toast.error(result.error);
return;
}
try {
const result = await createAnnouncement(formData);
if (result.error) {
toast.error(result.error);
return;
}
if (result.warning) {
toast.warning(result.warning);
} else {
toast.success("Ankündigung veröffentlicht.");
if (result.warning) {
toast.warning(result.warning);
} else {
toast.success(
sendEmail
? "Ankündigung veröffentlicht. E-Mails sind zum Versand vorgemerkt."
: "Ankündigung veröffentlicht.",
);
}
formRef.current?.reset();
setContent("");
setSendEmail(false);
} catch {
toast.error(
"Ankündigung konnte nicht veröffentlicht werden. Bitte versuche es erneut.",
);
}
});
}
return (
<form action={handleSubmit} className="grid gap-5">
<form ref={formRef} action={handleSubmit} className="grid gap-5">
<div className="grid gap-2">
<Label htmlFor="news-title">Titel</Label>
<Input
id="news-title"
name="title"
minLength={3}
maxLength={160}
disabled={isPending}
placeholder="Sommerfest: Aufbau und Mitbringliste"
required
/>
@@ -62,6 +79,9 @@ export function NewsForm() {
id="news-content"
name="content"
rows={10}
minLength={3}
maxLength={20_000}
disabled={isPending}
value={content}
onChange={(event) => setContent(event.target.value)}
placeholder={
+141 -48
View File
@@ -1,3 +1,7 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { FileText, Megaphone, Send } from "lucide-react";
@@ -18,7 +22,11 @@ import { NewsForm } from "./news-form";
export const metadata = { title: "Schwarzes Brett · Kita-Planer" };
export default async function AdminNewsPage() {
export default async function AdminNewsPage({
searchParams,
}: {
searchParams: Promise<{ q?: string; page?: string }>;
}) {
const session = await requireRole([UserRole.ADMIN]);
const kitaId = session.user.kitaId;
@@ -26,29 +34,46 @@ export default async function AdminNewsPage() {
throw new Error("Kein Mandant zugeordnet.");
}
const announcements = await prisma.announcement.findMany({
where: { kitaId },
select: {
id: true,
title: true,
audience: true,
createdAt: true,
attachments: {
select: {
id: true,
fileName: true,
},
const params = await searchParams;
const q = typeof params.q === "string" ? params.q.trim().slice(0, 160) : "";
const parsedPage = Number(params.page ?? 1);
const page =
Number.isSafeInteger(parsedPage) && parsedPage > 0
? Math.min(parsedPage, 100000)
: 1;
const pageSize = 12;
const where = {
kitaId,
...(q
? {
OR: [
{ title: { contains: q, mode: "insensitive" as const } },
{ content: { contains: q, mode: "insensitive" as const } },
],
}
: {}),
};
const [announcements, total] = await Promise.all([
prisma.announcement.findMany({
where,
select: {
id: true,
title: true,
audience: true,
createdAt: true,
editedAt: true,
revision: true,
_count: { select: { attachments: true } },
author: { select: { firstName: true, lastName: true } },
},
author: {
select: {
firstName: true,
lastName: true,
},
},
},
orderBy: { createdAt: "desc" },
take: 8,
});
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.announcement.count({ where }),
]);
const href = (p: number) =>
`/dashboard/admin/news?${new URLSearchParams({ ...(q ? { q } : {}), page: String(p) })}#beitraege`;
return (
<div className="mx-auto max-w-[1280px] px-11 py-9 pb-16 max-[760px]:px-5">
@@ -60,7 +85,7 @@ export default async function AdminNewsPage() {
<h1 className="mt-2 text-[36px] font-normal leading-tight tracking-[-0.025em] text-[var(--ink)]">
Meldungen{" "}
<em className="font-normal italic text-[var(--accent-deep)]">
veröffentlichen
verwalten
</em>
</h1>
<p className="mt-2 max-w-2xl text-[15px] leading-6 text-[var(--ink-soft)]">
@@ -71,42 +96,77 @@ export default async function AdminNewsPage() {
</div>
<span className="inline-flex h-9 items-center gap-2 rounded-full border border-[var(--hairline)] bg-[var(--surface)] px-4 text-[13px] font-semibold text-[var(--ink-soft)] tabular-nums">
<Megaphone className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
{announcements.length} zuletzt
{total} {total === 1 ? "Beitrag" : "Beiträge"}
</span>
</header>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_380px]">
<Card>
<div className="grid gap-6">
<details
open={!q && page === 1}
className="rounded-xl border border-[var(--hairline)] bg-[var(--surface)] p-5"
>
<summary className="cursor-pointer text-lg font-semibold">
Neue Ankündigung veröffentlichen
</summary>
<Card className="mt-4 border-0 shadow-none">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Send className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
Neue Ankündigung
</CardTitle>
<CardDescription>
Markdown wird sicher gerendert; PDF, JPG und PNG können als
geschützte Anhänge hinzugefügt werden.
</CardDescription>
</CardHeader>
<CardContent>
<NewsForm />
</CardContent>
</Card>
</details>
<Card id="beitraege" className="h-fit">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Send className="h-4 w-4 text-[var(--accent-deep)] [stroke-width:1.7]" />
Neue Ankündigung
<CardTitle className="text-base">
Veröffentlichte Beiträge
</CardTitle>
<CardDescription>
Markdown wird sicher gerendert; PDF, JPG und PNG können als
geschützte Anhänge hinzugefügt werden.
</CardDescription>
</CardHeader>
<CardContent>
<NewsForm />
</CardContent>
</Card>
<Card className="h-fit">
<CardHeader>
<CardTitle className="text-base">Zuletzt veröffentlicht</CardTitle>
<CardDescription>
Die neuesten Meldungen auf dem Schwarzen Brett.
Korrigiere auch ältere Beiträge und prüfe den E-Mail-Versand.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3">
<form
className="mb-4 flex flex-wrap items-end gap-3"
action="/dashboard/admin/news#beitraege"
>
<div className="grid flex-1 gap-2">
<Label htmlFor="news-search">Beiträge durchsuchen</Label>
<Input
id="news-search"
name="q"
defaultValue={q}
maxLength={160}
placeholder="Titel oder Text"
/>
</div>
<Button type="submit" variant="outline">
Suchen
</Button>
{q && (
<Button asChild variant="ghost">
<Link href="/dashboard/admin/news#beitraege">
Alle Beiträge
</Link>
</Button>
)}
</form>
{announcements.length === 0 ? (
<div className="flex max-h-[280px] min-h-[220px] flex-col items-center justify-center rounded-lg border border-dashed border-[var(--hairline)] bg-[var(--surface-2)] p-6 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-[var(--accent-soft)] text-[var(--accent-deep)]">
<Megaphone className="h-6 w-6 [stroke-width:1.7]" />
</div>
<p className="text-sm font-medium text-[var(--ink)]">
Noch keine Ankündigungen
Keine Beiträge gefunden
</p>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Veröffentlichte Meldungen erscheinen hier.
@@ -119,13 +179,13 @@ export default async function AdminNewsPage() {
className="rounded-lg border border-[var(--hairline)] bg-[var(--surface-2)] p-3"
>
<div className="mb-2 flex items-start justify-between gap-2">
<p className="font-medium leading-snug text-[var(--ink)]">
<p className="min-w-0 break-words font-medium leading-snug text-[var(--ink)]">
{announcement.title}
</p>
{announcement.attachments.length > 0 && (
{announcement._count.attachments > 0 && (
<Badge variant="secondary" className="shrink-0">
<FileText className="h-3 w-3 [stroke-width:1.7]" />
{announcement.attachments.length}
{announcement._count.attachments}
</Badge>
)}
</div>
@@ -139,9 +199,42 @@ export default async function AdminNewsPage() {
· {announcement.author.firstName}{" "}
{announcement.author.lastName}
</p>
{announcement.editedAt && (
<p className="mt-1 text-xs text-[var(--accent-deep)]">
Aktualisiert am{" "}
{format(announcement.editedAt, "dd.MM.yyyy HH:mm")}
</p>
)}
<Button asChild variant="outline" size="sm" className="mt-3">
<Link href={`/dashboard/admin/news/${announcement.id}`}>
Bearbeiten &amp; Versand
</Link>
</Button>
</div>
))
)}
<nav
aria-label="Beiträge durchblättern"
className="mt-4 flex flex-wrap items-center justify-between gap-3 border-t border-[var(--hairline)] pt-4"
>
<div>
{page > 1 && (
<Button asChild variant="outline" size="sm">
<Link href={href(page - 1)}>Neuere Beiträge</Link>
</Button>
)}
</div>
<span className="text-sm text-[var(--ink-soft)]">
Seite {page} · {total} {total === 1 ? "Beitrag" : "Beiträge"}
</span>
<div>
{page * pageSize < total && (
<Button asChild variant="outline" size="sm">
<Link href={href(page + 1)}>Ältere Beiträge</Link>
</Button>
)}
</div>
</nav>
</CardContent>
</Card>
</div>
+8 -4
View File
@@ -26,6 +26,8 @@ import { MarkdownContent } from "@/components/markdown-content";
export type NewsTickerItemDto = {
id: string;
revision: number;
editedAt: string | null;
title: string;
content: string;
excerpt: string;
@@ -53,16 +55,16 @@ export function NewsTicker({
function openAnnouncement(item: NewsTickerItemDto) {
setSelected(item);
if (!item.isUnread || readIds.has(item.id)) return;
if (!item.isUnread || readIds.has(`${item.id}:${item.revision}`)) return;
startTransition(async () => {
try {
const result = await markAnnouncementRead(item.id);
const result = await markAnnouncementRead(item.id, item.revision);
if (result.error) {
toast.error(result.error);
return;
}
setReadIds((current) => new Set(current).add(item.id));
setReadIds((current) => new Set(current).add(`${item.id}:${item.revision}`));
} catch {
toast.error("Lesestatus konnte nicht gespeichert werden.");
}
@@ -101,7 +103,7 @@ export function NewsTicker({
</p>
) : null}
{items.map((item) => {
const isUnread = item.isUnread && !readIds.has(item.id);
const isUnread = item.isUnread && !readIds.has(`${item.id}:${item.revision}`);
return (
<button
key={item.id}
@@ -116,6 +118,7 @@ export function NewsTicker({
</div>
{isUnread && <Badge variant="success">NEU</Badge>}
</div>
{item.editedAt && <p className="mb-2 text-xs font-medium text-[var(--accent-deep)]">Aktualisiert am {item.editedAt}</p>}
<h3 className="line-clamp-2 font-semibold leading-snug text-[var(--ink)]">
{item.title}
</h3>
@@ -146,6 +149,7 @@ export function NewsTicker({
<DialogTitle className="text-2xl">{selected.title}</DialogTitle>
<DialogDescription>
{selected.createdAt} · {selected.authorName}
{selected.editedAt && <span className="block">Aktualisiert am {selected.editedAt}</span>}
</DialogDescription>
</DialogHeader>