From ec84763705d2c13ae02c7a33af678ed745597147 Mon Sep 17 00:00:00 2001 From: Chad Nuttall Date: Fri, 2 Oct 2026 10:21:21 -0400 Subject: [PATCH 1/2] feat(admin): add trade lead pipeline and follow-up tracking Closes #150. Turns the public trade inquiry flow into an operational sales pipeline for brewery staff: - New authenticated /admin/trade workspace: filterable lead list + detail view (status, owner, follow-up state, business type, search, newest/oldest/follow-up sorting) and a compact dashboard summary. - Canonical lifecycle: new -> contacted -> follow_up -> customer/closed, with closedAt/outcome captured on terminal transitions and follow-ups cleared so they can't render as misleading overdue states. - Append-only history in a tradeLeads/{id}/activities subcollection: notes plus automatic entries for creation, status, owner, and follow-up changes, each carrying seq + author identity. - Ownership reuses adminUsers (active admins only, name snapshotted). - Optional nextFollowUpAt with overdue/due-today/upcoming classification by local calendar day. - Manual lead creation with an explicit source (whatsapp/phone/ in_person/referral/event/email/other) -- never mislabeled trade_form. - Channel-agnostic "communication" activity type reserved as the seam for future outbound messaging; no send controls shipped. - All reads/writes behind requireAdminActor API routes; firestore.rules still denies every client access to tradeLeads and its subcollection. - Meaningful mutations advance updatedAt/lastActivityAt (the 24-month retention anchor); reads never write; the prune script now removes the activities subcollection alongside each expired lead. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/(pages)/admin-trade-fixture/page.tsx | 26 + app/(pages)/admin/trade/page.tsx | 19 + .../trade-leads/[id]/activities/route.ts | 66 ++ app/api/admin/trade-leads/[id]/route.ts | 113 ++ app/api/admin/trade-leads/route.ts | 83 ++ app/robots.ts | 1 + components/admin-auth-gate.tsx | 137 +++ components/admin-trade-fixture.tsx | 14 + components/admin-trade-page.tsx | 12 + components/admin-trade-summary.tsx | 73 ++ components/admin-trade-workspace.tsx | 978 ++++++++++++++++++ components/admin-workspace.tsx | 3 + docs/TECHNICAL.md | 87 +- docs/admin/README.md | 1 + docs/admin/trade-inquiries.md | 82 +- firestore.rules | 14 +- lib/admin-serializers.ts | 2 +- lib/trade-leads-admin-common.ts | 726 +++++++++++++ lib/trade-leads-admin.ts | 265 +++++ lib/trade-leads-common.ts | 2 +- lib/trade-leads.ts | 34 +- rules-tests/firestore.rules.test.ts | 36 + scripts/prune-trade-leads.ts | 11 +- smoke-tests/admin-accessibility.spec.ts | 124 +++ smoke-tests/fixtures.ts | 12 + smoke-tests/seo.spec.ts | 4 + tests/lib/trade-leads-admin.test.ts | 475 +++++++++ tests/security-rules.test.ts | 16 + 28 files changed, 3365 insertions(+), 51 deletions(-) create mode 100644 app/(pages)/admin-trade-fixture/page.tsx create mode 100644 app/(pages)/admin/trade/page.tsx create mode 100644 app/api/admin/trade-leads/[id]/activities/route.ts create mode 100644 app/api/admin/trade-leads/[id]/route.ts create mode 100644 app/api/admin/trade-leads/route.ts create mode 100644 components/admin-auth-gate.tsx create mode 100644 components/admin-trade-fixture.tsx create mode 100644 components/admin-trade-page.tsx create mode 100644 components/admin-trade-summary.tsx create mode 100644 components/admin-trade-workspace.tsx create mode 100644 lib/trade-leads-admin-common.ts create mode 100644 lib/trade-leads-admin.ts create mode 100644 tests/lib/trade-leads-admin.test.ts diff --git a/app/(pages)/admin-trade-fixture/page.tsx b/app/(pages)/admin-trade-fixture/page.tsx new file mode 100644 index 0000000..d38c93c --- /dev/null +++ b/app/(pages)/admin-trade-fixture/page.tsx @@ -0,0 +1,26 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { AdminTradeFixture } from "@/components/admin-trade-fixture"; + +// Test-only rendering of the admin trade-lead pipeline — same gate as +// /admin-fixture (ADMIN_A11Y_FIXTURE is set only by the Playwright webServer +// config; normal deployments 404). It grants no access to real data: the +// workspace's API calls are intercepted by Playwright route mocks. +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { + title: "Admin trade fixture", + robots: { index: false, follow: false }, +}; + +export default function AdminTradeFixturePage() { + if (process.env.ADMIN_A11Y_FIXTURE !== "1") { + notFound(); + } + + return ( +
+ +
+ ); +} diff --git a/app/(pages)/admin/trade/page.tsx b/app/(pages)/admin/trade/page.tsx new file mode 100644 index 0000000..95d1262 --- /dev/null +++ b/app/(pages)/admin/trade/page.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from "next"; +import { AdminTradePage } from "@/components/admin-trade-page"; + +export const metadata: Metadata = { + title: "Trade Leads — Admin", + description: "Deep Dive Brewing Co admin trade-lead pipeline.", + robots: { + index: false, + follow: false, + }, +}; + +export default function AdminTradeRoute() { + return ( +
+ +
+ ); +} diff --git a/app/api/admin/trade-leads/[id]/activities/route.ts b/app/api/admin/trade-leads/[id]/activities/route.ts new file mode 100644 index 0000000..9f6ca50 --- /dev/null +++ b/app/api/admin/trade-leads/[id]/activities/route.ts @@ -0,0 +1,66 @@ +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 { + addTradeLeadNote, + getTradeLead, + listTradeLeadActivities, + tradeLeadActorOf, +} from "@/lib/trade-leads-admin"; +import { + serializeTradeLead, + serializeTradeLeadActivity, + validateNoteBody, +} from "@/lib/trade-leads-admin-common"; + +interface RouteParams { + params: Promise<{ id: string }>; +} + +// Append-only internal note. The note body is lead PII-adjacent content: +// logged as lead id + action only. +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 = validateNoteBody( + (body as Record)?.note + ); + if (!parsed.ok) return badRequestResponse(parsed.error); + + 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) + ), + }); + } catch (error) { + return apiErrorResponse(error, { + fallback: "Failed to add note.", + event: "trade_lead.note_failed", + context: { requestId }, + }); + } +} diff --git a/app/api/admin/trade-leads/[id]/route.ts b/app/api/admin/trade-leads/[id]/route.ts new file mode 100644 index 0000000..0317395 --- /dev/null +++ b/app/api/admin/trade-leads/[id]/route.ts @@ -0,0 +1,113 @@ +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 { + applyLeadPatch, + getTradeLead, + listTradeLeadActivities, + resolveAssignee, + tradeLeadActorOf, +} from "@/lib/trade-leads-admin"; +import { + parseLeadPatchBody, + serializeTradeLead, + serializeTradeLeadActivity, + type NormalizedLeadPatch, +} from "@/lib/trade-leads-admin-common"; + +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); + if (!idToken) return unauthorizedResponse(); + + try { + await requireAdminActor(idToken); + const { id } = await params; + const detail = await leadDetail(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. + return NextResponse.json({ ok: true, ...detail }); + } catch (error) { + return apiErrorResponse(error, { + fallback: "Failed to load trade lead.", + event: "trade_lead.get_failed", + context: { requestId }, + }); + } +} + +export async function PATCH(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 = parseLeadPatchBody(body); + if (!parsed.ok) return badRequestResponse(parsed.error); + + const patch: NormalizedLeadPatch = { + status: parsed.patch.status, + nextFollowUpAt: parsed.patch.nextFollowUpAt, + outcome: parsed.patch.outcome, + }; + // The uid is untrusted input: resolve it to a verified, active admin + // record so a forged uid/name pair can never reach the document. + if (parsed.patch.assignedToUid !== undefined) { + patch.assignee = parsed.patch.assignedToUid + ? await resolveAssignee(parsed.patch.assignedToUid) + : null; + } + + const result = await applyLeadPatch(id, patch, tradeLeadActorOf(actor)); + if (result.changed) { + logInfo("trade_lead.updated", { + leadId: id, + actions: result.activityTypes.join(","), + requestId, + }); + } + + const detail = await leadDetail(id); + return NextResponse.json({ ok: true, ...detail }); + } catch (error) { + return apiErrorResponse(error, { + fallback: "Failed to update trade lead.", + event: "trade_lead.update_failed", + context: { requestId }, + }); + } +} diff --git a/app/api/admin/trade-leads/route.ts b/app/api/admin/trade-leads/route.ts new file mode 100644 index 0000000..1351fa7 --- /dev/null +++ b/app/api/admin/trade-leads/route.ts @@ -0,0 +1,83 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { requireAdminActor } from "@/lib/admin-auth"; +import { getBearerToken, badRequestResponse, unauthorizedResponse } from "@/lib/api-auth"; +import { apiErrorResponse } from "@/lib/api-error"; +import { getRequestId, logInfo } from "@/lib/log"; +import { + createManualTradeLead, + getTradeLead, + listAssignableAdmins, + listTradeLeads, + tradeLeadActorOf, +} from "@/lib/trade-leads-admin"; +import { + parseManualLeadBody, + serializeTradeLead, +} from "@/lib/trade-leads-admin-common"; + +export async function GET(req: NextRequest) { + const requestId = getRequestId(req.headers); + const idToken = getBearerToken(req); + if (!idToken) return unauthorizedResponse(); + + try { + await requireAdminActor(idToken); + const [leads, admins] = await Promise.all([ + listTradeLeads(), + listAssignableAdmins(), + ]); + return NextResponse.json({ + ok: true, + leads: leads.map((lead) => serializeTradeLead(lead.id, lead.data)), + admins, + }); + } catch (error) { + return apiErrorResponse(error, { + fallback: "Failed to load trade leads.", + event: "trade_leads.list_failed", + context: { requestId }, + }); + } +} + +export async function POST(req: NextRequest) { + 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 parsed = parseManualLeadBody(body); + if (!parsed.ok) return badRequestResponse(parsed.error); + + // Lead id and action name only — never the submitted PII (#150). + const leadId = await createManualTradeLead( + parsed.input, + tradeLeadActorOf(actor) + ); + logInfo("trade_lead.created", { + leadId, + source: parsed.input.source, + requestId, + }); + + const created = await getTradeLead(leadId); + return NextResponse.json({ + ok: true, + lead: created ? serializeTradeLead(created.id, created.data) : null, + }); + } catch (error) { + return apiErrorResponse(error, { + fallback: "Failed to create trade lead.", + event: "trade_lead.create_failed", + context: { requestId }, + }); + } +} diff --git a/app/robots.ts b/app/robots.ts index dfdcb7f..cedea24 100644 --- a/app/robots.ts +++ b/app/robots.ts @@ -13,6 +13,7 @@ export default function robots(): MetadataRoute.Robots { disallow: [ "/admin", "/admin-fixture", + "/admin-trade-fixture", "/carousel-fixture", "/where-to-buy-fixture", "/trade/login", diff --git a/components/admin-auth-gate.tsx b/components/admin-auth-gate.tsx new file mode 100644 index 0000000..0c99a4d --- /dev/null +++ b/components/admin-auth-gate.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useEffect, useState, type ReactNode } from "react"; +import Link from "next/link"; +import { + getIdTokenResult, + GoogleAuthProvider, + onAuthStateChanged, + signInWithPopup, + signOut, + type User, +} from "firebase/auth"; +import { Button } from "@/components/ui/button"; +import { getFirebaseAuth } from "@/lib/firebase"; + +// Minimal sign-in/authorization shell for admin sub-pages (/admin/trade). +// The full bootstrap + invitation flow stays on /admin — a signed-in user +// without admin claims is pointed there rather than duplicating that flow. +export function AdminAuthGate({ + heading, + children, +}: { + heading: string; + children: (user: User) => ReactNode; +}) { + const [user, setUser] = useState(null); + const [authReady, setAuthReady] = useState(false); + const [isAdmin, setIsAdmin] = useState(false); + const [statusMessage, setStatusMessage] = useState(""); + + useEffect(() => { + let unsub: (() => void) | undefined; + try { + unsub = onAuthStateChanged( + getFirebaseAuth(), + async (nextUser) => { + setUser(nextUser); + setIsAdmin(false); + if (nextUser) { + try { + const tokenResult = await getIdTokenResult(nextUser, true); + const claims = tokenResult.claims as { + admin?: boolean; + role?: string; + }; + setIsAdmin( + claims.admin === true && + (claims.role === "admin" || claims.role === "superadmin") + ); + } catch (error) { + console.error("Failed to resolve admin session:", error); + } + } + setAuthReady(true); + }, + (error) => { + console.error("Auth state listener failed:", error); + setStatusMessage("Sign-in is currently unavailable."); + setAuthReady(true); + } + ); + } catch (error) { + console.error("Firebase Auth is unavailable:", error); + // Deferred so the fallback isn't a synchronous setState in the effect. + queueMicrotask(() => { + setStatusMessage("Sign-in is currently unavailable."); + setAuthReady(true); + }); + return; + } + return () => unsub(); + }, []); + + async function handleGoogleSignIn() { + setStatusMessage(""); + try { + await signInWithPopup(getFirebaseAuth(), new GoogleAuthProvider()); + } catch (error) { + console.error(error); + setStatusMessage("Sign-in failed. Please try again."); + } + } + + if (!authReady) { + return ( +

