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,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>
);