Files
pitchfast/components/audits/audits-board.tsx

298 lines
10 KiB
TypeScript

"use client";
import { useMemo, useState } from "react";
import { useQuery } from "convex/react";
import { FunctionReturnType } from "convex/server";
import { Activity, Files, FileSearch, SquarePen } from "lucide-react";
import Link from "next/link";
import { api } from "@/convex/_generated/api";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
type AuditDashboardRowsResult = FunctionReturnType<typeof api.audits.listDashboardRows>;
type AuditRow = Extract<
NonNullable<AuditDashboardRowsResult>[number],
{ kind: "audit" }
>;
type AuditDashboardRow = NonNullable<AuditDashboardRowsResult>[number];
type AuditStatusFilter = "all" | "audit" | "generation" | "failed";
const statusText: Record<string, string> = {
draft: "Entwurf",
approved: "Freigegeben",
published: "Veröffentlicht",
deactivated: "Deaktiviert",
};
const fallbackStatus = "Unbekannt";
const generationStageText: Record<string, string> = {
audit_generation: "Audit-Generierung",
classification: "Klassifikation",
multimodalAudit: "Multimodale Analyse",
germanCopy: "Deutsche Texte",
qualityReview: "Qualitätsprüfung",
};
function formatPageCount(pageCount: number) {
return `${pageCount} Seite${pageCount === 1 ? "" : "n"}`;
}
function getStatusLabel(status: AuditRow["status"]) {
return statusText[status] ?? fallbackStatus;
}
function getGenerationStatusLabel(
row: Extract<AuditDashboardRow, { kind: "generation" }>,
) {
if (row.status === "pending") {
return "Wartet auf Start";
}
if (row.status === "failed") {
return "Fehlgeschlagen";
}
if (row.status === "canceled") {
return "Abgebrochen";
}
if (row.status === "succeeded") {
return "Wartet auf finales Audit";
}
return "Generierung läuft";
}
function getStageLabel(stage: string) {
return generationStageText[stage] ?? stage;
}
function AuditsBoardLoading() {
return (
<section className="space-y-4">
<header className="agency-panel space-y-2 p-4">
<p className="agency-kicker">Evidence Dossier</p>
<h1 className="font-heading text-2xl font-semibold tracking-normal">Audits</h1>
<p className="text-sm text-muted-foreground">Audits werden geladen...</p>
</header>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 4 }, (_, index) => (
<Skeleton className="h-40 rounded-lg" key={index} />
))}
</div>
</section>
);
}
export function AuditsBoard() {
const dashboardRows = useQuery(api.audits.listDashboardRows, { limit: 100 });
const [activeFilter, setActiveFilter] = useState<AuditStatusFilter>("all");
const rows = useMemo(() => {
if (!dashboardRows) {
return [];
}
return [...dashboardRows].sort((a, b) => b.updatedAt - a.updatedAt);
}, [dashboardRows]);
const statusCounts = useMemo(() => {
return {
all: rows.length,
audit: rows.filter((row) => row.kind === "audit").length,
generation: rows.filter((row) => row.kind === "generation").length,
failed: rows.filter(
(row) => row.kind === "generation" && row.status === "failed",
).length,
};
}, [rows]);
const visibleRows = useMemo(() => {
if (activeFilter === "audit") {
return rows.filter((row) => row.kind === "audit");
}
if (activeFilter === "generation") {
return rows.filter((row) => row.kind === "generation");
}
if (activeFilter === "failed") {
return rows.filter(
(row) => row.kind === "generation" && row.status === "failed",
);
}
return rows;
}, [activeFilter, rows]);
const auditStatusFilters: Array<{
label: string;
value: AuditStatusFilter;
count: number;
}> = [
{ label: "Alle", value: "all", count: statusCounts.all },
{ label: "Audits", value: "audit", count: statusCounts.audit },
{ label: "Pipeline", value: "generation", count: statusCounts.generation },
{ label: "Fehlgeschlagen", value: "failed", count: statusCounts.failed },
];
if (dashboardRows === undefined) {
return <AuditsBoardLoading />;
}
if (rows.length === 0) {
return (
<section className="space-y-4">
<header className="agency-panel space-y-2 p-4">
<p className="agency-kicker">Evidence Dossier</p>
<h1 className="font-heading text-2xl font-semibold tracking-normal">Audits</h1>
</header>
<Card className="agency-panel">
<CardHeader>
<h2 className="text-sm font-medium">Noch keine Audits</h2>
<CardDescription>
Sobald neue Audits oder laufende Audit-Generierungen angelegt
wurden, erscheinen sie hier als kompakte Cards.
</CardDescription>
</CardHeader>
</Card>
</section>
);
}
return (
<section className="space-y-4">
<header className="agency-panel space-y-2 p-4">
<p className="agency-kicker">Evidence Dossier</p>
<h1 className="font-heading text-2xl font-semibold tracking-normal">Audits</h1>
<p className="max-w-3xl text-sm leading-6 text-muted-foreground">
Laufende Generierungen, veröffentlichbare Audits und Fehlerzustände
als Prüfmappe statt lose Datensatzliste.
</p>
</header>
<div className="flex flex-wrap gap-2" aria-label="Audit-Filter">
{auditStatusFilters.map((filter) => (
<button
aria-pressed={activeFilter === filter.value}
className="agency-tab"
key={filter.value}
onClick={() => setActiveFilter(filter.value)}
type="button"
>
{filter.label}
<Badge variant="secondary">{filter.count}</Badge>
</button>
))}
</div>
<section
className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"
aria-label="Audit-Cards"
>
{visibleRows.map((row: AuditDashboardRow) => {
const rowTitleId = `audit-row-title-${row.id}`;
return (
<Card
aria-labelledby={rowTitleId}
className="agency-panel flex min-w-0 flex-col overflow-hidden"
key={row.id}
>
<CardHeader className="gap-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<CardDescription className="inline-flex items-center gap-2">
<FileSearch className="size-3.5" aria-hidden="true" />
{row.kind === "audit" ? "Audit Evidence" : "Pipeline Evidence"}
</CardDescription>
<CardTitle className="mt-1 break-words text-base" id={rowTitleId}>
{row.title}
</CardTitle>
</div>
<Badge variant={row.kind === "audit" ? "secondary" : "outline"}>
{row.kind === "audit"
? getStatusLabel(row.status)
: getGenerationStatusLabel(row)}
</Badge>
</div>
</CardHeader>
<CardContent className="flex flex-1 flex-col gap-4">
<div className="grid gap-3 text-sm">
<div className="evidence-surface min-w-0 rounded-md px-3 py-2">
<p className="text-xs font-medium text-muted-foreground">Domain</p>
<p className="mt-1 break-all">{row.checkedDomain}</p>
</div>
<div className="min-w-0 rounded-md border border-border/75 bg-background/60 p-3">
<p className="text-xs font-medium text-muted-foreground">
{row.kind === "audit" ? "Seiten" : "Phase"}
</p>
<p className="mt-1 inline-flex items-center gap-1 text-muted-foreground">
{row.kind === "audit" ? (
<>
<Files className="size-3.5" aria-hidden="true" />
{formatPageCount(row.pageCount)}
</>
) : (
<>
<Activity className="size-3.5" aria-hidden="true" />
{getStageLabel(row.latestStage)}
</>
)}
</p>
</div>
<div className="min-w-0 rounded-md bg-muted/45 p-3">
<p className="text-xs font-medium text-muted-foreground">Slug</p>
<p className="mt-1 break-words text-muted-foreground">
{row.kind === "generation" ? `Run ${row.runId}` : row.title}
</p>
</div>
{row.kind === "generation" && row.errorSummary ? (
<p className="break-words rounded-md border border-destructive/30 bg-[var(--danger-soft)] px-3 py-2 text-xs text-destructive">
{row.errorSummary}
</p>
) : null}
</div>
<div className="mt-auto flex justify-end">
{row.kind === "audit" ? (
<Link
className="inline-flex min-h-8 items-center gap-1 rounded-md px-2 text-sm font-semibold text-primary hover:bg-muted"
href={row.detailHref}
>
<SquarePen className="size-4" aria-hidden="true" />
Öffnen
</Link>
) : (
<span className="inline-flex min-h-8 items-center text-sm text-muted-foreground">
Pipeline läuft
</span>
)}
</div>
</CardContent>
</Card>
);
})}
{visibleRows.length === 0 ? (
<Card className="agency-panel sm:col-span-2 xl:col-span-3">
<CardHeader>
<CardTitle>Keine Treffer</CardTitle>
<CardDescription>
Für diesen Filter gibt es aktuell keine Audit-Einträge.
</CardDescription>
</CardHeader>
</Card>
) : null}
</section>
</section>
);
}