+ Loading admin... +

+ ); + } + + if (!user) { + return ( +
+

{heading}

+

+ Sign in with an authorized Google account to manage trade leads. +

+ + {statusMessage && ( +

+ {statusMessage} +

+ )} +
+ ); + } + + if (!isAdmin) { + return ( +
+

{heading}

+

+ {user.email} is not authorized for admin access. +

+

+ If you were invited, accept the invitation from the{" "} + + admin dashboard + + . +

+ +
+ ); + } + + return <>{children(user)}; +} diff --git a/components/admin-trade-fixture.tsx b/components/admin-trade-fixture.tsx new file mode 100644 index 0000000..27d5e10 --- /dev/null +++ b/components/admin-trade-fixture.tsx @@ -0,0 +1,14 @@ +"use client"; + +// Deterministic rendering of the trade-lead pipeline for the Playwright +// accessibility suite — same pattern as admin-fixture.tsx. Rendered only by +// /admin-trade-fixture (gated server-side by ADMIN_A11Y_FIXTURE); it never +// touches Firebase, and /api/admin/trade-leads* calls are intercepted by +// Playwright route mocks. +import { AdminTradeWorkspace } from "@/components/admin-trade-workspace"; + +export function AdminTradeFixture() { + return ( + "fixture-token" }} /> + ); +} diff --git a/components/admin-trade-page.tsx b/components/admin-trade-page.tsx new file mode 100644 index 0000000..54bfc66 --- /dev/null +++ b/components/admin-trade-page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { AdminAuthGate } from "@/components/admin-auth-gate"; +import { AdminTradeWorkspace } from "@/components/admin-trade-workspace"; + +export function AdminTradePage() { + return ( + + {(user) => } + + ); +} diff --git a/components/admin-trade-summary.tsx b/components/admin-trade-summary.tsx new file mode 100644 index 0000000..19381d9 --- /dev/null +++ b/components/admin-trade-summary.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import type { AdminPanelUser } from "@/components/admin-access"; +import { + classifyFollowUp, + type TradeLeadView, +} from "@/lib/trade-leads-admin-common"; + +interface SummaryCounts { + fresh: number; + dueToday: number; + overdue: number; +} + +// Compact entry point into /admin/trade (#150). Loads the same authorized +// list endpoint the pipeline page uses and derives the actionable counts; +// a failed load still leaves the working link rather than a broken card. +export function AdminTradeSummary({ user }: { user: AdminPanelUser }) { + const [counts, setCounts] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const idToken = await user.getIdToken(); + const res = await fetch("/api/admin/trade-leads", { + headers: { Authorization: `Bearer ${idToken}` }, + }); + const data = (await res.json().catch(() => ({}))) as { + ok?: boolean; + leads?: TradeLeadView[]; + }; + if (cancelled || !res.ok || !data.ok || !data.leads) return; + const next: SummaryCounts = { fresh: 0, dueToday: 0, overdue: 0 }; + for (const lead of data.leads) { + if (lead.status === "new") next.fresh++; + const state = classifyFollowUp(lead.nextFollowUpAt, lead.status); + if (state === "due_today") next.dueToday++; + else if (state === "overdue") next.overdue++; + } + setCounts(next); + } catch { + // Deliberately quiet — this card is a convenience entry point, not + // the operational surface itself. + } + })(); + return () => { + cancelled = true; + }; + }, [user]); + + return ( +
+
+

