Improve audit pipeline and outreach review
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user