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..59cc04e --- /dev/null +++ b/components/admin-auth-gate.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { useEffect, useRef, 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); + // Claims are resolved asynchronously after each auth event; while pending, + // the gate must show neither the workspace nor a false "not authorized". + const [claimsResolved, setClaimsResolved] = useState(false); + const [statusMessage, setStatusMessage] = useState(""); + // uid of the most recent auth event — a slow getIdTokenResult must never + // apply claims to a user that has since signed out or been replaced. + const latestUidRef = useRef(null); + + useEffect(() => { + let unsub: (() => void) | undefined; + try { + unsub = onAuthStateChanged( + getFirebaseAuth(), + async (nextUser) => { + const uid = nextUser?.uid ?? null; + latestUidRef.current = uid; + setUser(nextUser); + setIsAdmin(false); + setClaimsResolved(false); + setAuthReady(true); + if (!nextUser) { + setClaimsResolved(true); + return; + } + try { + const tokenResult = await getIdTokenResult(nextUser, true); + // A newer auth event superseded this resolution — drop it. + if (latestUidRef.current !== uid) return; + 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); + } + if (latestUidRef.current === uid) { + setClaimsResolved(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 || (user && !claimsResolved)) { + 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..d704019 --- /dev/null +++ b/components/admin-trade-workspace.tsx @@ -0,0 +1,995 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, 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); + // Synchronous mirror of selectedId — async detail/mutation responses must + // only paint if they still belong to the currently selected lead. Without + // this a slow fetch for lead A can overwrite the panel for lead B and a + // follow-up edit would land on the wrong record. + const selectedIdRef = useRef(null); + + 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) => { + selectedIdRef.current = id; + 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[]) ?? [], + }; + // The list is shared state — always merge the fresh record so a + // stale panel can't keep showing outdated values later. + setLeads((prev) => + prev.map((lead) => (lead.id === next.lead.id ? next.lead : lead)) + ); + // Selection moved on while this fetch was in flight — discard the + // detail (the newer request owns the panel and the loading flag). + if (selectedIdRef.current !== id) return; + setDetail(next); + setFollowUpDraft(dateToLocalDateInput(next.lead.nextFollowUpAt)); + setOutcomeDraft(next.lead.outcome ?? ""); + } catch (error) { + if (selectedIdRef.current !== id) return; + console.error(error); + setStatusMessage("Failed to load the selected lead."); + setStatusIsError(true); + } finally { + if (selectedIdRef.current === id) { + setDetailLoading(false); + } + } + }, + [apiFetch] + ); + + // 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). + function applyDetail(next: LeadDetail) { + 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]; + }); + if (selectedIdRef.current !== next.lead.id) return; + setDetail(next); + setFollowUpDraft(dateToLocalDateInput(next.lead.nextFollowUpAt)); + setOutcomeDraft(next.lead.outcome ?? ""); + } + + 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 && ( + + )} + +
+ )} + +
+