Trade leads

+

+ {counts + ? `${counts.fresh} new · ${counts.dueToday} due today · ${counts.overdue} overdue` + : "New inquiries and follow-ups live in the trade pipeline."} +

+
+ +
+ ); +} diff --git a/components/admin-trade-workspace.tsx b/components/admin-trade-workspace.tsx new file mode 100644 index 0000000..186c774 --- /dev/null +++ b/components/admin-trade-workspace.tsx @@ -0,0 +1,978 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} 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 { + classifyFollowUp, + describeTradeLeadActivity, + MANUAL_LEAD_SOURCES, + TRADE_LEAD_STATUSES, + tradeLeadSourceLabel, + tradeLeadStatusLabel, + tradeVenueTypeLabel, + type FollowUpState, + type TradeLeadActivityView, + type TradeLeadAssignee, + type TradeLeadStatus, + type TradeLeadView, +} from "@/lib/trade-leads-admin-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). +const fieldClass = "w-full rounded-md border border-ink/50 px-3 py-2"; + +type StatusFilter = "all" | TradeLeadStatus; +type FollowUpFilter = "all" | FollowUpState; +type SortMode = "newest" | "oldest" | "follow_up"; + +interface LeadDetail { + lead: TradeLeadView; + activities: TradeLeadActivityView[]; +} + +const EMPTY_LEAD_FORM = { + businessName: "", + contactName: "", + email: "", + phoneOrWhatsapp: "", + venueType: "", + source: "", + message: "", +}; + +function dateToLocalDateInput(iso: string | undefined): string { + if (!iso) return ""; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ""; + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +// "YYYY-MM-DD" from → local-midnight instant. Storing the +// instant keeps the picked calendar day intact in the viewer's timezone — +// the date-only string must never go through `new Date(value)` (UTC parse). +function dateInputToIso(value: string): string | null { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim()); + if (!match) return null; + const d = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])); + if ( + d.getFullYear() !== Number(match[1]) || + d.getMonth() !== Number(match[2]) - 1 || + d.getDate() !== Number(match[3]) + ) { + return null; + } + return d.toISOString(); +} + +function leadMillis(iso?: string): number { + if (!iso) return 0; + const t = new Date(iso).getTime(); + return Number.isNaN(t) ? 0 : t; +} + +// "Last activity" falls back through the chain an older record actually has: +// pre-pipeline leads only carry createdAt/updatedAt. +export function leadLastActivityIso(lead: TradeLeadView): string | undefined { + return lead.lastActivityAt ?? lead.updatedAt ?? lead.createdAt; +} + +const STATUS_BADGE_CLASS: Record = { + new: "border-transparent bg-primary text-primary-foreground", + contacted: "border-transparent bg-secondary text-secondary-foreground", + follow_up: "border-amber-500/40 bg-amber-100/70 text-amber-900", + customer: "border-moss/40 bg-moss/10 text-moss", + closed: "border-border text-muted-foreground", +}; + +function StatusBadge({ status }: { status: TradeLeadStatus }) { + return ( + + {tradeLeadStatusLabel(status)} + + ); +} + +function FollowUpBadge({ lead }: { lead: TradeLeadView }) { + const state = classifyFollowUp(lead.nextFollowUpAt, lead.status); + if (state === "none") return null; + if (state === "overdue") { + return ( + + Overdue · {formatAdminDate(lead.nextFollowUpAt)} + + ); + } + if (state === "due_today") { + return ( + + Due today + + ); + } + return ( + {formatAdminDate(lead.nextFollowUpAt)} + ); +} + +// wa.me accepts digits only; strip formatting from whatever was submitted. +function whatsappDigits(phone: string): string { + return phone.replace(/[^\d]/g, ""); +} + +export function AdminTradeWorkspace({ user }: { user: AdminPanelUser }) { + const [leads, setLeads] = useState([]); + const [admins, setAdmins] = useState([]); + const [loading, setLoading] = useState(true); + const [statusMessage, setStatusMessage] = useState(""); + const [statusIsError, setStatusIsError] = useState(false); + + const [selectedId, setSelectedId] = useState(null); + const [detail, setDetail] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + + const [statusFilter, setStatusFilter] = useState("all"); + const [ownerFilter, setOwnerFilter] = useState("all"); + const [typeFilter, setTypeFilter] = useState("all"); + const [followUpFilter, setFollowUpFilter] = useState("all"); + const [sortMode, setSortMode] = useState("newest"); + const [search, setSearch] = useState(""); + + const [saving, setSaving] = useState(null); + const [noteDraft, setNoteDraft] = useState(""); + const [followUpDraft, setFollowUpDraft] = useState(""); + const [outcomeDraft, setOutcomeDraft] = useState(""); + + const [newLeadOpen, setNewLeadOpen] = useState(false); + const [newLeadForm, setNewLeadForm] = useState(EMPTY_LEAD_FORM); + const [newLeadError, setNewLeadError] = useState(""); + const [creating, setCreating] = useState(false); + + const apiFetch = useCallback( + async (path: string, init?: RequestInit) => { + const idToken = await user.getIdToken(); + const res = await fetch(path, { + ...init, + headers: { + Authorization: `Bearer ${idToken}`, + ...(init?.body ? { "Content-Type": "application/json" } : {}), + }, + }); + const data = (await res.json().catch(() => ({}))) as Record< + string, + unknown + > & { ok?: boolean; error?: string }; + if (!res.ok || data.ok !== true) { + throw new Error(data.error ?? "Request failed."); + } + return data; + }, + [user] + ); + + const load = useCallback(async () => { + setLoading(true); + try { + const data = await apiFetch("/api/admin/trade-leads"); + setLeads((data.leads as TradeLeadView[]) ?? []); + setAdmins((data.admins as TradeLeadAssignee[]) ?? []); + } catch (error) { + console.error(error); + setStatusMessage("Failed to load trade leads."); + setStatusIsError(true); + } finally { + setLoading(false); + } + }, [apiFetch]); + + useEffect(() => { + void load(); + }, [load]); + + const openLead = useCallback( + async (id: string) => { + setSelectedId(id); + setDetailLoading(true); + setDetail(null); + setNoteDraft(""); + setOutcomeDraft(""); + try { + const data = await apiFetch(`/api/admin/trade-leads/${id}`); + const next = { + lead: data.lead as TradeLeadView, + activities: (data.activities as TradeLeadActivityView[]) ?? [], + }; + setDetail(next); + setFollowUpDraft(dateToLocalDateInput(next.lead.nextFollowUpAt)); + setOutcomeDraft(next.lead.outcome ?? ""); + setLeads((prev) => + prev.map((lead) => (lead.id === next.lead.id ? next.lead : lead)) + ); + } catch (error) { + console.error(error); + setStatusMessage("Failed to load the selected lead."); + setStatusIsError(true); + } finally { + setDetailLoading(false); + } + }, + [apiFetch] + ); + + // Applies the fresh lead + activities a mutation response returns. + function applyDetail(next: LeadDetail) { + setDetail(next); + setFollowUpDraft(dateToLocalDateInput(next.lead.nextFollowUpAt)); + setOutcomeDraft(next.lead.outcome ?? ""); + setLeads((prev) => { + const exists = prev.some((lead) => lead.id === next.lead.id); + return exists + ? prev.map((lead) => (lead.id === next.lead.id ? next.lead : lead)) + : [next.lead, ...prev]; + }); + } + + async function patchLead( + fields: Record, + actionKey: string + ) { + if (!detail || saving) return; + setSaving(actionKey); + setStatusMessage(""); + try { + const data = await apiFetch(`/api/admin/trade-leads/${detail.lead.id}`, { + method: "PATCH", + body: JSON.stringify(fields), + }); + applyDetail({ + lead: data.lead as TradeLeadView, + activities: (data.activities as TradeLeadActivityView[]) ?? [], + }); + setStatusMessage("Lead updated."); + setStatusIsError(false); + } catch (error) { + setStatusMessage( + error instanceof Error ? error.message : "Update failed." + ); + setStatusIsError(true); + } finally { + setSaving(null); + } + } + + async function addNote(e: React.FormEvent) { + e.preventDefault(); + if (!detail || !noteDraft.trim() || saving) return; + setSaving("note"); + setStatusMessage(""); + try { + const data = await apiFetch( + `/api/admin/trade-leads/${detail.lead.id}/activities`, + { method: "POST", body: JSON.stringify({ note: noteDraft }) } + ); + applyDetail({ + lead: data.lead as TradeLeadView, + activities: (data.activities as TradeLeadActivityView[]) ?? [], + }); + setNoteDraft(""); + setStatusMessage("Note added."); + setStatusIsError(false); + } catch (error) { + setStatusMessage( + error instanceof Error ? error.message : "Could not add the note." + ); + setStatusIsError(true); + } finally { + setSaving(null); + } + } + + async function createLead(e: React.FormEvent) { + e.preventDefault(); + if (creating) return; + setCreating(true); + setNewLeadError(""); + try { + const data = await apiFetch("/api/admin/trade-leads", { + method: "POST", + body: JSON.stringify(newLeadForm), + }); + const lead = data.lead as TradeLeadView | null; + if (!lead) throw new Error("Lead was created but could not be loaded."); + setLeads((prev) => [lead, ...prev]); + setNewLeadOpen(false); + setNewLeadForm(EMPTY_LEAD_FORM); + setStatusMessage("Lead created."); + setStatusIsError(false); + if (lead?.id) await openLead(lead.id); + } catch (error) { + setNewLeadError( + error instanceof Error ? error.message : "Could not create the lead." + ); + } finally { + setCreating(false); + } + } + + const filtered = useMemo(() => { + const query = search.trim().toLowerCase(); + const list = leads.filter((lead) => { + if (statusFilter !== "all" && lead.status !== statusFilter) return false; + if (ownerFilter === "unassigned" && lead.assignedToUid) return false; + if ( + ownerFilter !== "all" && + ownerFilter !== "unassigned" && + lead.assignedToUid !== ownerFilter + ) + return false; + if (typeFilter !== "all" && lead.venueType !== typeFilter) return false; + if ( + followUpFilter !== "all" && + classifyFollowUp(lead.nextFollowUpAt, lead.status) !== followUpFilter + ) + return false; + if ( + query && + !`${lead.businessName} ${lead.contactName}` + .toLowerCase() + .includes(query) + ) + return false; + return true; + }); + list.sort((a, b) => { + if (sortMode === "oldest") { + return leadMillis(a.createdAt) - leadMillis(b.createdAt); + } + if (sortMode === "follow_up") { + // Soonest first; leads without a follow-up sink to the bottom. + const av = leadMillis(a.nextFollowUpAt) || Number.MAX_SAFE_INTEGER; + const bv = leadMillis(b.nextFollowUpAt) || Number.MAX_SAFE_INTEGER; + return av - bv || leadMillis(b.createdAt) - leadMillis(a.createdAt); + } + return leadMillis(b.createdAt) - leadMillis(a.createdAt); + }); + return list; + }, [leads, statusFilter, ownerFilter, typeFilter, followUpFilter, sortMode, search]); + + const counts = useMemo(() => { + let fresh = 0; + let dueToday = 0; + let overdue = 0; + for (const lead of leads) { + if (lead.status === "new") fresh++; + const state = classifyFollowUp(lead.nextFollowUpAt, lead.status); + if (state === "due_today") dueToday++; + else if (state === "overdue") overdue++; + } + return { fresh, dueToday, overdue }; + }, [leads]); + + const selectedTerminal = + detail && (detail.lead.status === "customer" || detail.lead.status === "closed"); + + // The lead's current owner might be a since-disabled admin who is absent + // from the assignable list — surface them so the select stays truthful. + const ownerOptions = useMemo(() => { + const options = [...admins]; + if ( + detail?.lead.assignedToUid && + detail.lead.assignedToName && + !options.some((a) => a.uid === detail.lead.assignedToUid) + ) { + options.push({ + uid: detail.lead.assignedToUid, + name: detail.lead.assignedToName, + }); + } + return options; + }, [admins, detail]); + + const timeline = useMemo( + () => + detail + ? [...detail.activities].sort((a, b) => (b.seq ?? 0) - (a.seq ?? 0)) + : [], + [detail] + ); + + const waDigits = detail ? whatsappDigits(detail.lead.phoneOrWhatsapp) : ""; + + return ( +
+
+
+
+

