Add announcement editing and reliable per-recipient mail delivery
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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={
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user