Improve audit pipeline and outreach review

This commit is contained in:
2026-06-08 22:16:32 +02:00
parent ff18fc202e
commit 1695110e0a
34 changed files with 2792 additions and 238 deletions

View File

@@ -10,6 +10,7 @@ import { Badge } from "@/components/ui/badge";
import { Globe } from "lucide-react";
type UsedSkill = {
id?: string;
name: string;
purpose?: string;
category?: string;
@@ -17,6 +18,12 @@ type UsedSkill = {
version?: string;
};
type SkillSummary = {
name: string;
purpose: string;
summary: string;
};
type LeadContext = {
_id: Id<"leads">;
companyName?: string;
@@ -35,9 +42,35 @@ type SkillAwareAudit = {
createdAt?: number;
updatedAt?: number;
usedSkills?: UsedSkill[];
skillSummaries?: SkillSummary[];
internalSummary?: string | null;
};
type AuditFindingEvidenceRef = {
id: string;
type:
| "crawl_page"
| "technical_check"
| "screenshot"
| "pagespeed"
| "jina_excerpt"
| "generation_stage";
label: string;
sourceUrl?: string;
};
type AuditFinding = {
_id: string;
skillId: string;
claim: string;
recommendation: string;
customerBenefit: string;
severity: 1 | 2 | 3;
confidence: number;
evidenceRefs: AuditFindingEvidenceRef[];
reviewStatus: "pending" | "accepted" | "rejected";
};
type CheckedPageScreenshot = {
id: Id<"_storage">;
url: string;
@@ -69,6 +102,7 @@ type CheckedPageEvidence = {
type AuditDetailResult = {
audit: SkillAwareAudit;
lead: LeadContext | null;
findings: AuditFinding[];
sourceSummaries: {
checkedPages: CheckedPageEvidence[];
};
@@ -121,6 +155,19 @@ function metaSignalText(page: CheckedPageEvidence) {
return "Unbekannt";
}
function evidenceTypeLabel(type: AuditFindingEvidenceRef["type"]) {
const labels: Record<AuditFindingEvidenceRef["type"], string> = {
crawl_page: "Crawl",
technical_check: "Technik",
screenshot: "Screenshot",
pagespeed: "PageSpeed",
jina_excerpt: "Reader",
generation_stage: "KI-Stufe",
};
return labels[type] ?? type;
}
function leadSummary(lead: LeadContext | null | undefined) {
if (!lead) {
return "Kein Lead-Kontext gespeichert";
@@ -156,7 +203,20 @@ export function AuditDetail({ id }: { id: string | Id<"audits"> }) {
const audit = result?.audit;
const lead = result?.lead;
const usedSkills = useMemo(() => audit?.usedSkills ?? [], [audit]);
const usedSkills = useMemo(() => {
const summaries = audit?.skillSummaries ?? [];
return (audit?.usedSkills ?? []).map((skill) => {
const summary = summaries.find(
(candidate) => candidate.name === skill.name || candidate.name === skill.id,
);
return {
...skill,
purpose: summary?.purpose ?? skill.purpose,
summary: summary?.summary,
};
});
}, [audit]);
const findings = useMemo(() => result?.findings ?? [], [result]);
const checkedPageEvidence = useMemo(
() => result?.sourceSummaries.checkedPages ?? [],
[result],
@@ -220,6 +280,56 @@ export function AuditDetail({ id }: { id: string | Id<"audits"> }) {
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Geprüfte Befunde</CardTitle>
<CardDescription>
Verifizierte Aussagen mit konkreten Belegen aus Crawl, Screenshots und Messungen.
</CardDescription>
</CardHeader>
<CardContent>
{findings.length === 0 ? (
<p className="text-sm text-muted-foreground">
Noch keine verifizierten Befunde gespeichert.
</p>
) : (
<ul className="grid gap-3">
{findings.map((finding) => (
<li className="rounded-md border p-3 text-sm" key={finding._id}>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-medium">{finding.claim}</p>
<p className="mt-2 text-muted-foreground">
{finding.customerBenefit}
</p>
</div>
<div className="flex flex-wrap gap-1">
<Badge variant={finding.severity === 3 ? "secondary" : "outline"}>
Priorität {finding.severity}
</Badge>
<Badge variant="outline">
{Math.round(finding.confidence * 100)}% sicher
</Badge>
</div>
</div>
<p className="mt-3">
<span className="font-medium">Empfehlung: </span>
{finding.recommendation}
</p>
<div className="mt-3 flex flex-wrap gap-2">
{finding.evidenceRefs.map((ref) => (
<Badge variant="outline" key={ref.id}>
Quelle: {evidenceTypeLabel(ref.type)} · {ref.label}
</Badge>
))}
</div>
</li>
))}
</ul>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Geprüfte Seiten</CardTitle>
@@ -322,6 +432,9 @@ export function AuditDetail({ id }: { id: string | Id<"audits"> }) {
<p className="text-sm text-muted-foreground">
{skill.purpose ?? "Keine Zweckbeschreibung"}
</p>
{"summary" in skill && skill.summary ? (
<p className="text-sm text-muted-foreground">{skill.summary}</p>
) : null}
<p className="mt-1 inline-flex flex-wrap items-center gap-1">
{skill.category ? <Badge variant="outline">{skill.category}</Badge> : null}
{skill.version ? <Badge variant="outline">{skill.version}</Badge> : null}

View File

@@ -1,6 +1,6 @@
"use client";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { useQuery } from "convex/react";
import { FunctionReturnType } from "convex/server";
@@ -10,10 +10,21 @@ 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 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",
@@ -74,12 +85,10 @@ function AuditsBoardLoading() {
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
<p className="text-sm text-muted-foreground">Audits werden geladen...</p>
</header>
<div className="rounded-lg border">
<div className="grid gap-2 p-3">
{Array.from({ length: 4 }, (_, index) => (
<Skeleton className="h-20 rounded-md" key={index} />
))}
</div>
<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>
);
@@ -87,6 +96,7 @@ function AuditsBoardLoading() {
export function AuditsBoard() {
const dashboardRows = useQuery(api.audits.listDashboardRows, { limit: 100 });
const [activeFilter, setActiveFilter] = useState<AuditStatusFilter>("all");
const rows = useMemo(() => {
if (!dashboardRows) {
return [];
@@ -94,6 +104,43 @@ export function AuditsBoard() {
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 />;
@@ -107,13 +154,15 @@ export function AuditsBoard() {
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
</header>
<article className="rounded-lg border p-4">
<h2 className="text-sm font-medium">Noch keine Audits</h2>
<p className="mt-1 text-sm text-muted-foreground">
Sobald neue Audits oder laufende Audit-Generierungen angelegt
wurden, erscheinen sie hier als kompakte Zeilen.
</p>
</article>
<Card>
<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>
);
}
@@ -125,74 +174,118 @@ export function AuditsBoard() {
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
</header>
<section className="space-y-2 overflow-x-auto">
<div className="grid min-w-[840px] grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_170px_150px_auto] gap-2 rounded-md border bg-muted/40 px-3 py-2 text-xs font-medium text-muted-foreground">
<span>Slug</span>
<span>Domain</span>
<span>Status</span>
<span>Seiten/Phase</span>
<span className="text-right">Aktion</span>
</div>
<div className="flex flex-wrap gap-2" aria-label="Audit-Filter">
{auditStatusFilters.map((filter) => (
<button
aria-pressed={activeFilter === filter.value}
className="inline-flex min-h-8 items-center gap-2 rounded-md border px-3 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted aria-pressed:border-foreground aria-pressed:text-foreground"
key={filter.value}
onClick={() => setActiveFilter(filter.value)}
type="button"
>
{filter.label}
<Badge variant="secondary">{filter.count}</Badge>
</button>
))}
</div>
<div className="space-y-2">
{rows.map((row: AuditDashboardRow) => (
<article
className="grid min-w-[840px] grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_170px_150px_auto] items-center gap-2 rounded-lg border px-3 py-2 text-sm"
<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="flex min-w-0 flex-col"
key={row.id}
>
<div className="min-w-0">
<p className="truncate font-medium">{row.title}</p>
{row.kind === "generation" ? (
<p className="truncate text-xs text-muted-foreground">
Run {row.runId}
</p>
) : null}
</div>
<p className="truncate text-muted-foreground">{row.checkedDomain}</p>
<Badge
className="h-auto min-h-6 justify-center whitespace-normal text-center"
variant="secondary"
>
{row.kind === "audit"
? getStatusLabel(row.status)
: getGenerationStatusLabel(row)}
</Badge>
<p className="text-muted-foreground">
<span className="inline-flex items-center gap-1">
<CardHeader className="gap-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<CardDescription>
{row.kind === "audit" ? "Audit" : "Pipeline"}
</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="min-w-0">
<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">
<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">
<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 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
{row.errorSummary}
</p>
) : null}
</div>
<div className="mt-auto flex justify-end">
{row.kind === "audit" ? (
<>
<Files className="size-3.5" />
{formatPageCount(row.pageCount)}
</>
<Link
className="inline-flex min-h-8 items-center gap-1 rounded-md px-2 text-sm text-primary hover:bg-muted"
href={row.detailHref}
>
<SquarePen className="size-4" aria-hidden="true" />
Öffnen
</Link>
) : (
<>
<Activity className="size-3.5" />
{getStageLabel(row.latestStage)}
</>
<span className="inline-flex min-h-8 items-center text-sm text-muted-foreground">
Pipeline läuft
</span>
)}
</span>
{row.kind === "generation" && row.errorSummary ? (
<span className="mt-1 block truncate text-xs">
{row.errorSummary}
</span>
) : null}
</p>
<div className="flex justify-end">
{row.kind === "audit" ? (
<Link
className="inline-flex min-h-8 items-center gap-1 text-sm text-primary"
href={row.detailHref}
>
<SquarePen className="size-4" />
Öffnen
</Link>
) : (
<span className="text-sm text-muted-foreground">Pipeline</span>
)}
</div>
</article>
))}
</div>
</div>
</CardContent>
</Card>
);
})}
{visibleRows.length === 0 ? (
<Card className="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>
);

View File

@@ -284,80 +284,92 @@ export function CampaignsBoard() {
</CardHeader>
</Card>
) : (
<div className="grid gap-3">
{campaignsSorted.map((campaign) => (
<Card key={campaign._id}>
<CardHeader>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<CardTitle className="truncate">{campaign.name}</CardTitle>
<CardDescription className="truncate">
{formatNiche(campaign)}
</CardDescription>
</div>
<Badge
variant={campaign.status === "active" ? "default" : "secondary"}
>
{campaign.status === "active" ? "Aktiv" : "Pausiert"}
</Badge>
</div>
</CardHeader>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{campaignsSorted.map((campaign) => {
const campaignTitleId = `campaign-title-${campaign._id}`;
<CardContent className="grid gap-2 text-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="inline-flex items-center gap-1 text-muted-foreground">
<MapPin className="size-3" />
<span>{campaign.postalCode}</span>
return (
<Card aria-labelledby={campaignTitleId} key={campaign._id}>
<CardHeader>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<CardTitle className="truncate" id={campaignTitleId}>
{campaign.name}
</CardTitle>
<CardDescription className="truncate">
{formatNiche(campaign)}
</CardDescription>
</div>
<Badge
variant={campaign.status === "active" ? "default" : "secondary"}
>
{campaign.status === "active" ? "Aktiv" : "Pausiert"}
</Badge>
</div>
<span>{campaign.radiusKm} km</span>
</div>
<Separator className="bg-border" />
<div>
<p>Cadence: {recurrenceLabel[campaign.recurrence]}</p>
<p>
Limits: L {campaign.maxNewLeadsPerRun}, A{" "}
{campaign.maxAuditsPerRun}
</p>
</div>
<div>
<p className="text-muted-foreground">Letzter Lauf: {formatDateTime(campaign.lastRunAt)}</p>
<p className="text-muted-foreground">Nächster Lauf: {formatDateTime(campaign.nextRunAt)}</p>
<p className="text-muted-foreground">
Run-Status: {statusLabel[campaign.currentRunStatus] ?? campaign.currentRunStatus}
</p>
</div>
</CardHeader>
<div className="grid gap-2">
<Button
variant="outline"
onClick={() => openEditDialog(campaign)}
disabled={actionBusyId === campaign._id}
className="w-full justify-start"
>
<Pencil className="size-4" />
Bearbeiten
</Button>
<Button
variant="outline"
onClick={() => toggleCampaign(campaign)}
disabled={actionBusyId === campaign._id}
className="w-full justify-start"
>
<RefreshCcw className="size-4" />
{campaign.status === "active" ? "Pausieren" : "Fortfahren"}
</Button>
<Button
onClick={() => runCampaign(campaign)}
disabled={actionBusyId === campaign._id}
className="w-full justify-start"
>
<Play className="size-4" />
Jetzt ausführen
</Button>
</div>
</CardContent>
</Card>
))}
<CardContent className="grid gap-2 text-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="inline-flex items-center gap-1 text-muted-foreground">
<MapPin className="size-3" />
<span>{campaign.postalCode}</span>
</div>
<span>{campaign.radiusKm} km</span>
</div>
<Separator className="bg-border" />
<div>
<p>Cadence: {recurrenceLabel[campaign.recurrence]}</p>
<p>
Limits: L {campaign.maxNewLeadsPerRun}, A{" "}
{campaign.maxAuditsPerRun}
</p>
</div>
<div>
<p className="text-muted-foreground">
Letzter Lauf: {formatDateTime(campaign.lastRunAt)}
</p>
<p className="text-muted-foreground">
Nächster Lauf: {formatDateTime(campaign.nextRunAt)}
</p>
<p className="text-muted-foreground">
Run-Status:{" "}
{statusLabel[campaign.currentRunStatus] ??
campaign.currentRunStatus}
</p>
</div>
<div className="grid gap-2">
<Button
variant="outline"
onClick={() => openEditDialog(campaign)}
disabled={actionBusyId === campaign._id}
className="w-full justify-start"
>
<Pencil className="size-4" />
Bearbeiten
</Button>
<Button
variant="outline"
onClick={() => toggleCampaign(campaign)}
disabled={actionBusyId === campaign._id}
className="w-full justify-start"
>
<RefreshCcw className="size-4" />
{campaign.status === "active" ? "Pausieren" : "Fortfahren"}
</Button>
<Button
onClick={() => runCampaign(campaign)}
disabled={actionBusyId === campaign._id}
className="w-full justify-start"
>
<Play className="size-4" />
Jetzt ausführen
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
)}

View File

@@ -23,7 +23,16 @@ import {
} from "@/lib/dashboard-model";
import { Button } from "@/components/ui/button";
import { Card, CardHeader } from "@/components/ui/card";
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
@@ -63,6 +72,7 @@ type LeadReviewPayload = {
reviewContactPerson?: string;
reviewIsBusinessContactAddress?: boolean;
};
type LeadStatusFilter = "all" | "high" | "blocked";
function normalizeTextInput(value: string): string | undefined {
const next = value.trim();
@@ -132,6 +142,7 @@ function duplicateBadgeVariant(
export function LeadsReviewTable() {
const leads = useQuery(api.leads.list, { limit: 120 });
const [actionMessage, setActionMessage] = useState<string | null>(null);
const [activeFilter, setActiveFilter] = useState<LeadStatusFilter>("all");
const sortedLeads = useMemo(() => {
if (!leads) {
@@ -140,6 +151,30 @@ export function LeadsReviewTable() {
return [...leads].sort((a, b) => b.createdAt - a.createdAt);
}, [leads]);
const filteredLeads = useMemo(() => {
if (activeFilter === "high") {
return sortedLeads.filter((lead) => lead.priority === "high");
}
if (activeFilter === "blocked") {
return sortedLeads.filter((lead) => lead.blacklistStatus === "blocked");
}
return sortedLeads;
}, [activeFilter, sortedLeads]);
const leadStatusFilters: Array<{ label: string; value: LeadStatusFilter; count: number }> = [
{ label: "Alle Leads", value: "all", count: sortedLeads.length },
{
label: "Hohe Priorität",
value: "high",
count: sortedLeads.filter((lead) => lead.priority === "high").length,
},
{
label: "Gesperrt",
value: "blocked",
count: sortedLeads.filter((lead) => lead.blacklistStatus === "blocked").length,
},
];
return (
<section className="space-y-4 px-4 py-5 sm:px-6 lg:px-8">
@@ -148,16 +183,52 @@ export function LeadsReviewTable() {
<h1 className="text-2xl font-semibold tracking-normal">Leads prüfen</h1>
</div>
<div className="mx-auto flex w-full max-w-7xl flex-wrap gap-2" aria-label="Lead-Filter">
{leadStatusFilters.map((filter) => (
<button
aria-pressed={activeFilter === filter.value}
className="inline-flex min-h-8 items-center gap-2 rounded-md border px-3 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted aria-pressed:border-foreground aria-pressed:text-foreground"
key={filter.value}
onClick={() => setActiveFilter(filter.value)}
type="button"
>
{filter.label}
<Badge variant="secondary">{filter.count}</Badge>
</button>
))}
</div>
<div className="mx-auto grid w-full max-w-7xl gap-3">
{leads === undefined ? (
<p className="rounded-md bg-muted p-4 text-sm">Leads werden geladen</p>
Array.from({ length: 4 }, (_, index) => (
<Card key={index}>
<CardHeader>
<div className="h-5 w-2/3 rounded-md bg-muted" />
<div className="h-4 w-1/2 rounded-md bg-muted" />
<div className="mt-2 h-12 rounded-md bg-muted" />
</CardHeader>
</Card>
))
) : sortedLeads.length === 0 ? (
<p className="rounded-md border p-4 text-sm text-muted-foreground">
Keine Leads vorhanden. Bitte zuerst eine Kampagne starten oder
importieren.
</p>
<Card>
<CardHeader>
<p className="text-sm font-medium">Keine Leads vorhanden</p>
<p className="text-sm text-muted-foreground">
Bitte zuerst eine Kampagne starten oder importieren.
</p>
</CardHeader>
</Card>
) : filteredLeads.length === 0 ? (
<Card>
<CardHeader>
<p className="text-sm font-medium">Keine Treffer</p>
<p className="text-sm text-muted-foreground">
Für diesen Filter sind aktuell keine Leads vorhanden.
</p>
</CardHeader>
</Card>
) : (
sortedLeads.map((lead) => (
filteredLeads.map((lead) => (
<LeadReviewRow
key={lead._id}
lead={lead}
@@ -168,7 +239,7 @@ export function LeadsReviewTable() {
</div>
{actionMessage ? (
<p className="mx-auto max-w-7xl text-sm text-muted-foreground">
<p className="mx-auto max-w-7xl text-sm text-muted-foreground" role="status">
{actionMessage}
</p>
) : null}
@@ -183,7 +254,7 @@ function LeadReviewRow({
lead: LeadRow;
onActionMessage: (value: string) => void;
}) {
const [isExpanded, setIsExpanded] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [draft, setDraft] = useState<LeadReviewDraft>(() => ({
priority: lead.priority,
contactStatus: lead.contactStatus,
@@ -279,14 +350,26 @@ function LeadReviewRow({
};
const detailsId = `lead-review-details-${lead._id}`;
const titleId = `lead-review-title-${lead._id}`;
const priorityId = `lead-priority-${lead._id}`;
const contactStatusId = `lead-contact-status-${lead._id}`;
const priorityReasonId = `lead-priority-reason-${lead._id}`;
const contactReasonId = `lead-contact-reason-${lead._id}`;
const notesId = `lead-notes-${lead._id}`;
const reviewEmailId = `lead-review-email-${lead._id}`;
const reviewSourceId = `lead-review-source-${lead._id}`;
const contactPersonId = `lead-contact-person-${lead._id}`;
const businessContactId = `lead-business-contact-${lead._id}`;
const duplicateStatusId = `lead-duplicate-status-${lead._id}`;
const blacklistStatusId = `lead-blacklist-status-${lead._id}`;
return (
<Card>
<Card aria-labelledby={titleId}>
<CardHeader className="pb-3">
<div className="grid min-w-0 gap-2">
<div className="flex min-w-0 flex-wrap items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="max-w-full truncate font-medium">
<p className="max-w-full truncate font-medium" id={titleId}>
{lead.companyName}
</p>
<p className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground">
@@ -339,24 +422,35 @@ function LeadReviewRow({
<Button
type="button"
variant="outline"
onClick={() => setIsExpanded((previous) => !previous)}
aria-expanded={isExpanded}
aria-controls={detailsId}
onClick={() => setIsDialogOpen(true)}
size="sm"
>
{isExpanded ? "Weniger anzeigen" : "Mehr anzeigen"}
Mehr anzeigen
</Button>
</div>
<div
id={detailsId}
className="grid gap-3 border-t p-4"
hidden={!isExpanded}
<Dialog
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
>
<DialogContent
className="max-h-[calc(100dvh-2rem)] max-w-5xl overflow-y-auto"
id={detailsId}
>
<DialogHeader>
<div>
<DialogTitle>{lead.companyName} prüfen</DialogTitle>
<DialogDescription>
Priorität, Kontaktstatus, Duplikate und Kontaktinformationen bearbeiten.
</DialogDescription>
</div>
<DialogCloseButton />
</DialogHeader>
<div className="grid gap-3 xl:grid-cols-2">
<section className="grid gap-2">
<div>
<p className="text-xs text-muted-foreground">Priorität</p>
<Label className="text-xs text-muted-foreground" htmlFor={priorityId}>Priorität</Label>
<div className="mt-2">
<Select
value={draft.priority}
@@ -364,7 +458,7 @@ function LeadReviewRow({
updateDraft("priority", nextPriority as LeadPriority)
}
>
<SelectTrigger>
<SelectTrigger id={priorityId}>
<SelectValue placeholder="Priorität" />
</SelectTrigger>
<SelectContent>
@@ -379,7 +473,7 @@ function LeadReviewRow({
</div>
<div>
<p className="text-xs text-muted-foreground">Kontaktstatus</p>
<Label className="text-xs text-muted-foreground" htmlFor={contactStatusId}>Kontaktstatus</Label>
<div className="mt-2">
<Select
value={draft.contactStatus}
@@ -387,7 +481,7 @@ function LeadReviewRow({
updateDraft("contactStatus", nextStatus as LeadContactStatus)
}
>
<SelectTrigger>
<SelectTrigger id={contactStatusId}>
<SelectValue placeholder="Kontaktstatus" />
</SelectTrigger>
<SelectContent>
@@ -404,8 +498,9 @@ function LeadReviewRow({
<section className="grid gap-2">
<div>
<p className="text-xs text-muted-foreground">Prioritätsgrund</p>
<Label className="text-xs text-muted-foreground" htmlFor={priorityReasonId}>Prioritätsgrund</Label>
<Input
id={priorityReasonId}
value={draft.priorityReason}
onChange={(event) => {
updateDraft("priorityReason", event.target.value);
@@ -413,10 +508,11 @@ function LeadReviewRow({
/>
</div>
<div>
<p className="mt-2 text-xs text-muted-foreground">
<Label className="mt-2 text-xs text-muted-foreground" htmlFor={contactReasonId}>
Kontaktstatus-Notiz
</p>
</Label>
<Input
id={contactReasonId}
value={draft.contactStatusReason}
onChange={(event) => {
updateDraft("contactStatusReason", event.target.value);
@@ -424,8 +520,9 @@ function LeadReviewRow({
/>
</div>
<div>
<p className="mt-2 text-xs text-muted-foreground">Notiz</p>
<Label className="mt-2 text-xs text-muted-foreground" htmlFor={notesId}>Notiz</Label>
<Input
id={notesId}
value={draft.notes}
onChange={(event) => {
updateDraft("notes", event.target.value);
@@ -443,8 +540,9 @@ function LeadReviewRow({
<section className="grid gap-2">
<div>
<p className="text-xs text-muted-foreground">Review-E-Mail</p>
<Label className="text-xs text-muted-foreground" htmlFor={reviewEmailId}>Review-E-Mail</Label>
<Input
id={reviewEmailId}
value={draft.reviewEmail}
onChange={(event) => {
updateDraft("reviewEmail", event.target.value);
@@ -453,8 +551,9 @@ function LeadReviewRow({
</div>
<div>
<p className="mt-2 text-xs text-muted-foreground">Review-Quelle</p>
<Label className="mt-2 text-xs text-muted-foreground" htmlFor={reviewSourceId}>Review-Quelle</Label>
<Input
id={reviewSourceId}
value={draft.reviewEmailSource}
onChange={(event) => {
updateDraft("reviewEmailSource", event.target.value);
@@ -462,28 +561,30 @@ function LeadReviewRow({
/>
</div>
<div>
<p className="mt-2 text-xs text-muted-foreground">Ansprechperson</p>
<Label className="mt-2 text-xs text-muted-foreground" htmlFor={contactPersonId}>Ansprechperson</Label>
<Input
id={contactPersonId}
value={draft.reviewContactPerson}
onChange={(event) => {
updateDraft("reviewContactPerson", event.target.value);
}}
/>
</div>
<label className="mt-2 inline-flex items-center gap-2 text-xs text-muted-foreground">
<Label className="mt-2 inline-flex items-center gap-2 text-xs text-muted-foreground" htmlFor={businessContactId}>
<Switch
id={businessContactId}
checked={draft.reviewIsBusinessContactAddress}
onCheckedChange={(checked) => {
updateDraft("reviewIsBusinessContactAddress", checked);
}}
/>
Genannte E-Mail als Business-Kontakt
</label>
</Label>
</section>
<section className="grid gap-2">
<div>
<p className="text-xs text-muted-foreground">Duplikatstatus</p>
<Label className="text-xs text-muted-foreground" htmlFor={duplicateStatusId}>Duplikatstatus</Label>
<div className="mt-2">
<Select
value={draft.duplicateStatus}
@@ -491,7 +592,7 @@ function LeadReviewRow({
updateDraft("duplicateStatus", nextStatus as LeadDuplicateStatus)
}
>
<SelectTrigger>
<SelectTrigger id={duplicateStatusId}>
<SelectValue placeholder="Duplikatstatus" />
</SelectTrigger>
<SelectContent>
@@ -506,7 +607,7 @@ function LeadReviewRow({
</div>
<div>
<label className="text-xs text-muted-foreground">Sperrstatus</label>
<Label className="text-xs text-muted-foreground" htmlFor={blacklistStatusId}>Sperrstatus</Label>
<div className="mt-2">
<Select
value={draft.blacklistStatus}
@@ -514,7 +615,7 @@ function LeadReviewRow({
updateDraft("blacklistStatus", nextStatus as LeadBlacklistStatus)
}
>
<SelectTrigger>
<SelectTrigger id={blacklistStatusId}>
<SelectValue placeholder="Sperrstatus" />
</SelectTrigger>
<SelectContent>
@@ -557,11 +658,16 @@ function LeadReviewRow({
</Button>
</div>
{rowMessage ? (
<p className="text-xs text-muted-foreground">{rowMessage}</p>
rowMessage === "Speichern fehlgeschlagen" ? (
<p className="text-xs text-destructive" role="alert">{rowMessage}</p>
) : (
<p className="text-xs text-muted-foreground" role="status">{rowMessage}</p>
)
) : null}
</section>
</div>
</div>
</DialogContent>
</Dialog>
</Card>
);
}

View File

@@ -10,7 +10,7 @@ import Link from "next/link";
import { api } from "@/convex/_generated/api";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogCloseButton,
@@ -45,6 +45,7 @@ type PendingEmailConfirmation = {
sender: string;
auditSlug: string | null;
};
type ReviewStatusFilter = "all" | "ready" | "mail_open";
const emptyDraft: DraftState = {
auditBody: "",
@@ -124,6 +125,20 @@ function skillLabel(skill: UsedSkill) {
return skill.category ? `${name} · ${skill.category}` : name;
}
function isEmailDraftReady(record: ReviewWorkspaceItem) {
const outreach = record.latestOutreach;
return Boolean(outreach?.emailSubject?.trim() && outreach.emailBody?.trim());
}
function isReadyToSend(record: ReviewWorkspaceItem) {
return Boolean(
record.latestOutreach &&
record.latestOutreach.sendStatus !== "queued" &&
isEmailDraftReady(record),
);
}
function DetailToggle({
isOpen,
label,
@@ -187,10 +202,40 @@ export function OutreachReviewWorkspace() {
const [openRaw, setOpenRaw] = useState<Record<string, boolean>>({});
const [busyAction, setBusyAction] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [activeFilter, setActiveFilter] = useState<ReviewStatusFilter>("all");
const [selectedRecordId, setSelectedRecordId] = useState<string | null>(null);
const [pendingEmailConfirmation, setPendingEmailConfirmation] =
useState<PendingEmailConfirmation | null>(null);
const rows = useMemo<ReviewWorkspaceItem[]>(() => records ?? [], [records]);
const reviewStatusFilters: Array<{ label: string; value: ReviewStatusFilter; count: number }> = [
{ label: "Alle Reviews", value: "all", count: rows.length },
{
label: "Bereit zum Versand",
value: "ready",
count: rows.filter(isReadyToSend).length,
},
{
label: "Mail offen",
value: "mail_open",
count: rows.filter((row) => !isEmailDraftReady(row)).length,
},
];
const filteredRows = useMemo(() => {
if (activeFilter === "ready") {
return rows.filter(isReadyToSend);
}
if (activeFilter === "mail_open") {
return rows.filter((row) => !isEmailDraftReady(row));
}
return rows;
}, [activeFilter, rows]);
const selectedRecord =
filteredRows.find((row) => row.id === selectedRecordId) ??
filteredRows[0] ??
null;
if (records === undefined) {
return <WorkspaceLoading />;
@@ -447,7 +492,7 @@ export function OutreachReviewWorkspace() {
</header>
{notice ? (
<p className="rounded-md border bg-muted/30 px-3 py-2 text-sm">{notice}</p>
<p className="rounded-md border bg-muted/30 px-3 py-2 text-sm" role="status">{notice}</p>
) : null}
<Dialog
@@ -525,8 +570,107 @@ export function OutreachReviewWorkspace() {
) : null}
</Dialog>
<section className="space-y-3" aria-label="Review-Queue">
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-sm font-semibold">Review-Queue</h2>
<div className="flex flex-wrap gap-2" aria-label="Review-Filter">
{reviewStatusFilters.map((filter) => (
<button
aria-pressed={activeFilter === filter.value}
className="inline-flex min-h-8 items-center gap-2 rounded-md border px-3 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted aria-pressed:border-foreground aria-pressed:text-foreground"
key={filter.value}
onClick={() => setActiveFilter(filter.value)}
type="button"
>
{filter.label}
<Badge variant="secondary">{filter.count}</Badge>
</button>
))}
</div>
</div>
<div className="grid gap-3 lg:grid-cols-2 xl:grid-cols-3">
{filteredRows.map((record) => {
const lead = record.lead;
const audit = record.audit;
const outreach = record.latestOutreach;
const strategy = outreach?.strategy;
const publicAuditHref = audit?.slug ? `/audit/${audit.slug}` : null;
const queueTitleId = `review-queue-title-${record.id}`;
return (
<Card
aria-labelledby={queueTitleId}
className={cn(
"flex min-w-0 flex-col",
selectedRecord?.id === record.id ? "border-foreground" : "",
)}
key={record.id}
>
<CardHeader className="gap-3">
<div className="min-w-0 space-y-1">
<CardTitle className="break-words text-base" id={queueTitleId}>
{compactText(lead?.companyName, "Unbenannter Lead")}
</CardTitle>
<CardDescription className="break-all">
{compactText(lead?.websiteDomain ?? lead?.websiteUrl, "Keine Domain")}
</CardDescription>
</div>
<div className="flex flex-wrap gap-2">
<Badge variant="secondary">{formatStrategy(strategy)}</Badge>
<Badge variant="outline">
{compactText(lead?.contactStatus, "Kontaktstatus offen")}
</Badge>
<Badge variant="outline">
{compactText(audit?.status, "Auditstatus offen")}
</Badge>
<Badge variant={isEmailDraftReady(record) ? "secondary" : "outline"}>
{isEmailDraftReady(record) ? "E-Mail bereit" : "Mail offen"}
</Badge>
</div>
</CardHeader>
<CardContent className="flex flex-1 flex-col gap-3">
<p className="line-clamp-2 text-sm text-muted-foreground">
{compactText(lead?.priorityReason, "Kein Prioritätsgrund hinterlegt.")}
</p>
<div className="mt-auto flex flex-wrap gap-2">
<Button
aria-pressed={selectedRecord?.id === record.id}
onClick={() => setSelectedRecordId(record.id)}
size="sm"
type="button"
>
Details prüfen
</Button>
{publicAuditHref ? (
<Button asChild size="sm" type="button" variant="outline">
<Link href={publicAuditHref}>
<ExternalLink className="size-3.5" aria-hidden="true" />
Public-Audit
</Link>
</Button>
) : null}
</div>
</CardContent>
</Card>
);
})}
{filteredRows.length === 0 ? (
<Card className="lg:col-span-2 xl:col-span-3">
<CardHeader>
<CardTitle>Keine Treffer</CardTitle>
<CardDescription>
Für diesen Review-Filter gibt es aktuell keine Einträge.
</CardDescription>
</CardHeader>
</Card>
) : null}
</div>
</section>
<div className="space-y-3">
{rows.map((record) => {
{selectedRecord ? (() => {
const record = selectedRecord;
const draft = drafts[record.id] ?? getDraft(record);
const lead = record.lead;
const audit = record.audit;
@@ -851,7 +995,7 @@ export function OutreachReviewWorkspace() {
</CardContent>
</Card>
);
})}
})() : null}
</div>
</section>
);