From 3b5686bbbe764451cefccb528d0302df2ccb273a Mon Sep 17 00:00:00 2001 From: Chad Nuttall Date: Fri, 2 Oct 2026 15:22:06 -0400 Subject: [PATCH 1/2] refactor(admin): polish trade lead workspace UI Densify /admin/trade without changing the data model or workflow: narrower list rail, unmistakable selected-lead state, follow-up date input that saves on pick, quieter original-inquiry block, icon-coded activity timeline, and a mobile list/detail switch with a back affordance. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- components/admin-trade-workspace.tsx | 494 ++++++++++++++++----------- lib/trade-leads-admin-common.ts | 16 + scripts/trade-polish-shots.mjs | 212 ++++++++++++ tests/lib/trade-leads-admin.test.ts | 30 ++ 4 files changed, 555 insertions(+), 197 deletions(-) create mode 100644 scripts/trade-polish-shots.mjs diff --git a/components/admin-trade-workspace.tsx b/components/admin-trade-workspace.tsx index d704019..3c1319e 100644 --- a/components/admin-trade-workspace.tsx +++ b/components/admin-trade-workspace.tsx @@ -2,6 +2,18 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; +import { + ArrowLeft, + ArrowRightLeft, + CalendarClock, + Flag, + Mail, + MessageSquare, + Phone, + Sparkles, + UserRound, + type LucideIcon, +} from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -16,6 +28,7 @@ import { TRADE_VENUE_TYPES } from "@/lib/trade-leads-common"; import { classifyFollowUp, describeTradeLeadActivity, + followUpDayDelta, MANUAL_LEAD_SOURCES, TRADE_LEAD_STATUSES, tradeLeadSourceLabel, @@ -31,6 +44,12 @@ import { // 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"; +// Denser variant for the left-rail filters. +const filterFieldClass = + "w-full rounded-md border border-ink/50 px-2.5 py-1.5 text-sm"; +// Small uppercase labels group read-only context vs. editable controls. +const sectionLabelClass = + "text-xs font-medium uppercase tracking-wide text-muted-foreground"; type StatusFilter = "all" | TradeLeadStatus; type FollowUpFilter = "all" | FollowUpState; @@ -104,17 +123,27 @@ function StatusBadge({ status }: { status: TradeLeadStatus }) { ); } -function FollowUpBadge({ lead }: { lead: TradeLeadView }) { - const state = classifyFollowUp(lead.nextFollowUpAt, lead.status); - if (state === "none") return null; - if (state === "overdue") { +function FollowUpBadge({ + lead, + showEmpty = false, +}: { + lead: TradeLeadView; + showEmpty?: boolean; +}) { + const delta = followUpDayDelta(lead.nextFollowUpAt, lead.status); + if (delta === null) { + return showEmpty ? ( + No follow-up + ) : null; + } + if (delta < 0) { return ( - Overdue · {formatAdminDate(lead.nextFollowUpAt)} + {delta === -1 ? "Overdue" : `Overdue by ${-delta} days`} ); } - if (state === "due_today") { + if (delta === 0) { return ( {formatAdminDate(lead.nextFollowUpAt)} + Due {formatAdminDate(lead.nextFollowUpAt)} ); } +// Timeline type → icon + tint. Restrained accents: notes read as the +// human-authored entries, system entries stay muted. +const ACTIVITY_ICON: Record< + string, + { icon: LucideIcon; className: string } +> = { + note: { icon: MessageSquare, className: "text-ocean" }, + lead_created: { icon: Sparkles, className: "text-moss" }, + status_changed: { icon: ArrowRightLeft, className: "text-ink" }, + owner_changed: { icon: UserRound, 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" }, + outcome_changed: { icon: Flag, className: "text-moss" }, + communication: { icon: Mail, className: "text-muted-foreground" }, +}; +const DEFAULT_ACTIVITY_ICON = { + icon: MessageSquare, + 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, ""); @@ -247,6 +297,14 @@ export function AdminTradeWorkspace({ user }: { user: AdminPanelUser }) { [apiFetch] ); + // Clears the selection (mobile "back to list" and any future deselect). + function closeLead() { + selectedIdRef.current = null; + setSelectedId(null); + setDetail(null); + setDetailLoading(false); + } + // 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). @@ -430,54 +488,58 @@ export function AdminTradeWorkspace({ user }: { user: AdminPanelUser }) { const waDigits = detail ? whatsappDigits(detail.lead.phoneOrWhatsapp) : ""; return ( -
-
+
+
-

Trade Leads

-

+

Trade Leads

+

Admin dashboard {" "} → Trade leads

-
+
{!loading && ( -

+

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

)} - +
{statusMessage && (

{statusMessage}

)}
-
-
+
+
-
- - {selectedTerminal && ( -
-
+ ) : ( +
+ + Next follow-up + +
+ { + const value = e.target.value; + setFollowUpDraft(value); + // A complete picked date is explicit intent — the + // same save-on-select pattern as the status/owner + // selects. An emptied field does NOT clear; clearing + // stays explicit via the Clear button. + const iso = dateInputToIso(value); + if (iso) { + void patchLead({ nextFollowUpAt: iso }, "followup"); + } + }} + /> + + {detail.lead.nextFollowUpAt && ( + + )} +
+
+ )} +