Trade Leads

+

+ + Admin dashboard + {" "} + → Trade leads +

+
+
+ {!loading && ( +

+ {counts.fresh} new · {counts.dueToday} due today ·{" "} + {counts.overdue} overdue +

+ )} + +
+
+ {statusMessage && ( +

+ {statusMessage} +

+ )} +
+ +
+
+
+ + + + + + +
+ + {loading ? ( +

+ Loading leads... +

+ ) : leads.length === 0 ? ( +

+ No trade leads yet. New website inquiries appear here + automatically; use New lead to record one by hand. +

+ ) : filtered.length === 0 ? ( +

+ No leads match the current filters. +

+ ) : ( +
    + {filtered.map((lead) => ( +
  • + +
  • + ))} +
+ )} +
+ +
+ {!selectedId ? ( +

+ Select a lead from the list to view its details and history. +

+ ) : detailLoading || !detail ? ( +

+ Loading lead... +

+ ) : ( +
+
+
+

+ {detail.lead.businessName} +

+

+ {tradeLeadSourceLabel(detail.lead.source)} ·{" "} + {tradeVenueTypeLabel(detail.lead.venueType)} +

+
+ +
+ +
+
+

Contact

+

+ {detail.lead.contactName || "—"} +

+
+ {detail.lead.email && ( + + {detail.lead.email} + + )} + {detail.lead.phoneOrWhatsapp && ( + <> + + {detail.lead.phoneOrWhatsapp} + + {waDigits && ( + + WhatsApp + + )} + + )} +
+
+
+

Dates

+

+ Created {formatAdminDateTime(detail.lead.createdAt)} +
+ Last activity{" "} + {formatAdminDateTime(leadLastActivityIso(detail.lead))} + {detail.lead.closedAt && ( + <> +
+ Closed {formatAdminDate(detail.lead.closedAt)} + + )} +

+
+
+ + {detail.lead.message && ( +
+

Original inquiry

+

+ {detail.lead.message} +

+
+ )} + +
+ + +
+ + {selectedTerminal && ( +
+ + +
+ )} + + {!selectedTerminal && ( +
+ + + {detail.lead.nextFollowUpAt && ( + + )} + +
+ )} + +
+