From 7e0972d9b7908092844027abbea1f1894ce0b944 Mon Sep 17 00:00:00 2001 From: Chad Nuttall Date: Fri, 2 Oct 2026 16:09:58 -0400 Subject: [PATCH 1/5] feat(admin): trade lead email pipeline, islands, and phone normalization (#152) Make /admin/trade the system of record for the whole trade relationship: admins send email from the lead workspace, customer replies and forwarded mail attach automatically via per-lead inbound routing addresses, and the unified timeline shows communications alongside notes and lifecycle events. - Per-lead opaque replyToken (never the Firestore id) forms the inbound address @; Reply-To on outbound mail routes customer replies back, and staff can forward outside email to attach it - POST /api/webhooks/resend verifies svix signatures before touching the payload, resolves leads by token, dedupes by provider email id, and correlates threads via In-Reply-To/References against stored Message-IDs - Delivery callbacks (delivered/bounced/delayed/complained/failed) advance communication state monotonically; replays never regress it - Staff notifications: blanket new-inquiry alert to TRADE_NOTIFICATION_EMAIL (legacy TRADE_INQUIRY_TO_EMAIL fallback), and customer-reply alerts to the assigned owner's admin email or the shared mailbox when unassigned - Full communication records in tradeLeads/{id}/communications with a compact communication summary on the matching activity entry; bodies stay out of the timeline, analytics, and logs - First-class island field (canonical venue-island vocabulary) on leads, public form, manual create, list badges, detail header, and filtering - Conservative phone normalization (E.164 where confidently parsed) with readable display and tel:/wa.me links; ambiguous input stays raw - Retention pruning now sweeps both activities and communications subcollections in bounded batches Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .env.local.example | 15 + .../trade-leads/[id]/activities/route.ts | 22 +- .../admin/trade-leads/[id]/messages/route.ts | 68 ++ app/api/admin/trade-leads/[id]/route.ts | 26 +- app/api/webhooks/resend/route.ts | 64 ++ components/admin-trade-workspace.tsx | 454 +++++++++++- components/trade-inquiry-form.tsx | 24 +- docs/TECHNICAL.md | 114 ++- docs/admin/trade-inquiries.md | 81 ++- docs/operations/credential-rotation.md | 5 +- docs/operations/deployment.md | 6 +- docs/operations/observability.md | 12 +- firestore.indexes.json | 15 +- firestore.rules | 12 +- lib/phone.ts | 142 ++++ lib/resend-config.ts | 40 ++ lib/trade-leads-admin-common.ts | 124 +++- lib/trade-leads-admin.ts | 96 +++ lib/trade-leads-common.ts | 68 +- lib/trade-leads-email-common.ts | 469 +++++++++++++ lib/trade-leads-email.ts | 662 ++++++++++++++++++ lib/trade-leads.ts | 43 +- rules-tests/firestore.rules.test.ts | 36 + scripts/prune-trade-leads.ts | 52 +- smoke-tests/admin-accessibility.spec.ts | 134 +++- tests/lib/phone.test.ts | 138 ++++ tests/lib/trade-leads-admin.test.ts | 159 ++++- tests/lib/trade-leads-email.test.ts | 419 +++++++++++ tests/lib/trade-leads.test.ts | 50 ++ tests/security-rules.test.ts | 15 +- 30 files changed, 3387 insertions(+), 178 deletions(-) create mode 100644 app/api/admin/trade-leads/[id]/messages/route.ts create mode 100644 app/api/webhooks/resend/route.ts create mode 100644 lib/phone.ts create mode 100644 lib/trade-leads-email-common.ts create mode 100644 lib/trade-leads-email.ts create mode 100644 tests/lib/phone.test.ts create mode 100644 tests/lib/trade-leads-email.test.ts diff --git a/.env.local.example b/.env.local.example index 830f41a..e90c85f 100644 --- a/.env.local.example +++ b/.env.local.example @@ -22,10 +22,25 @@ NEXT_PUBLIC_GTM_ID=GTM-XXXXXXX # Resend console. RESEND_API_KEY=re_xxxxxxxxx TRADE_INQUIRY_TO_EMAIL=your-email@example.com +# Canonical staff notification mailbox for the trade pipeline (new inquiry +# alerts + customer-reply alerts on unassigned leads). Falls back to +# TRADE_INQUIRY_TO_EMAIL when unset — set this and remove the legacy name. +# TRADE_NOTIFICATION_EMAIL=your-email@example.com # Sender for trade-inquiry emails; also the fallback sender for admin # invitations. Optional — defaults to noreply@mail.deepdivebrewing.com # (the verified Resend sending domain). RESEND_FROM_EMAIL=deepdive@mail.deepdivebrewing.com +# Sender for customer-facing lead email sent from /admin/trade. Optional — +# defaults to trade@mail.deepdivebrewing.com (verified domain). +# TRADE_FROM_EMAIL=trade@mail.deepdivebrewing.com +# Domain for per-lead inbound addresses (@) used in Reply-To +# and the "attach email to this lead" feature. Requires an inbound-configured +# domain in Resend (MX records) — defaults to reply.deepdivebrewing.com. +# TRADE_REPLY_DOMAIN=reply.deepdivebrewing.com +# Signing secret (whsec_…) for POST /api/webhooks/resend — required for the +# endpoint to accept inbound email and delivery events. Configure the same +# secret on the webhook in the Resend console. +# RESEND_WEBHOOK_SECRET=whsec_xxxxxxxxxxxx # Optional preferred sender for admin invitation emails. ADMIN_INVITE_FROM_EMAIL=invitations@mail.deepdivebrewing.com # Optional cooldown in milliseconds before an invitation email can be resent (default 60,000). diff --git a/app/api/admin/trade-leads/[id]/activities/route.ts b/app/api/admin/trade-leads/[id]/activities/route.ts index 9f6ca50..1f40bd0 100644 --- a/app/api/admin/trade-leads/[id]/activities/route.ts +++ b/app/api/admin/trade-leads/[id]/activities/route.ts @@ -5,15 +5,10 @@ import { apiErrorResponse } from "@/lib/api-error"; import { getRequestId, logInfo } from "@/lib/log"; import { addTradeLeadNote, - getTradeLead, - listTradeLeadActivities, + getTradeLeadDetail, tradeLeadActorOf, } from "@/lib/trade-leads-admin"; -import { - serializeTradeLead, - serializeTradeLeadActivity, - validateNoteBody, -} from "@/lib/trade-leads-admin-common"; +import { validateNoteBody } from "@/lib/trade-leads-admin-common"; interface RouteParams { params: Promise<{ id: string }>; @@ -45,17 +40,8 @@ export async function POST(req: NextRequest, { params }: RouteParams) { await addTradeLeadNote(id, parsed.note, tradeLeadActorOf(actor)); logInfo("trade_lead.note_added", { leadId: id, requestId }); - const [lead, activities] = await Promise.all([ - getTradeLead(id), - listTradeLeadActivities(id), - ]); - return NextResponse.json({ - ok: true, - lead: lead ? serializeTradeLead(lead.id, lead.data) : null, - activities: activities.map((a) => - serializeTradeLeadActivity(a.id, a.data) - ), - }); + const detail = await getTradeLeadDetail(id); + return NextResponse.json({ ok: true, ...detail }); } catch (error) { return apiErrorResponse(error, { fallback: "Failed to add note.", diff --git a/app/api/admin/trade-leads/[id]/messages/route.ts b/app/api/admin/trade-leads/[id]/messages/route.ts new file mode 100644 index 0000000..7017a68 --- /dev/null +++ b/app/api/admin/trade-leads/[id]/messages/route.ts @@ -0,0 +1,68 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { requireAdminActor } from "@/lib/admin-auth"; +import { + badRequestResponse, + getBearerToken, + unauthorizedResponse, +} from "@/lib/api-auth"; +import { apiErrorResponse } from "@/lib/api-error"; +import { getRequestId, logInfo } from "@/lib/log"; +import { + getTradeLeadDetail, + tradeLeadActorOf, +} from "@/lib/trade-leads-admin"; +import { + parseOutboundMessageBody, + sendLeadEmail, +} from "@/lib/trade-leads-email"; + +interface RouteParams { + params: Promise<{ id: string }>; +} + +// Sends an admin-composed email to the lead's contact address (Issue #152). +// The recipient is always the lead's stored email — the request cannot aim +// the message at an arbitrary address, so the endpoint is not a mail relay. +// The send is recorded on the lead (communication doc + timeline entry) +// whether or not the provider accepts it, so the history never claims a +// message that did not happen. +export async function POST(req: NextRequest, { params }: RouteParams) { + const requestId = getRequestId(req.headers); + const idToken = getBearerToken(req); + if (!idToken) return unauthorizedResponse(); + + let body: unknown; + try { + body = await req.json(); + } catch { + return badRequestResponse("Invalid JSON body."); + } + + try { + const actor = await requireAdminActor(idToken); + const { id } = await params; + + const parsed = parseOutboundMessageBody(body); + if (!parsed.ok) return badRequestResponse(parsed.error); + + const result = await sendLeadEmail( + id, + parsed.message, + tradeLeadActorOf(actor) + ); + logInfo("trade_lead.email_sent", { + leadId: id, + communicationId: result.communicationId, + requestId, + }); + + const detail = await getTradeLeadDetail(id); + return NextResponse.json({ ok: true, ...detail }); + } catch (error) { + return apiErrorResponse(error, { + fallback: "Failed to send the email.", + event: "trade_lead.email_send_failed", + context: { requestId }, + }); + } +} diff --git a/app/api/admin/trade-leads/[id]/route.ts b/app/api/admin/trade-leads/[id]/route.ts index 0317395..f29ab87 100644 --- a/app/api/admin/trade-leads/[id]/route.ts +++ b/app/api/admin/trade-leads/[id]/route.ts @@ -5,15 +5,12 @@ import { apiErrorResponse } from "@/lib/api-error"; import { getRequestId, logInfo } from "@/lib/log"; import { applyLeadPatch, - getTradeLead, - listTradeLeadActivities, + getTradeLeadDetail, resolveAssignee, tradeLeadActorOf, } from "@/lib/trade-leads-admin"; import { parseLeadPatchBody, - serializeTradeLead, - serializeTradeLeadActivity, type NormalizedLeadPatch, } from "@/lib/trade-leads-admin-common"; @@ -21,18 +18,6 @@ interface RouteParams { params: Promise<{ id: string }>; } -async function leadDetail(id: string) { - const [lead, activities] = await Promise.all([ - getTradeLead(id), - listTradeLeadActivities(id), - ]); - if (!lead) return null; - return { - lead: serializeTradeLead(lead.id, lead.data), - activities: activities.map((a) => serializeTradeLeadActivity(a.id, a.data)), - }; -} - export async function GET(req: NextRequest, { params }: RouteParams) { const requestId = getRequestId(req.headers); const idToken = getBearerToken(req); @@ -41,15 +26,15 @@ export async function GET(req: NextRequest, { params }: RouteParams) { try { await requireAdminActor(idToken); const { id } = await params; - const detail = await leadDetail(id); + const detail = await getTradeLeadDetail(id); if (!detail) { return NextResponse.json( { ok: false, error: "Trade lead not found." }, { status: 404 } ); } - // Read-only: viewing a lead must not touch updatedAt/lastActivityAt — - // those timestamps drive the 24-month retention window. + // Read-only apart from first-touch routing-token provisioning, which + // does not advance updatedAt/lastActivityAt — the retention anchors. return NextResponse.json({ ok: true, ...detail }); } catch (error) { return apiErrorResponse(error, { @@ -81,6 +66,7 @@ export async function PATCH(req: NextRequest, { params }: RouteParams) { const patch: NormalizedLeadPatch = { status: parsed.patch.status, + island: parsed.patch.island, nextFollowUpAt: parsed.patch.nextFollowUpAt, outcome: parsed.patch.outcome, }; @@ -101,7 +87,7 @@ export async function PATCH(req: NextRequest, { params }: RouteParams) { }); } - const detail = await leadDetail(id); + const detail = await getTradeLeadDetail(id); return NextResponse.json({ ok: true, ...detail }); } catch (error) { return apiErrorResponse(error, { diff --git a/app/api/webhooks/resend/route.ts b/app/api/webhooks/resend/route.ts new file mode 100644 index 0000000..8a7395d --- /dev/null +++ b/app/api/webhooks/resend/route.ts @@ -0,0 +1,64 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getRequestId, logError, logInfo } from "@/lib/log"; +import { + applyDeliveryEvent, + handleInboundEmail, + verifyResendWebhook, +} from "@/lib/trade-leads-email"; + +// Resend webhook endpoint (Issue #152): inbound customer replies +// (email.received) and outbound delivery-state callbacks (email.delivered, +// email.bounced, …). Not behind admin auth — the caller is Resend — so the +// svix signature on the raw body is the trust boundary, verified before any +// payload is touched. +// +// Failure contract: +// 401/400 → invalid or unverifiable request (no retry desired) +// 200 → handled, ignored, or already recorded (idempotent) +// 500 → transient/internal failure — Resend retries the delivery +export async function POST(req: NextRequest) { + const requestId = getRequestId(req.headers); + const rawPayload = await req.text(); + + let event; + try { + event = verifyResendWebhook(rawPayload, { + // Resend uses the Standard Webhooks header set. + id: req.headers.get("svix-id") ?? req.headers.get("webhook-id"), + timestamp: + req.headers.get("svix-timestamp") ?? + req.headers.get("webhook-timestamp"), + signature: + req.headers.get("svix-signature") ?? + req.headers.get("webhook-signature"), + }); + } catch (error) { + logError("trade_email.webhook_rejected", error, { requestId }); + return NextResponse.json( + { ok: false, error: "Invalid webhook." }, + { status: 401 } + ); + } + + try { + if (event.type === "email.received") { + const outcome = await handleInboundEmail(event.data); + logInfo("trade_email.inbound_processed", { requestId, outcome }); + return NextResponse.json({ ok: true, outcome }); + } + + const outcome = await applyDeliveryEvent(event.type, event.data); + if (outcome !== "ignored") { + logInfo("trade_email.delivery_processed", { requestId, outcome }); + } + return NextResponse.json({ ok: true, outcome }); + } catch (error) { + // Processing failure → 500 so the provider retries rather than silently + // dropping a customer reply. + logError("trade_email.webhook_processing_failed", error, { requestId }); + return NextResponse.json( + { ok: false, error: "Webhook processing failed." }, + { status: 500 } + ); + } +} diff --git a/components/admin-trade-workspace.tsx b/components/admin-trade-workspace.tsx index e80c1d4..b5cfe36 100644 --- a/components/admin-trade-workspace.tsx +++ b/components/admin-trade-workspace.tsx @@ -8,6 +8,7 @@ import { CalendarClock, Flag, Mail, + MapPin, MessageSquare, Phone, Sparkles, @@ -24,7 +25,16 @@ import { } from "@/components/ui/dialog"; import { formatAdminDate, formatAdminDateTime } from "@/lib/admin-format"; import type { AdminPanelUser } from "@/components/admin-access"; -import { TRADE_VENUE_TYPES } from "@/lib/trade-leads-common"; +import { + normalizePhoneNumber, + telHref, + whatsappHref, +} from "@/lib/phone"; +import { + tradeLeadIslandLabel, + TRADE_LEAD_ISLANDS, + TRADE_VENUE_TYPES, +} from "@/lib/trade-leads-common"; import { classifyFollowUp, describeTradeLeadActivity, @@ -40,6 +50,11 @@ import { type TradeLeadStatus, type TradeLeadView, } from "@/lib/trade-leads-admin-common"; +import { + TRADE_EMAIL_BODY_MAX, + TRADE_EMAIL_SUBJECT_MAX, + type TradeLeadCommunicationView, +} from "@/lib/trade-leads-email-common"; // Input/select borders use ink at 50% so every form control boundary is // visible against the paper background (WCAG 1.4.11 non-text contrast). @@ -53,11 +68,14 @@ const sectionLabelClass = type StatusFilter = "all" | TradeLeadStatus; type FollowUpFilter = "all" | FollowUpState; +// "unset" finds leads with no island recorded — pre-#152 records included. +type IslandFilter = "all" | "unset" | string; type SortMode = "newest" | "oldest" | "follow_up"; interface LeadDetail { lead: TradeLeadView; activities: TradeLeadActivityView[]; + communications: TradeLeadCommunicationView[]; } const EMPTY_LEAD_FORM = { @@ -66,6 +84,7 @@ const EMPTY_LEAD_FORM = { email: "", phoneOrWhatsapp: "", venueType: "", + island: "", source: "", message: "", }; @@ -168,6 +187,7 @@ const ACTIVITY_ICON: Record< lead_created: { icon: Sparkles, className: "text-moss" }, status_changed: { icon: ArrowRightLeft, className: "text-ink" }, owner_changed: { icon: UserRound, className: "text-ocean" }, + island_changed: { icon: MapPin, className: "text-ocean" }, follow_up_set: { icon: CalendarClock, className: "text-amber-700" }, follow_up_changed: { icon: CalendarClock, className: "text-amber-700" }, follow_up_cleared: { icon: CalendarClock, className: "text-amber-700" }, @@ -179,9 +199,69 @@ const DEFAULT_ACTIVITY_ICON = { className: "text-muted-foreground", }; -// wa.me accepts digits only; strip formatting from whatever was submitted. -function whatsappDigits(phone: string): string { - return phone.replace(/[^\d]/g, ""); +// Compact island chip for list rows and the detail header. Missing islands +// (all pre-#152 leads) render a muted "not set" marker rather than being +// silently filed under an island. +function IslandBadge({ island }: { island?: string }) { + if (!island) { + return ( + + No island + + ); + } + return {tradeLeadIslandLabel(island)}; +} + +// Live delivery badge for an outbound email — read from the communication +// record (provider callbacks update it) rather than the activity snapshot. +const DELIVERY_BADGE: Record< + string, + { label: string; className: string } +> = { + queued: { label: "Queued", className: "text-muted-foreground" }, + sent: { label: "Sent", className: "text-ocean" }, + delivery_delayed: { + label: "Delayed", + className: "border-amber-500/40 bg-amber-100/70 text-amber-900", + }, + delivered: { + label: "Delivered", + className: "border-moss/40 bg-moss/10 text-moss", + }, + bounced: { + label: "Bounced", + className: "border-ember/40 bg-ember/10 text-ember", + }, + complained: { + label: "Complaint", + className: "border-ember/40 bg-ember/10 text-ember", + }, + failed: { + label: "Failed", + className: "border-ember/40 bg-ember/10 text-ember", + }, +}; + +function DeliveryBadge({ state }: { state?: string }) { + const entry = state ? DELIVERY_BADGE[state] : undefined; + if (!entry) return null; + return ( + + {entry.label} + + ); +} + +// Pre-fills the composer when replying to an inbound email — keeps an +// existing Re: prefix rather than stacking another one. +function replySubject(subject: string | undefined): string { + const trimmed = subject?.trim() ?? ""; + if (!trimmed) return ""; + return /^re:/i.test(trimmed) ? trimmed : `Re: ${trimmed}`; } export function AdminTradeWorkspace({ user }: { user: AdminPanelUser }) { @@ -203,6 +283,7 @@ export function AdminTradeWorkspace({ user }: { user: AdminPanelUser }) { const [statusFilter, setStatusFilter] = useState("all"); const [ownerFilter, setOwnerFilter] = useState("all"); const [typeFilter, setTypeFilter] = useState("all"); + const [islandFilter, setIslandFilter] = useState("all"); const [followUpFilter, setFollowUpFilter] = useState("all"); const [sortMode, setSortMode] = useState("newest"); const [search, setSearch] = useState(""); @@ -212,6 +293,15 @@ export function AdminTradeWorkspace({ user }: { user: AdminPanelUser }) { const [followUpDraft, setFollowUpDraft] = useState(""); const [outcomeDraft, setOutcomeDraft] = useState(""); + // Email composer (#152). replyToCommunicationId targets the In-Reply-To/ + // References headers at the message being answered. + const [composerOpen, setComposerOpen] = useState(false); + const [emailSubject, setEmailSubject] = useState(""); + const [emailBody, setEmailBody] = useState(""); + const [emailReplyToId, setEmailReplyToId] = useState(); + const [emailError, setEmailError] = useState(""); + const [addressCopied, setAddressCopied] = useState(false); + const [newLeadOpen, setNewLeadOpen] = useState(false); const [newLeadForm, setNewLeadForm] = useState(EMPTY_LEAD_FORM); const [newLeadError, setNewLeadError] = useState(""); @@ -271,6 +361,8 @@ export function AdminTradeWorkspace({ user }: { user: AdminPanelUser }) { const next = { lead: data.lead as TradeLeadView, activities: (data.activities as TradeLeadActivityView[]) ?? [], + communications: + (data.communications as TradeLeadCommunicationView[]) ?? [], }; // The list is shared state — always merge the fresh record so a // stale panel can't keep showing outdated values later. @@ -283,6 +375,9 @@ export function AdminTradeWorkspace({ user }: { user: AdminPanelUser }) { setDetail(next); setFollowUpDraft(dateToLocalDateInput(next.lead.nextFollowUpAt)); setOutcomeDraft(next.lead.outcome ?? ""); + setComposerOpen(false); + setEmailError(""); + setAddressCopied(false); } catch (error) { if (selectedIdRef.current !== id) return; console.error(error); @@ -305,6 +400,16 @@ export function AdminTradeWorkspace({ user }: { user: AdminPanelUser }) { setDetailLoading(false); } + // Staff notification emails deep-link to /admin/trade?lead=; honor it + // once the workspace mounts so the link lands on the open lead. + const deepLinkHandled = useRef(false); + useEffect(() => { + if (deepLinkHandled.current) return; + deepLinkHandled.current = true; + const id = new URLSearchParams(window.location.search).get("lead"); + if (id) void openLead(id); + }, [openLead]); + // Applies the fresh lead + activities a mutation response returns. The // list always updates; the detail panel only updates when the mutated // lead is still the selected one (a response may land after switching). @@ -321,6 +426,71 @@ export function AdminTradeWorkspace({ user }: { user: AdminPanelUser }) { setOutcomeDraft(next.lead.outcome ?? ""); } + async function sendEmail(e: React.FormEvent) { + e.preventDefault(); + if (!detail || !emailSubject.trim() || !emailBody.trim() || saving) return; + setSaving("email"); + setEmailError(""); + setStatusMessage(""); + try { + const data = await apiFetch( + `/api/admin/trade-leads/${detail.lead.id}/messages`, + { + method: "POST", + body: JSON.stringify({ + subject: emailSubject, + body: emailBody, + ...(emailReplyToId + ? { replyToCommunicationId: emailReplyToId } + : {}), + }), + } + ); + applyDetail({ + lead: data.lead as TradeLeadView, + activities: (data.activities as TradeLeadActivityView[]) ?? [], + communications: + (data.communications as TradeLeadCommunicationView[]) ?? [], + }); + setComposerOpen(false); + setEmailSubject(""); + setEmailBody(""); + setEmailReplyToId(undefined); + setStatusMessage("Email sent."); + setStatusIsError(false); + } catch (error) { + setEmailError( + error instanceof Error ? error.message : "Could not send the email." + ); + } finally { + setSaving(null); + } + } + + // Reply targets an existing communication so the outbound send carries + // real In-Reply-To/References headers and stays in its thread. + function openComposer(opts?: { + subject?: string; + replyToCommunicationId?: string; + }) { + setEmailSubject(opts?.subject ?? ""); + setEmailBody(""); + setEmailReplyToId(opts?.replyToCommunicationId); + setEmailError(""); + setComposerOpen(true); + } + + async function copyInboundAddress() { + const address = detail?.lead.inboundAddress; + if (!address) return; + try { + await navigator.clipboard.writeText(address); + setAddressCopied(true); + } catch { + setAddressCopied(false); + } + } + async function patchLead( fields: Record, actionKey: string @@ -336,6 +506,8 @@ export function AdminTradeWorkspace({ user }: { user: AdminPanelUser }) { applyDetail({ lead: data.lead as TradeLeadView, activities: (data.activities as TradeLeadActivityView[]) ?? [], + communications: + (data.communications as TradeLeadCommunicationView[]) ?? [], }); setStatusMessage("Lead updated."); setStatusIsError(false); @@ -362,6 +534,8 @@ export function AdminTradeWorkspace({ user }: { user: AdminPanelUser }) { applyDetail({ lead: data.lead as TradeLeadView, activities: (data.activities as TradeLeadActivityView[]) ?? [], + communications: + (data.communications as TradeLeadCommunicationView[]) ?? [], }); setNoteDraft(""); setStatusMessage("Note added."); @@ -415,6 +589,13 @@ export function AdminTradeWorkspace({ user }: { user: AdminPanelUser }) { ) return false; if (typeFilter !== "all" && lead.venueType !== typeFilter) return false; + if (islandFilter === "unset" && lead.island) return false; + if ( + islandFilter !== "all" && + islandFilter !== "unset" && + lead.island !== islandFilter + ) + return false; if ( followUpFilter !== "all" && classifyFollowUp(lead.nextFollowUpAt, lead.status) !== followUpFilter @@ -442,7 +623,7 @@ export function AdminTradeWorkspace({ user }: { user: AdminPanelUser }) { return leadMillis(b.createdAt) - leadMillis(a.createdAt); }); return list; - }, [leads, statusFilter, ownerFilter, typeFilter, followUpFilter, sortMode, search]); + }, [leads, statusFilter, ownerFilter, typeFilter, islandFilter, followUpFilter, sortMode, search]); const counts = useMemo(() => { let fresh = 0; @@ -485,7 +666,12 @@ export function AdminTradeWorkspace({ user }: { user: AdminPanelUser }) { [detail] ); - const waDigits = detail ? whatsappDigits(detail.lead.phoneOrWhatsapp) : ""; + const phone = detail + ? normalizePhoneNumber(detail.lead.phoneOrWhatsapp) + : null; + const communicationsById = new Map( + (detail?.communications ?? []).map((comm) => [comm.id, comm]) + ); return (
@@ -582,6 +768,22 @@ export function AdminTradeWorkspace({ user }: { user: AdminPanelUser }) { ))} + + +