-
-
Contact
-
+
+
+
Contact
+
{detail.lead.contactName || "—"}
{detail.lead.email && (
+
{detail.lead.email}
)}
@@ -641,8 +724,9 @@ export function AdminTradeWorkspace({ user }: { user: AdminPanelUser }) {
<>
+
{detail.lead.phoneOrWhatsapp}
{waDigits && (
@@ -650,8 +734,9 @@ export function AdminTradeWorkspace({ user }: { user: AdminPanelUser }) {
href={`https://wa.me/${waDigits}`}
target="_blank"
rel="noopener noreferrer"
- className="text-ocean hover:underline"
+ className="inline-flex items-center gap-1.5 text-ocean hover:underline"
>
+
WhatsApp
)}
@@ -659,13 +744,13 @@ export function AdminTradeWorkspace({ user }: { user: AdminPanelUser }) {
)}
-
-
Dates
+
+
Activity
- Created {formatAdminDateTime(detail.lead.createdAt)}
-
Last activity{" "}
- {formatAdminDateTime(leadLastActivityIso(detail.lead))}
+ {formatAdminDate(leadLastActivityIso(detail.lead))}
+
+ Created {formatAdminDate(detail.lead.createdAt)}
{detail.lead.closedAt && (
<>
@@ -677,138 +762,135 @@ export function AdminTradeWorkspace({ user }: { user: AdminPanelUser }) {
{detail.lead.message && (
-
-
Original inquiry
-
+
+
Original inquiry
+
{detail.lead.message}
)}
-
-
- Status
-
- void patchLead({ status: e.target.value }, "status")
- }
- >
- {TRADE_LEAD_STATUSES.map((s) => (
-
- {s.label}
-
- ))}
-
-
-
- Owner
-
- void patchLead(
- { assignedToUid: e.target.value || null },
- "owner"
- )
- }
- >
- Unassigned
- {ownerOptions.map((a) => (
-
- {a.name}
-
- ))}
-
-
-
-
- {selectedTerminal && (
-
+ ) : (
+
+
+ Next follow-up
+
+
+ setFollowUpDraft(e.target.value)}
+ // Save on blur, not per keystroke: date inputs emit a
+ // change event per segment while typing (year "2026"
+ // passes through "0202", a valid-but-wrong date). An
+ // emptied field does NOT clear; clearing stays
+ // explicit via the Clear button.
+ onBlur={(e) => {
+ const iso = dateInputToIso(e.target.value);
+ if (
+ iso &&
+ e.target.value !==
+ dateToLocalDateInput(detail.lead.nextFollowUpAt)
+ ) {
+ void patchLead({ nextFollowUpAt: iso }, "followup");
+ }
+ }}
+ />
+
+ {detail.lead.nextFollowUpAt && (
+
+ void patchLead({ nextFollowUpAt: null }, "followup")
+ }
+ >
+ Clear
+
+ )}
+
+
+ )}
+
-
History
+
History
{timeline.length === 0 ? (
No activity recorded yet.
) : (
-
- {timeline.map((activity) => (
-
-
- {formatAdminDateTime(activity.createdAt)}
- {activity.authorName
- ? ` · ${activity.authorName}`
- : ""}
-
- {describeTradeLeadActivity(activity)}
- {activity.body && (
-
- {activity.body}
-
- )}
-
- ))}
+
+ {timeline.map((activity) => {
+ const iconMeta =
+ ACTIVITY_ICON[activity.type] ?? DEFAULT_ACTIVITY_ICON;
+ const ActivityIcon = iconMeta.icon;
+ const isNote = activity.type === "note";
+ return (
+
+
+
+
+ {formatAdminDateTime(activity.createdAt)}
+ {activity.authorName
+ ? ` · ${activity.authorName}`
+ : ""}
+
+
+ {isNote
+ ? activity.body
+ : describeTradeLeadActivity(activity)}
+
+ {!isNote && activity.body && (
+
+ {activity.body}
+
+ )}
+
+
+ );
+ })}
)}
+ )}
+ >
)}
diff --git a/lib/trade-leads-admin-common.ts b/lib/trade-leads-admin-common.ts
index 1ab4607..cfa5aa2 100644
--- a/lib/trade-leads-admin-common.ts
+++ b/lib/trade-leads-admin-common.ts
@@ -320,6 +320,22 @@ export function classifyFollowUp(
return "upcoming";
}
+// Signed calendar-day delta between the follow-up date and `today` in the
+// viewer's local timezone: negative = overdue by N days, 0 = due today,
+// positive = N days until due. Returns null when the lead has no active
+// follow-up (unset, unparseable, or terminal).
+export function followUpDayDelta(
+ nextFollowUpAt: string | Date | null | undefined,
+ status: string,
+ today: CalendarDate = todayCalendarDate()
+): number | null {
+ if (classifyFollowUp(nextFollowUpAt, status, today) === "none") return null;
+ if (nextFollowUpAt == null) return null;
+ const date =
+ nextFollowUpAt instanceof Date ? nextFollowUpAt : new Date(nextFollowUpAt);
+ return dayNumber(calendarDateOf(date)) - dayNumber(today);
+}
+
// --- Admin mutations (pure planning layer) ---
export class TradeLeadError extends Error {
diff --git a/scripts/trade-polish-shots.mjs b/scripts/trade-polish-shots.mjs
new file mode 100644
index 0000000..b8ae160
--- /dev/null
+++ b/scripts/trade-polish-shots.mjs
@@ -0,0 +1,212 @@
+// Temporary visual-inspection script for the /admin/trade polish pass.
+// Serves fixture data via route mocks and screenshots the workspace at
+// several viewports. Run with a `next start` server that has
+// ADMIN_A11Y_FIXTURE=1 (BASE_URL env to override the default port).
+import { chromium } from "playwright";
+import { mkdir } from "fs/promises";
+import { fileURLToPath } from "url";
+import { dirname, join } from "path";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const outDir = join(__dirname, "..", "screenshots", "trade-polish");
+await mkdir(outDir, { recursive: true });
+
+const baseUrl = process.env.BASE_URL ?? "http://localhost:3105";
+
+const lead = (over) => ({
+ venueType: "bar",
+ status: "new",
+ source: "trade_form",
+ createdAt: "2026-10-01T12:00:00.000Z",
+ updatedAt: "2026-10-02T12:00:00.000Z",
+ lastActivityAt: "2026-10-02T12:00:00.000Z",
+ ...over,
+});
+
+// "Today" shifts with the clock; anchor relative dates to now so the
+// overdue/due-today/upcoming pills are exercised regardless of run date.
+const daysAgo = (n) => new Date(Date.now() - n * 864e5).toISOString();
+const daysAhead = (n) => new Date(Date.now() + n * 864e5).toISOString();
+
+const LEADS = [
+ lead({
+ id: "lead-1",
+ businessName: "Fixture Harbour Bar",
+ contactName: "Sam Keeper",
+ email: "sam@harbour.example",
+ phoneOrWhatsapp: "+599 555 1234",
+ message:
+ "Interested in carrying the Pilsner on tap. We have two lines free from next month and a fridge for cans. Can someone drop by with samples and the wholesale price list?",
+ status: "contacted",
+ assignedToName: "Chad",
+ assignedToUid: "u-1",
+ nextFollowUpAt: daysAgo(2),
+ }),
+ lead({
+ id: "lead-2",
+ businessName: "Saba Bottleshop",
+ contactName: "Maya Reyes",
+ email: "maya@bottleshop.example",
+ venueType: "retail",
+ status: "follow_up",
+ nextFollowUpAt: daysAgo(0),
+ }),
+ lead({
+ id: "lead-3",
+ businessName: "Windward Restaurant & Grill House",
+ contactName: "Tom",
+ venueType: "restaurant",
+ status: "new",
+ nextFollowUpAt: daysAhead(5),
+ createdAt: daysAgo(1),
+ lastActivityAt: daysAgo(1),
+ updatedAt: daysAgo(1),
+ }),
+ lead({
+ id: "lead-4",
+ businessName: "Hilltop Hotel",
+ contactName: "Ana",
+ venueType: "hotel",
+ status: "customer",
+ source: "whatsapp",
+ assignedToName: "Chad",
+ assignedToUid: "u-1",
+ outcome: "First order placed",
+ closedAt: daysAgo(3),
+ createdAt: daysAgo(30),
+ lastActivityAt: daysAgo(3),
+ updatedAt: daysAgo(3),
+ }),
+ lead({
+ id: "lead-5",
+ businessName: "Island Dive Shop",
+ venueType: "other",
+ status: "closed",
+ source: "in_person",
+ closedAt: daysAgo(10),
+ createdAt: daysAgo(40),
+ lastActivityAt: daysAgo(10),
+ updatedAt: daysAgo(10),
+ }),
+];
+
+const ACTIVITIES = [
+ {
+ id: "a-0",
+ type: "lead_created",
+ seq: 0,
+ details: { source: "trade_form" },
+ createdAt: "2026-10-01T12:00:00.000Z",
+ },
+ {
+ id: "a-1",
+ type: "owner_changed",
+ seq: 1,
+ authorUid: "u-1",
+ authorName: "Chad",
+ details: { fromName: null, toName: "Chad" },
+ createdAt: "2026-10-01T13:00:00.000Z",
+ },
+ {
+ id: "a-2",
+ type: "status_changed",
+ seq: 2,
+ authorUid: "u-1",
+ authorName: "Chad",
+ details: { from: "new", to: "contacted" },
+ createdAt: "2026-10-02T09:00:00.000Z",
+ },
+ {
+ id: "a-3",
+ type: "note",
+ seq: 3,
+ authorUid: "u-1",
+ authorName: "Chad",
+ body: "Spoke on WhatsApp. Interested in the Pilsner — wants samples first.",
+ createdAt: "2026-10-02T10:00:00.000Z",
+ },
+ {
+ id: "a-4",
+ type: "follow_up_set",
+ seq: 4,
+ authorUid: "u-1",
+ authorName: "Chad",
+ details: { to: daysAgo(2) },
+ createdAt: "2026-10-02T10:05:00.000Z",
+ },
+];
+
+const mockApi = async (page) => {
+ await page.route(/\/api\/admin\/trade-leads/, (route) => {
+ const url = route.request().url();
+ const method = route.request().method();
+ const json = (body, status = 200) =>
+ route.fulfill({
+ status,
+ contentType: "application/json",
+ body: JSON.stringify(body),
+ });
+ if (method === "GET" && url.endsWith("/api/admin/trade-leads")) {
+ return json({
+ ok: true,
+ leads: LEADS,
+ admins: [
+ { uid: "u-1", name: "Chad" },
+ { uid: "u-2", name: "Malachy" },
+ ],
+ });
+ }
+ if (method === "GET" && url.includes("/api/admin/trade-leads/")) {
+ const id = url.split("/api/admin/trade-leads/")[1].split("?")[0];
+ const found = LEADS.find((l) => l.id === id);
+ return json({ ok: true, lead: found, activities: ACTIVITIES });
+ }
+ if (method === "PATCH" || method === "POST") {
+ return json({ ok: true, lead: LEADS[0], activities: ACTIVITIES });
+ }
+ return json({ ok: false, error: "Unexpected fixture request" });
+ });
+};
+
+const viewports = [
+ { width: 1440, height: 900, name: "desktop-1440" },
+ { width: 1024, height: 768, name: "laptop-1024" },
+ { width: 820, height: 1000, name: "tablet-820" },
+ { width: 390, height: 844, name: "mobile-390" },
+];
+
+const browser = await chromium.launch({ headless: true });
+
+for (const vp of viewports) {
+ const context = await browser.newContext({ viewport: vp });
+ const p = await context.newPage();
+ await mockApi(p);
+ try {
+ await p.goto(`${baseUrl}/admin-trade-fixture`, {
+ waitUntil: "networkidle",
+ timeout: 20000,
+ });
+ await p.getByText("Fixture Harbour Bar").first().waitFor();
+ await p.waitForTimeout(400);
+ await p.screenshot({
+ path: join(outDir, `list-${vp.name}.png`),
+ fullPage: true,
+ });
+ await p
+ .getByRole("button", { name: /Fixture Harbour Bar/ })
+ .press("Enter");
+ await p.getByText("Spoke on WhatsApp").waitFor();
+ await p.waitForTimeout(400);
+ await p.screenshot({
+ path: join(outDir, `detail-${vp.name}.png`),
+ fullPage: true,
+ });
+ console.log(`done ${vp.name}`);
+ } catch (err) {
+ console.error(`Failed @ ${vp.name}: ${err.message}`);
+ } finally {
+ await context.close();
+ }
+}
+
+await browser.close();
diff --git a/tests/lib/trade-leads-admin.test.ts b/tests/lib/trade-leads-admin.test.ts
index 599d0fc..8fa3868 100644
--- a/tests/lib/trade-leads-admin.test.ts
+++ b/tests/lib/trade-leads-admin.test.ts
@@ -6,6 +6,7 @@ import {
buildLeadUpdate,
classifyFollowUp,
DELETE_FIELD,
+ followUpDayDelta,
describeTradeLeadActivity,
isManualLeadSource,
isTerminalLeadStatus,
@@ -105,6 +106,35 @@ describe("classifyFollowUp", () => {
});
});
+describe("followUpDayDelta", () => {
+ const followUpIso = (year: number, month: number, day: number) =>
+ new Date(year, month - 1, day).toISOString();
+
+ it("returns signed calendar-day deltas", () => {
+ assert.strictEqual(
+ followUpDayDelta(followUpIso(2026, 9, 30), "contacted", TODAY),
+ -2
+ );
+ assert.strictEqual(
+ followUpDayDelta(followUpIso(2026, 10, 2), "contacted", TODAY),
+ 0
+ );
+ assert.strictEqual(
+ followUpDayDelta(followUpIso(2026, 10, 5), "contacted", TODAY),
+ 3
+ );
+ });
+
+ it("returns null when there is no active follow-up", () => {
+ assert.strictEqual(followUpDayDelta(undefined, "new", TODAY), null);
+ assert.strictEqual(followUpDayDelta("junk", "new", TODAY), null);
+ assert.strictEqual(
+ followUpDayDelta(followUpIso(2026, 9, 1), "closed", TODAY),
+ null
+ );
+ });
+});
+
describe("parseLeadPatchBody", () => {
it("accepts a status patch", () => {
const result = parseLeadPatchBody({ status: "contacted" });