Improve audit pipeline and outreach review
This commit is contained in:
@@ -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