From a767652d740475060d2193b26dc4ac9868334896 Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Sat, 2 May 2026 04:09:51 +0200 Subject: [PATCH] feat(sales-ux): triage signals, reminders, realtime toasts, mobile FAB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sales-CRM workflow batch — closes audit recommendations #2, #3, #4, #6, #7, #8, #9, #10, #13, #15. Skips #11 (My-pipeline filter — needs a real assignee column on interests, defer until ownership model lands) and #12 (keyboard shortcuts — explicit user call). Interest list (the rep's main triage surface): - Last activity column replaces Created (sortable by dateLastContact). Postgres NULLs-last on DESC means never-contacted leads sort to the bottom — exactly the right triage default. - Comment-icon next to client name when notesCount > 0, with a tooltip showing the count. Cheap, glanceable signal that the lead has correspondence to peek at. - Urgency badges under stage when criteria fire: "Silent Nd" for mid-funnel interests with no contact in 7+ days, "EOI Nd" for EOIs awaiting signature 14+ days, "Deposit Nd" for eoi_signed interests with no deposit after 21 days. Pure derived — no extra fetch, computed from the dates the row already returns. - Bulk select checkbox column with bulk-archive (existing DataTable.bulkActions API; just wired with a confirm-dialog and a Promise.all fan-out). - Mobile FAB (+) for new interest, anchored above the bottom-tab bar with safe-area inset awareness. All four signals mirrored on the mobile InterestCard (comment icon, urgency badges, last-activity footer). Interest detail: - Reminder bell badge in the header showing pending/snoozed reminder count linked to the interest. Surfaced via getInterestById's new `activeReminderCount`. - "Latest note" teaser on the Overview tab — truncated 3-line preview of the most recent threaded note + relative time + "View all" link to the Notes tab. Saves a click for the common "what was discussed last?" peek. - Color-block swatches in InlineStagePicker dropdown (rounded-sm mini-bars in the stage's progressive saturation color, replacing the previous tiny dots). Reads as a visual scan instead of a list. Dashboard: - MyRemindersRail on the right sidebar above the existing AlertRail. Shows pending+snoozed reminders for the current user (overdue first), each with priority pill, relative due time, and click-through to the linked interest/client/berth. Berth detail: - BerthInterestPulse card at the top of the Overview tab, replacing the old "buried in tab" pattern. Shows up to 5 active interests with avatar, stage pill, urgency badges, and last-activity. Mirrors the old Nuxt CRM's beloved "Interested Parties" panel but with the new triage signals. Realtime toasts: - New mounted inside SocketProvider in the dashboard layout. Subscribes to interest:stageChanged, document:completed, document:signer:signed, and interest:outcomeSet — fires sonner toasts so reps watching any page learn about pipeline events without refreshing. Service layer: - listInterests: notesCount per row (left join + count + groupBy). - getInterestById: clientPrimaryPhone + clientPrimaryPhoneE164 (for the Email/Call/WhatsApp buttons added last commit; phone pieces were missing), notesCount, recentNote, activeReminderCount. - sortColumn switch handles 'dateLastContact' explicitly; default stays 'updatedAt'. tsc clean. vitest 835/835 pass. ESLint clean on every file touched. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/(dashboard)/layout.tsx | 2 + .../berths/berth-interest-pulse.tsx | 166 ++++++++++++++++++ src/components/berths/berth-tabs.tsx | 151 ++++++++-------- src/components/dashboard/dashboard-shell.tsx | 8 +- .../dashboard/my-reminders-rail.tsx | 153 ++++++++++++++++ .../interests/inline-stage-picker.tsx | 163 +++++++++++++++++ src/components/interests/interest-card.tsx | 52 +++++- src/components/interests/interest-columns.tsx | 103 ++++++++--- .../interests/interest-detail-header.tsx | 16 ++ src/components/interests/interest-detail.tsx | 11 ++ src/components/interests/interest-list.tsx | 46 ++++- src/components/interests/interest-tabs.tsx | 40 +++++ src/components/interests/urgency.ts | 91 ++++++++++ src/components/shared/realtime-toasts.tsx | 84 +++++++++ src/lib/services/interests.service.ts | 54 +++++- 15 files changed, 1033 insertions(+), 107 deletions(-) create mode 100644 src/components/berths/berth-interest-pulse.tsx create mode 100644 src/components/dashboard/my-reminders-rail.tsx create mode 100644 src/components/interests/inline-stage-picker.tsx create mode 100644 src/components/interests/urgency.ts create mode 100644 src/components/shared/realtime-toasts.tsx diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index c42f178..eb386ba 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -13,6 +13,7 @@ import { PermissionsProvider } from '@/providers/permissions-provider'; import { Sidebar } from '@/components/layout/sidebar'; import { Topbar } from '@/components/layout/topbar'; import { MobileLayout } from '@/components/layout/mobile/mobile-layout'; +import { RealtimeToasts } from '@/components/shared/realtime-toasts'; export default async function DashboardLayout({ children }: { children: React.ReactNode }) { const session = await auth.api.getSession({ headers: await headers() }); @@ -38,6 +39,7 @@ export default async function DashboardLayout({ children }: { children: React.Re + {/* Desktop shell — hidden by CSS on mobile */}
(); + const portSlug = params?.portSlug ?? ''; + + const { data, isLoading } = useQuery({ + queryKey: ['interests', { berthId, sort: 'dateLastContact', order: 'desc' }], + queryFn: () => + apiFetch( + `/api/v1/interests?berthId=${berthId}&limit=10&sort=dateLastContact&order=desc`, + ), + staleTime: 30_000, + }); + + const all = data?.data ?? []; + const active = all.filter((i) => !i.archivedAt && !i.outcome); + const preview = active.slice(0, PREVIEW_LIMIT); + const more = active.length - preview.length; + + if (isLoading) { + return ( + + + Interested parties + + +
+ {[0, 1, 2].map((i) => ( +
+ ))} +
+ + + ); + } + + if (active.length === 0) { + return ( + + + + + Interested parties + + + +

No active interests on this berth.

+
+
+ ); + } + + return ( + + + + + Interested parties + + {active.length} + + + + +
    + {preview.map((i) => { + const lastIso = i.dateLastContact ?? i.updatedAt ?? null; + const lastActivity = lastIso + ? formatDistanceToNowStrict(new Date(lastIso), { addSuffix: true }) + : null; + const urgency = computeUrgencyBadges(i); + const initials = (i.clientName ?? '?') + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((p) => p[0]!.toUpperCase()) + .join(''); + return ( +
  • + + + {initials || '?'} + +
    +
    + + {i.clientName ?? 'Unknown'} + + + {stageLabel(i.pipelineStage)} + + {urgency.map((b) => ( + + {b.label} + + ))} +
    + {lastActivity ? ( +

    + Last activity {lastActivity} +

    + ) : null} +
    + + +
  • + ); + })} +
+ {more > 0 ? ( + + View all {active.length} interests → + + ) : null} +
+
+ ); +} diff --git a/src/components/berths/berth-tabs.tsx b/src/components/berths/berth-tabs.tsx index 3ea6b9e..2fb1514 100644 --- a/src/components/berths/berth-tabs.tsx +++ b/src/components/berths/berth-tabs.tsx @@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { TagBadge } from '@/components/shared/tag-badge'; import { BerthReservationsTab } from './berth-reservations-tab'; import { BerthInterestsTab } from './berth-interests-tab'; +import { BerthInterestPulse } from './berth-interest-pulse'; type BerthData = { id: string; @@ -72,93 +73,99 @@ function OverviewTab({ berth }: { berth: BerthData }) { : null; return ( -
- {/* Specifications */} - - - Specifications - - - - - - - - - - - - - - +
+ {/* Sales pulse — top-of-page so reps doing berth-level triage can see + who's interested + how warm without clicking into the Interests tab. */} + - {/* Infrastructure & Pricing */} -
+
+ {/* Specifications */} - Infrastructure - - - - - - - - - - - - - - Tenure & Pricing + Specifications + - {berth.tenureType === 'fixed_term' && ( - <> - - - - - )} - + + + + + + + + - {berth.tags.length > 0 && ( + {/* Infrastructure & Pricing */} +
- Tags + Infrastructure - -
- {berth.tags.map((tag) => ( - - ))} -
+ + + + + + +
- )} + + + + Tenure & Pricing + + + + {berth.tenureType === 'fixed_term' && ( + <> + + + + + )} + + + + + {berth.tags.length > 0 && ( + + + Tags + + +
+ {berth.tags.map((tag) => ( + + ))} +
+
+
+ )} +
); diff --git a/src/components/dashboard/dashboard-shell.tsx b/src/components/dashboard/dashboard-shell.tsx index 9c21c8e..2967a33 100644 --- a/src/components/dashboard/dashboard-shell.tsx +++ b/src/components/dashboard/dashboard-shell.tsx @@ -11,6 +11,7 @@ import { PipelineFunnelChart } from './pipeline-funnel-chart'; import { OccupancyTimelineChart } from './occupancy-timeline-chart'; import { RevenueBreakdownChart } from './revenue-breakdown-chart'; import { LeadSourceChart } from './lead-source-chart'; +import { MyRemindersRail } from './my-reminders-rail'; import { WidgetErrorBoundary } from './widget-error-boundary'; import { AlertRail } from '@/components/alerts/alert-rail'; import type { DateRange } from '@/lib/services/analytics.service'; @@ -49,7 +50,7 @@ export function DashboardShell() { actions={} /> -
+
@@ -68,7 +69,10 @@ export function DashboardShell() {
-