Add SMTP send flow for approved outreach
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
import { useAction, useMutation, useQuery } from "convex/react";
|
||||
import type { FunctionReturnType } from "convex/server";
|
||||
import { ChevronDown, ChevronRight, ExternalLink, MailCheck, Save } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -11,6 +11,14 @@ 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 {
|
||||
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";
|
||||
@@ -30,6 +38,14 @@ type DraftState = {
|
||||
phoneScript: string;
|
||||
};
|
||||
|
||||
type PendingEmailConfirmation = {
|
||||
id: NonNullable<ReviewWorkspaceItem["latestOutreach"]>["_id"];
|
||||
recipient: string;
|
||||
subject: string;
|
||||
sender: string;
|
||||
auditSlug: string | null;
|
||||
};
|
||||
|
||||
const emptyDraft: DraftState = {
|
||||
auditBody: "",
|
||||
auditSummary: "",
|
||||
@@ -60,6 +76,30 @@ function compactText(value?: string | null, fallback = "Offen") {
|
||||
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<string, unknown>) : {});
|
||||
}
|
||||
|
||||
function extractRecordValue(record: Record<string, unknown>, 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<string, string> = {
|
||||
call_first: "Erst anrufen",
|
||||
@@ -140,14 +180,17 @@ export function OutreachReviewWorkspace() {
|
||||
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<Record<string, DraftState>>({});
|
||||
const [openSources, setOpenSources] = useState<Record<string, boolean>>({});
|
||||
const [openRaw, setOpenRaw] = useState<Record<string, boolean>>({});
|
||||
const [busyAction, setBusyAction] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [pendingEmailConfirmation, setPendingEmailConfirmation] =
|
||||
useState<PendingEmailConfirmation | null>(null);
|
||||
|
||||
const rows = useMemo(() => records ?? [], [records]);
|
||||
const rows = useMemo<ReviewWorkspaceItem[]>(() => records ?? [], [records]);
|
||||
|
||||
if (records === undefined) {
|
||||
return <WorkspaceLoading />;
|
||||
@@ -243,6 +286,12 @@ export function OutreachReviewWorkspace() {
|
||||
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;
|
||||
@@ -270,12 +319,57 @@ export function OutreachReviewWorkspace() {
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -295,7 +389,34 @@ export function OutreachReviewWorkspace() {
|
||||
followUpDraft: draft.followUpDraft,
|
||||
...(hasCallablePhone ? { phoneScript: draft.phoneScript } : {}),
|
||||
});
|
||||
await approveEmailDraft({ id: outreach._id });
|
||||
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.");
|
||||
@@ -304,6 +425,16 @@ export function OutreachReviewWorkspace() {
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<section className="space-y-4">
|
||||
<header className="space-y-2">
|
||||
@@ -319,12 +450,88 @@ export function OutreachReviewWorkspace() {
|
||||
<p className="rounded-md border bg-muted/30 px-3 py-2 text-sm">{notice}</p>
|
||||
) : null}
|
||||
|
||||
<Dialog
|
||||
open={Boolean(pendingEmailConfirmation)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
closeEmailConfirmation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{pendingEmailConfirmation ? (
|
||||
<DialogContent className="space-y-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>E-Mail-Versand bestätigen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Bitte prüfen Sie vor dem Senden die Finaldaten.
|
||||
</DialogDescription>
|
||||
<DialogCloseButton />
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="min-w-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Empfänger
|
||||
</label>
|
||||
<p className="break-words text-sm">{pendingEmailConfirmation.recipient}</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Betreff
|
||||
</label>
|
||||
<p className="break-words text-sm">{pendingEmailConfirmation.subject}</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Absender
|
||||
</label>
|
||||
<p className="text-sm">
|
||||
{pendingEmailConfirmation.sender}
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Audit-Link
|
||||
</label>
|
||||
{confirmationAuditLink ? (
|
||||
<Link
|
||||
className="inline-flex items-center gap-1 break-all text-sm text-blue-600 hover:underline"
|
||||
href={confirmationAuditLink}
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
{confirmationAuditLink}
|
||||
</Link>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Nicht verfügbar.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
disabled={
|
||||
busyAction === `${pendingEmailConfirmation.id}:email-send` ||
|
||||
isQueuedSendForConfirmation
|
||||
}
|
||||
onClick={sendApprovedEmailFromConfirmation}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
Senden
|
||||
</Button>
|
||||
<Button onClick={closeEmailConfirmation} size="sm" type="button" variant="outline">
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
) : null}
|
||||
</Dialog>
|
||||
|
||||
<div className="space-y-3">
|
||||
{rows.map((record) => {
|
||||
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,
|
||||
@@ -506,7 +713,9 @@ export function OutreachReviewWorkspace() {
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
disabled={busyAction === `${record.id}:outreach-save`}
|
||||
disabled={
|
||||
busyAction === `${record.id}:outreach-save` || isQueuedSend
|
||||
}
|
||||
onClick={() => saveOutreach(record)}
|
||||
size="sm"
|
||||
type="button"
|
||||
@@ -516,13 +725,15 @@ export function OutreachReviewWorkspace() {
|
||||
Änderungen speichern
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busyAction === `${record.id}:email-approval`}
|
||||
disabled={
|
||||
busyAction === `${record.id}:email-approval` || isQueuedSend
|
||||
}
|
||||
onClick={() => approveEmail(record)}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
<MailCheck className="size-3.5" />
|
||||
E-Mail freigeben
|
||||
E-Mail freigeben und senden
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user