"use client"; import { useMemo, useState } from "react"; import { useAction, useMutation, useQuery } from "convex/react"; import type { FunctionReturnType } from "convex/server"; import { CheckCircle2, ChevronDown, ChevronRight, ExternalLink, FileSearch, MailCheck, Save, ShieldCheck, } from "lucide-react"; 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, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; type ReviewWorkspaceListResult = FunctionReturnType< typeof api.outreach.listReviewWorkspace >; type ReviewWorkspaceItem = NonNullable[number]; type UsedSkill = ReviewWorkspaceItem["usedSkills"][number]; type DraftState = { auditBody: string; auditSummary: string; emailBody: string; emailSubject: string; followUpDraft: string; phoneScript: string; }; type PendingEmailConfirmation = { id: NonNullable["_id"]; recipient: string; subject: string; sender: string; auditSlug: string | null; }; type ReviewStatusFilter = "all" | "ready" | "mail_open"; const emptyDraft: DraftState = { auditBody: "", auditSummary: "", emailBody: "", emailSubject: "", followUpDraft: "", phoneScript: "", }; const textAreaClassName = "min-h-24 w-full rounded-md border border-input bg-background px-2.5 py-2 text-sm text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"; function getDraft(record: ReviewWorkspaceItem): DraftState { const outreach = record.latestOutreach; return { auditBody: record.audit?.publicBody ?? "", auditSummary: record.audit?.publicSummary ?? "", emailBody: outreach?.emailBody ?? "", emailSubject: outreach?.emailSubject ?? "", followUpDraft: outreach?.followUpDraft ?? "", phoneScript: outreach?.phoneScript ?? "", }; } function compactText(value?: string | null, fallback = "Offen") { const trimmed = value?.trim(); return trimmed ? trimmed : fallback; } function toStringIfText(value: unknown) { return typeof value === "string" ? value.trim() : ""; } function toNullableString(value: unknown) { const text = toStringIfText(value); return text.length > 0 ? text : null; } function asRecord(value: unknown) { return (typeof value === "object" && value !== null ? (value as Record) : {}); } function extractRecordValue(record: Record, candidates: string[]) { for (const candidate of candidates) { const text = toStringIfText(record[candidate]); if (text) { return text; } } return ""; } function formatStrategy(strategy?: string | null) { const labels: Record = { call_first: "Erst anrufen", defer: "Zurückstellen", do_not_contact: "Nicht kontaktieren", email_first: "Erst E-Mail", }; return strategy ? labels[strategy] ?? strategy : "Strategie offen"; } function formatRaw(value: unknown) { if (value === undefined || value === null) { return "Keine Rohdaten vorhanden."; } return JSON.stringify(value, null, 2); } function skillLabel(skill: UsedSkill) { const name = compactText(skill?.name, "Skill"); 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, onClick, }: { isOpen: boolean; label: string; onClick: () => void; }) { const Icon = isOpen ? ChevronDown : ChevronRight; return ( ); } function FieldPair({ label, value }: { label: string; value?: string | null }) { return (
{label}
{compactText(value)}
); } function WorkspaceLoading() { return (

Approval Bench

Review Workspace

{Array.from({ length: 3 }, (_, index) => ( ))}
); } export function OutreachReviewWorkspace() { const records = useQuery(api.outreach.listReviewWorkspace, { limit: 100 }); const saveReviewDraft = useMutation(api.outreach.saveReviewDraft); const approveEmailDraft = useMutation(api.outreach.approveEmailDraft); const savePublicAuditContent = useMutation(api.audits.savePublicAuditContent); const publishPublicAudit = useMutation(api.audits.publishPublicAudit); const sendApprovedEmail = useAction(api.outreachSendAction.sendApprovedEmail); const [drafts, setDrafts] = useState>({}); const [openSources, setOpenSources] = useState>({}); const [openRaw, setOpenRaw] = useState>({}); const [busyAction, setBusyAction] = useState(null); const [notice, setNotice] = useState(null); const [activeFilter, setActiveFilter] = useState("all"); const [selectedRecordId, setSelectedRecordId] = useState(null); const [pendingEmailConfirmation, setPendingEmailConfirmation] = useState(null); const rows = useMemo(() => 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 ; } if (rows.length === 0) { return (

Approval Bench

Review Workspace

Keine offenen Reviews

Sobald Audit- und Outreach-Entwürfe bereitstehen, erscheinen sie hier.

); } const updateDraft = ( id: string, field: keyof DraftState, value: string, ) => { const record = rows.find((row) => row.id === id); setDrafts((current) => ({ ...current, [id]: { ...(current[id] ?? (record ? getDraft(record) : emptyDraft)), [field]: value, }, })); }; const saveAudit = async (record: ReviewWorkspaceItem) => { const auditId = record.audit?._id; if (!auditId) { setNotice("Audit kann ohne Audit-ID nicht gespeichert werden."); return; } setBusyAction(`${record.id}:audit-save`); setNotice(null); try { const draft = drafts[record.id] ?? getDraft(record); await savePublicAuditContent({ id: auditId, publicBody: draft.auditBody, publicSummary: draft.auditSummary, }); setNotice("Audit-Änderungen gespeichert."); } catch { setNotice("Audit-Änderungen konnten nicht gespeichert werden."); } finally { setBusyAction(null); } }; const publishAudit = async (record: ReviewWorkspaceItem) => { const auditId = record.audit?._id; if (!auditId) { setNotice("Audit kann ohne Audit-ID nicht veröffentlicht werden."); return; } setBusyAction(`${record.id}:audit-publish`); setNotice(null); try { const draft = drafts[record.id] ?? getDraft(record); await savePublicAuditContent({ id: auditId, publicBody: draft.auditBody, publicSummary: draft.auditSummary, }); await publishPublicAudit({ id: auditId }); setNotice("Audit veröffentlicht."); } catch { setNotice("Audit konnte nicht veröffentlicht werden."); } finally { setBusyAction(null); } }; const saveOutreach = async (record: ReviewWorkspaceItem) => { const outreach = record.latestOutreach; if (!outreach) { setNotice("Outreach-Entwurf kann ohne Outreach-ID nicht gespeichert werden."); return; } if (outreach.sendStatus === "queued") { setNotice( "Aufgrund des laufenden Sendevorgangs kann der Outreach-Entwurf nicht gespeichert werden.", ); return; } const draft = drafts[record.id] ?? getDraft(record); const strategy = outreach.strategy; const hasCallablePhone = Boolean(record.lead?.phone) && (strategy === "call_first" || record.lead?.contactStatus === "missing_contact"); setBusyAction(`${record.id}:outreach-save`); setNotice(null); try { await saveReviewDraft({ id: outreach._id, strategy, emailBody: draft.emailBody, emailSubject: draft.emailSubject, followUpDraft: draft.followUpDraft, ...(hasCallablePhone ? { phoneScript: draft.phoneScript } : {}), }); setNotice("Outreach-Entwurf gespeichert."); } catch { setNotice("Outreach-Entwurf konnte nicht gespeichert werden."); } finally { setBusyAction(null); } }; const closeEmailConfirmation = () => { setPendingEmailConfirmation(null); }; const sendApprovedEmailFromConfirmation = async () => { const confirmation = pendingEmailConfirmation; if (!confirmation) { return; } const isQueuedSend = rows.some( (row: ReviewWorkspaceItem) => row.latestOutreach?._id === confirmation.id && row.latestOutreach?.sendStatus === "queued", ); if (isQueuedSend) { setNotice( "Aufgrund des laufenden Sendevorgangs kann der Versand nicht erneut gestartet werden.", ); return; } setBusyAction(`${confirmation.id}:email-send`); setNotice(null); try { await sendApprovedEmail({ id: confirmation.id }); setNotice("E-Mail gesendet."); setPendingEmailConfirmation(null); } catch { setNotice( "E-Mail konnte nicht gesendet werden. Bitte erneut versuchen.", ); } finally { setBusyAction(null); } }; const approveEmail = async (record: ReviewWorkspaceItem) => { const outreach = record.latestOutreach; const lead = record.lead; const audit = record.audit; if (!outreach) { setNotice("E-Mail kann ohne Outreach-ID nicht freigegeben werden."); return; } if (outreach.sendStatus === "queued") { setNotice( "Aufgrund des laufenden Sendevorgangs kann die E-Mail nicht freigegeben werden.", ); return; } const draft = drafts[record.id] ?? getDraft(record); const strategy = outreach.strategy; const hasCallablePhone = Boolean(record.lead?.phone) && (strategy === "call_first" || record.lead?.contactStatus === "missing_contact"); setBusyAction(`${record.id}:email-approval`); setNotice(null); try { await saveReviewDraft({ id: outreach._id, strategy, emailBody: draft.emailBody, emailSubject: draft.emailSubject, followUpDraft: draft.followUpDraft, ...(hasCallablePhone ? { phoneScript: draft.phoneScript } : {}), }); const approvalResult = await approveEmailDraft({ id: outreach._id }); const approvalData = asRecord(approvalResult); const recipient = extractRecordValue(approvalData, ["recipient", "email", "emailAddress"]) || lead?.email || "Offen"; const subject = extractRecordValue(approvalData, [ "emailSubject", "subject", "title", ]) || draft.emailSubject; const sender = toNullableString(approvalData.sender); if (!sender) { throw new Error("SMTP-Absender in der Freigabeantwort fehlt."); } const auditSlug = extractRecordValue(approvalData, ["auditSlug", "slug", "audit"]) || audit?.slug || null; setPendingEmailConfirmation({ id: outreach._id, recipient, subject, sender, auditSlug, }); setNotice("E-Mail freigegeben."); } catch { setNotice("E-Mail konnte nicht freigegeben werden."); } finally { setBusyAction(null); } }; const confirmationAuditLink = pendingEmailConfirmation?.auditSlug ? `/audit/${pendingEmailConfirmation.auditSlug}` : null; const pendingQueuedOutreachId = pendingEmailConfirmation?.id; const isQueuedSendForConfirmation = rows.some( (row: ReviewWorkspaceItem) => row.latestOutreach?._id === pendingQueuedOutreachId && row.latestOutreach?.sendStatus === "queued", ); return (

Approval Bench

Review Workspace

Audits, E-Mail-Empfehlung und Telefonnotizen prüfen, bevor etwas öffentlich wird oder eine Freigabe erhält.

{notice ? (

{notice}

) : null} { if (!open) { closeEmailConfirmation(); } }} > {pendingEmailConfirmation ? ( E-Mail-Versand bestätigen Bitte prüfen Sie vor dem Senden die Finaldaten.

{pendingEmailConfirmation.recipient}

{pendingEmailConfirmation.subject}

{pendingEmailConfirmation.sender}

{confirmationAuditLink ? ( {confirmationAuditLink} ) : (

Nicht verfügbar.

)}
) : null}

Review-Queue

{reviewStatusFilters.map((filter) => ( ))}
{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 (
{compactText(lead?.companyName, "Unbenannter Lead")} {compactText(lead?.websiteDomain ?? lead?.websiteUrl, "Keine Domain")}
{formatStrategy(strategy)} {compactText(lead?.contactStatus, "Kontaktstatus offen")} {compactText(audit?.status, "Auditstatus offen")} {isEmailDraftReady(record) ? "E-Mail bereit" : "Mail offen"}

{compactText(lead?.priorityReason, "Kein Prioritätsgrund hinterlegt.")}

{publicAuditHref ? ( ) : null}
); })} {filteredRows.length === 0 ? ( Keine Treffer Für diesen Review-Filter gibt es aktuell keine Einträge. ) : null}
{selectedRecord ? (() => { const record = selectedRecord; const draft = drafts[record.id] ?? getDraft(record); const lead = record.lead; const audit = record.audit; const outreach = record.latestOutreach; const isQueuedSend = outreach?.sendStatus === "queued"; const strategy = outreach?.strategy; const contactSources = [ lead.email ? `E-Mail: ${lead.email}` : null, lead.phone ? `Telefon: ${lead.phone}` : null, ...record.sourceSummaries.emailCandidates.map( (candidate) => `${candidate.email} · ${candidate.emailSource}${ candidate.accepted ? " · akzeptiert" : "" }`, ), ].filter((source): source is string => Boolean(source)); const skills = record.usedSkills; const hasCallablePhone = Boolean(lead?.phone) && (strategy === "call_first" || lead?.contactStatus === "missing_contact"); const publicAuditHref = audit?.slug ? `/audit/${audit.slug}` : null; return (
{compactText(lead?.companyName, "Unbenannter Lead")}

{compactText( lead?.websiteDomain ?? lead?.websiteUrl, "Keine Domain", )}

{formatStrategy(strategy)} {compactText(lead?.contactStatus, "Kontaktstatus offen")} {compactText(audit?.status, "Auditstatus offen")}
Evidence

{audit ? "Audit vorhanden" : "Audit offen"}

Public Audit

{audit?.status === "published" ? "Veröffentlicht" : "Prüfung offen"}

E-Mail

{isEmailDraftReady(record) ? "Bereit" : "Entwurf offen"}

Final Send

{isQueuedSend ? "Wird gesendet" : "Bestätigung nötig"}

Lead-Details

Prioritätsgrund

{compactText(lead?.priorityReason)}

Kontaktstrategie

{formatStrategy(strategy)}

Audit-Zusammenfassung

{publicAuditHref ? ( ) : ( Public-Audit ohne Slug )}