Files
EpicNext-Cms/src/app/admin/alerts/page.tsx
T
simoleo89andCursor 0e89d03940 Finish fine-grained ACL across remaining admin pages and actions.
Replace leftover requireStaff gates with module PERMS, drop hardcoded room rank thresholds, and expand contract tests so admin mutations cannot regress to dashboard-only checks.

Co-authored-by: Cursor <[email protected]>
2026-07-15 20:15:22 +02:00

188 lines
4.9 KiB
TypeScript

import { Bell } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { sendHotelAlert } from "@/actions/admin-alerts";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export const dynamic = "force-dynamic";
const CRITICAL = new Set(["critical", "error", "danger"]);
const WARNING = new Set(["warning", "warn"]);
function fromDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
}
function severityColor(severity: string): string {
switch (severity.toLowerCase()) {
case "critical":
case "error":
case "danger":
return "var(--color-danger)";
case "warning":
case "warn":
return "var(--admin-accent)";
case "success":
return "var(--color-secondary)";
case "info":
case "notice":
return "var(--color-accent)";
default:
return "var(--admin-text-muted)";
}
}
function Badge({ label, color }: { label: string; color: string }) {
return (
<span
style={{
display: "inline-block",
padding: "0.1rem 0.55rem",
borderRadius: "999px",
background: color,
color: "#fff",
fontSize: "0.72rem",
fontWeight: 700,
letterSpacing: "0.02em",
textTransform: "uppercase",
whiteSpace: "nowrap",
}}
>
{label}
</span>
);
}
export default async function AdminAlerts() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.NOTIFICATIONS_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.alerts");
let alerts: Awaited<ReturnType<typeof prisma.alertLogs.findMany>>;
try {
alerts = await prisma.alertLogs.findMany({
orderBy: { id: "desc" },
take: 100,
});
} catch {
alerts = [];
}
const critical = alerts.filter((a) =>
CRITICAL.has((a.severity || "").toLowerCase()),
).length;
const warnings = alerts.filter((a) =>
WARNING.has((a.severity || "").toLowerCase()),
).length;
const unread = alerts.filter((a) => !a.isRead).length;
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Bell size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={t("alertsShown")} value={alerts.length} icon="🔔" />
<StatusCard
label={t("critical")}
value={critical}
state={critical > 0 ? "danger" : "ok"}
icon="🚨"
/>
<StatusCard
label={t("warnings")}
value={warnings}
state={warnings > 0 ? "warn" : "neutral"}
icon="⚠️"
/>
<StatusCard
label={t("unread")}
value={unread}
state={unread > 0 ? "warn" : "ok"}
icon="📨"
/>
</div>
<form action={sendHotelAlert} className="admin-card mb-6">
<h3 className="mt-0">{t("form.send")}</h3>
<p className="text-xs theme-text-muted dark:theme-text-muted">
{t.rich("form.description", {
code: (chunks) => <code>{chunks}</code>,
})}
</p>
<div className="flex gap-2 flex-wrap items-start">
<textarea
name="message"
required
maxLength={1000}
rows={2}
placeholder={t("form.messagePlaceholder")}
className="flex-1 min-w-[240px]"
/>
<button type="submit" className="btn btn-primary">
{t("form.send")}
</button>
</div>
</form>
<section className="admin-card">
<h3 className="mt-0">{t("recentAlerts", { count: alerts.length })}</h3>
{alerts.length === 0 ? (
<div className="admin-empty">{t("noAlerts")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>{t("colWhen")}</th>
<th>{t("colSeverity")}</th>
<th>{t("colType")}</th>
<th>{t("colMessage")}</th>
</tr>
</thead>
<tbody>
{alerts.map((a) => (
<tr key={String(a.id)}>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{fromDate(a.createdAt)}
</td>
<td>
<Badge
label={a.severity || "—"}
color={severityColor(a.severity)}
/>
</td>
<td>
<Badge
label={a.type || "—"}
color="var(--admin-text-muted)"
/>
</td>
<td>{a.message}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</main>
);
}