From 14ae41d0fae54821f45b30ce46e514f2ee7992a9 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 25 May 2026 03:40:37 +0200 Subject: [PATCH] feat(uat-b1): ship Wave A-E of Bucket 1 audit findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave A (Interest+EOI form quick wins): - Auto-select yacht after inline-create from interest form - EOI generate dialog: "View EOI" action toast - Interest form berth picker: formatBerthRange compact label - Remove "Generate EOI" button from Documents tab (clean removal) - Interest auto-assign: only sales_agent/sales_manager auto-claim ownership on create (explicit role check via user_port_roles join) - LinkedBerthRowItem dims: drop "D" suffix + "L Γ— W" format - ExternalEoiUploadDialog: prefillSignatories prop threaded from active EOI signers - EOI signature progress on Overview milestone card footer Wave B (a11y + i18n sweeps): - aria-live on supplemental-info error state - text-[10px] -> text-xs in client-pipeline-summary - Currency formatter: locale default removed (Intl uses runtime) - en-US/en-GB hardcoded toLocaleString swept across 13 components Wave C (Primary berth always in EOI bundle): - Service guard strengthened on update path - Migration 0083 backfills historical primary rows Wave D (Onboarding super_admin discoverability): - /api/v1/admin/onboarding/status endpoint + shared service - Topbar OnboardingBanner (super_admin, session-dismissible) - OnboardingTile dashboard widget (rail group, self-hides at 100%) - Celebration toast + invalidate of shared status on last tick Wave E (Branded post-completion email idempotency): - Verified handleDocumentCompleted already owns the email fan-out - Added regression test for the polling path + idempotency Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/superpowers/audits/alpha-uat-master.md | 26 +++ src/app/(dashboard)/layout.tsx | 2 + .../api/v1/admin/onboarding/status/route.ts | 24 +++ .../public/supplemental-info/[token]/page.tsx | 2 +- .../document-templates/template-list.tsx | 4 +- src/components/admin/onboarding-banner.tsx | 95 ++++++++++ src/components/admin/onboarding-checklist.tsx | 23 ++- .../clients/client-pipeline-summary.tsx | 10 +- src/components/dashboard/dashboard-shell.tsx | 4 +- .../dashboard/date-range-picker.tsx | 2 +- src/components/dashboard/onboarding-tile.tsx | 83 +++++++++ src/components/dashboard/widget-registry.tsx | 14 ++ src/components/documents/document-detail.tsx | 2 +- src/components/documents/document-list.tsx | 2 +- src/components/documents/documents-hub.tsx | 2 +- .../documents/entity-folder-view.tsx | 2 +- .../documents/eoi-generate-dialog.tsx | 10 + src/components/documents/hub-root-view.tsx | 2 +- .../documents/signing-details-dialog.tsx | 2 +- src/components/expenses/expense-columns.tsx | 4 +- src/components/expenses/expense-detail.tsx | 4 +- .../interests/external-eoi-upload-dialog.tsx | 15 +- .../interests/interest-documents-tab.tsx | 18 +- src/components/interests/interest-eoi-tab.tsx | 32 ++++ src/components/interests/interest-form.tsx | 25 ++- src/components/interests/interest-tabs.tsx | 75 +++++++- .../interests/linked-berths-list.tsx | 11 +- src/components/invoices/invoice-columns.tsx | 2 +- src/components/invoices/invoice-detail.tsx | 2 +- .../reports/export-dashboard-pdf-button.tsx | 2 +- .../reports/export-list-pdf-button.tsx | 2 +- .../reservations/reservation-detail.tsx | 2 +- .../website-analytics/pageviews-chart.tsx | 2 +- src/hooks/use-onboarding-status.ts | 44 +++++ .../0083_primary_berth_in_bundle_backfill.sql | 7 + src/lib/services/interest-berths.service.ts | 17 +- src/lib/services/interests.service.ts | 17 +- src/lib/services/onboarding.service.ts | 174 ++++++++++++++++++ src/lib/utils/currency.ts | 10 +- .../documents-completion-email-fanout.test.ts | 129 +++++++++++++ 40 files changed, 835 insertions(+), 70 deletions(-) create mode 100644 src/app/api/v1/admin/onboarding/status/route.ts create mode 100644 src/components/admin/onboarding-banner.tsx create mode 100644 src/components/dashboard/onboarding-tile.tsx create mode 100644 src/hooks/use-onboarding-status.ts create mode 100644 src/lib/db/migrations/0083_primary_berth_in_bundle_backfill.sql create mode 100644 src/lib/services/onboarding.service.ts create mode 100644 tests/integration/documents-completion-email-fanout.test.ts diff --git a/docs/superpowers/audits/alpha-uat-master.md b/docs/superpowers/audits/alpha-uat-master.md index 914819a2..c9c97537 100644 --- a/docs/superpowers/audits/alpha-uat-master.md +++ b/docs/superpowers/audits/alpha-uat-master.md @@ -138,6 +138,17 @@ _Copy tweaks, alignment, single-prop edits, obvious typos._ > - **Q10.** Output formats beyond PDF β€” CSV export of the underlying data, Excel workbook with one sheet per section, PNG/JPEG snapshots of each chart, public share-link to a hosted HTML version? > - **Q11.** Customisable report metadata β€” title + subtitle + cover-page copy + footer note. Today the PDF header is hardcoded "Dashboard summary Β· {date-range-line}" at `src/lib/pdf/reports/dashboard-report.tsx:195`; the render path already accepts a `subtitle` prop override but the dialog never exposes it. The dedicated-page builder should expose: report title, optional subtitle, optional intro paragraph, optional sign-off / footer (e.g. "Prepared for Board Meeting Q1 2026"). Saved-templates inherit these. > - **Action:** schedule a design session covering Q1-Q10 with the operator stakeholder. Output a short design doc (`docs/reports-page-design.md`) covering routing, data shape, scheduler, permissions, then scope into discrete Bucket 3 items. Until then, keep iterating the dialog (badges, data-availability, currency etc.). Captured 2026-05-24 from UAT. +> - **🟒 DECISIONS LOCKED 2026-05-24 (via AskUserQuestion):** +> - **Q1 + Q2 surface:** Dedicated `/{portSlug}/reports` page with full-page builder (left = sections grouped by domain + availability pills; right = live PDF preview). Keep adopting Q2's two-panel layout. +> - **Q3 templates:** Rename + archive, share with team (port-scoped), duplicate from another template. **Skip** "set default-for-port" at v1. +> - **Q4 run history:** Yes β€” ship in v1. New `report_runs` table; list view on /reports; "Re-run" CTA reproduces with same sections + range. +> - **Q5 scheduling:** Yes β€” v1 must include scheduling. Saved template + cadence (weekly / monthly / quarterly) + recipients via BullMQ. +> - **Q6 delivery:** Yes β€” "Generate & email" alongside "Generate & download." Recipients + optional message; same shape as scheduled delivery. +> - **Q7 permissions:** Two perms β€” `reports.export` (generate + download; everyone with current access) and `reports.admin` (manages BOTH templates AND schedules; super_admin only by default). +> - **Q8 dashboard button:** Keep the dashboard's "Export as PDF" as a quick-path that pre-selects the Dashboard report kind at /reports with the current date range pre-filled. One-click access preserved. +> - **Q10 output formats:** PDF (primary) + CSV export of underlying data + PNG/JPEG chart snapshots. **Skip** Excel workbook and public hosted-HTML share-link for v1. +> - **Q11 metadata:** Override report title + subtitle; cover-page logo / branding swap (use another port's branding on the cover). **Skip** cover-page intro paragraph and footer/sign-off note for v1. +> - **Action:** scope into Bucket 3 items. Next step: write `docs/reports-page-design.md` covering routing, table shape (`report_runs`, `report_templates_shared`), scheduler queue + cron handler, permission seed, then split into discrete PRs. > - **Dashboard PDF export dialog: surface per-section data availability + don't render uninformative "n/a" rows** β€” _src/lib/services/dashboard-report-data.service.ts_ (per-widget resolvers) + _src/components/reports/export-dashboard-pdf-button.tsx_ (sections checklist) + _src/lib/pdf/reports/dashboard-report.tsx_ (render-time empty-state handling). Today on a fresh port (e.g. Port Nimara), the Average Sales Cycle section renders "Median: n/a Β· Mean: n/a" because there are 0 signed contracts to compute against. Same risk for: stage_conversion_rates (needs deals that have progressed AND won), berth_demand_ranking (needs interests on berths), reminders_summary (needs reminders in window), recent_activity (needs audit-log entries), new_clients_period / new_interests_period (window-dependent), etc. The "n/a" output is noisy + the rep wasn't warned that the section would be empty. > - **Two-tier fix:** > - **(a) Cheap baseline (~30-45 min):** server-side omit-when-empty. Each resolver returns `null` (or sets `data[widget] = undefined`) when the resulting payload has no meaningful content. The PDF render path already gates on `data.X ?` so the section disappears entirely. Concrete sections to add the gate to: avg_sales_cycle (sampleSize === 0 β†’ omit), reminders_summary (no reminders β†’ omit or render the empty state with copy), stage_conversion_rates (no advanced deals β†’ omit), recent_activity (no events β†’ omit), every period-cohort resolver (count === 0 β†’ omit). When omitted, the section just doesn't appear in the PDF. @@ -647,6 +658,21 @@ _Component refactors, multi-file edits, single-service tweaks, new validators._ > - **Possible rename for clarity:** "Reservation Agreement" β†’ "Tenancy Agreement" (signed doc); "Reservation" β†’ "Tenancy" (occupancy record). Aligns with marina-industry vocabulary and removes the agreement/record confusion. > - **Why this is the A-Z final piece:** the CRM today covers lead β†’ qualification β†’ EOI β†’ reservation agreement β†’ contract β†’ handover. Reservations are the canonical record of what the sale produced β€” without them filled in, the system has no answer to "which berths are taken right now and by whom?" beyond ad-hoc interest-state inference. Everything downstream (renewals, occupancy reporting, transfer flows, revenue recognition timing) hangs off this. **Worth a dedicated session to design before any implementation.** > - **Action:** schedule a design session covering Q1-Q10 with stakeholders who care about the operational side (rep workflow + ops/leadership reporting). Output should be a short design doc (`docs/reservations-design.md` or similar) covering data-model decisions (split or unify), workflow entry points, automation rules, reporting surfaces, and a phased rollout. THEN scope into discrete Bucket 3 items. Captured 2026-05-24 from UAT. +> - **🟒 DECISIONS LOCKED 2026-05-24 (via AskUserQuestion):** +> - **Vocabulary split (key decision):** The pipeline-stage "reservation" + signed "Reservation Agreement" KEEP their names (they describe the right being reserved, not the occupancy). The occupancy record (the `berth_reservations` table + sidebar + client/yacht entity tabs + top-level page) is renamed **"Tenancy."** A Reservation Agreement gets signed β†’ results in a Tenancy. +> - **Q1 data model:** Unify β€” keep `berth_reservations` (to be renamed `tenancies`) as one table with `tenure_type` + `status` as discriminators. The problem isn't the model β€” it's that flows / nav / reporting aren't wired. Schema rename pass + the workflow fixes below. +> - **Q2 entry points:** All four creation surfaces β€” berth detail (existing), interest detail (new, at reservation stage+), top-level `/tenancies` page (new sidebar entry), client detail (new). Each pre-fills from its parent context. +> - **Q3 auto-create:** Auto-create as `pending` on signed reservation_agreement via the existing idempotent `handleDocumentCompleted` webhook. Rep confirms `startDate` + `tenureType` in a follow-up modal before `pending β†’ active`. Default `startDate` = signed date if not on the doc. +> - **Q4 multi-berth:** One tenancy per in-bundle berth (loop `interest_berths WHERE is_in_eoi_bundle=true`). Each gets its own lifecycle (renewals, ends independently). +> - **Q5 renewals:** Configurable per tenure type β€” `permanent` / `fee_simple` / `strata_lot` β†’ mutate the existing row (one record forever). `seasonal` / `fixed_term` β†’ new row each cycle, linked via `previous_tenancy_id` self-FK. +> - **Q5 transfers:** End old tenancy (`status='ended'`, `endDate=transfer date`) + mint new tenancy for new client with `transferred_from_tenancy_id` FK back to the old one. Preserves history. +> - **Q6 public map:** Active tenancy auto-flips `berths.status='sold'` ONLY when `tenure_type IN ('permanent', 'fee_simple', 'strata_lot')`. `seasonal` / `fixed_term` don't (they're temporary). Reversed when tenancy ends + no replacement is active. +> - **Q7 reporting (locked):** All four widgets ship in v1 β€” Occupancy heatmap by month, Renewals at risk (next 90 days), Revenue forecast by tenure expiry, Tenancy by tenure type breakdown. All four are gated by the platform-wide module-enabled rule below (don't render when the Tenancies module is dormant). +> - **Q8 permissions:** Three perms β€” `tenancies.view` (read), `tenancies.manage` (create + mutate + transfer; default super_admin + sales_manager + sales_agent), `tenancies.cancel` (cancel only; default super_admin + sales_manager). Cancel gets its own perm because of revenue implications. +> - **Q9 empty-state UX:** Always show the tab on Client / Yacht detail **when the Tenancies module is enabled** (see platform-wide rule below). When empty, render a friendly empty-state (icon + "No tenancies yet" + a "Create tenancy" button if user has `tenancies.manage`). Discoverable + drives the creation flow. +> - **Q10 invoicing:** v1 ships READ-ONLY β€” no auto-invoice generation on tenancy lifecycle. Decouple invoicing; revisit once we see how ports actually use the tenancy data. +> - **Platform-wide module-enabled rule (locked 2026-05-25):** the entire Tenancies module surface area is hidden by default. **A sold berth stays sold without any tenancy data** β€” the platform does not assume tenancies exist for sold berths. The Tenancies module only surfaces when EITHER (a) at least one `tenancies` row exists for the port (lazy auto-enable on first creation, including auto-create from a signed reservation_agreement), OR (b) an admin has explicitly enabled it via a new `system_settings.tenancies_module_enabled` boolean (default `false`). When disabled: hide sidebar entry, hide Client/Yacht/Berth `Tenancies` tab, hide all four reporting widgets from dashboard registry, hide top-level `/{portSlug}/tenancies` page (404), skip auto-create branch in `handleDocumentCompleted` (signed reservation_agreement still progresses the interest stage and flips `reservationDocStatus`, but does NOT mint a `tenancies` row). Admin Settings β†’ Operations gets a "Tenancies module" toggle with helper copy explaining what enabling/disabling does + a warning when disabling with existing rows ("This will hide N existing tenancies β€” data is preserved but invisible until re-enabled"). Module auto-flips to enabled on first row insert; never auto-disables. +> - **Action:** scope into Bucket 3 items. Next steps: write `docs/tenancies-design.md` covering (1) table rename migration (`berth_reservations` β†’ `tenancies` + `previous_tenancy_id` + `transferred_from_tenancy_id` self-FKs), (2) webhook auto-create branch (gated on module-enabled), (3) status-flip rules for public map, (4) sidebar entry + new permissions + module-enabled gating, (5) reporting widgets (all four, module-gated), (6) entity-tab empty-state CTAs (module-gated), (7) admin Operations toggle + auto-enable-on-first-insert behavior. Then split into PRs. > - **Interest create: duplicate-detection warning (overlap with existing open interest for same client)** β€” _src/lib/services/interests.service.ts_ (new helper `findOverlappingOpenInterests(portId, clientId, berthIds, { excludeInterestId? })`) + _src/lib/services/interest-berths.service.ts_ (read-side helper) + _src/components/interests/interest-form.tsx_ (new pre-submit warning panel) + _new route_ `GET /api/v1/interests/duplicate-check?clientId=...&berthIds=...`. Today a rep can create an "A1" interest for a client and then a second "A1-A10" interest for the same client without any signal β€” silent data quality erosion that compounds across pipeline reports, the public map ("Under Offer" precedence), recommender tier ladder, EOI bundles. Decision: **warn, do not block** β€” legitimate cases exist (re-opening a lost deal, parallel scenarios, renewal alongside existing). > - **Detection scope:** match on `client_id` + ANY berth-set intersection. "Open" = `outcome IS NULL` (or non-terminal); closed/lost/won interests are excluded from the warning. Use `interest_berths` as the source of truth (per CLAUDE.md β€” `interests.berth_id` does not exist post-0029); intersection is any shared `berth_id` across the candidate's berth list and any open sibling interest's berth list. Multi-tenant scope enforced via `port_id` on both sides of the join. > - **Service shape:** `findOverlappingOpenInterests(portId, clientId, berthIds, { excludeInterestId? })` returns `Array<{ id, displayLabel, pipelineStage, currentBerths: string[], overlappingBerths: string[], updatedAt }>` β€” sorted most-recent first. `displayLabel` reuses the same derivation used for the document-detail Interest sub-label (berth-range via `formatBerthRange()`). diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index fb01c4cf..415e1d48 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -11,6 +11,7 @@ import { SocketProvider } from '@/providers/socket-provider'; import { PortProvider } from '@/providers/port-provider'; import { PermissionsProvider } from '@/providers/permissions-provider'; import { AppShell } from '@/components/layout/app-shell'; +import { OnboardingBanner } from '@/components/admin/onboarding-banner'; import { DevModeBanner } from '@/components/shared/dev-mode-banner'; import { RealtimeToasts } from '@/components/shared/realtime-toasts'; import { WebVitalsReporter } from '@/components/shared/web-vitals-reporter'; @@ -84,6 +85,7 @@ export default async function DashboardLayout({ children }: { children: React.Re rerouted. Production hides itself (env.ts forbids the flag in prod) so the banner is dev/staging-only. */} + {/* #26: AppShell mounts ONE responsive tree (desktop OR * mobile) per render - never both - so pages don't pay the * double-state, double-fetch, double-Tabs-provider tax. */} diff --git a/src/app/api/v1/admin/onboarding/status/route.ts b/src/app/api/v1/admin/onboarding/status/route.ts new file mode 100644 index 00000000..a007dfd1 --- /dev/null +++ b/src/app/api/v1/admin/onboarding/status/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { errorResponse } from '@/lib/errors'; +import { resolveOnboardingStatus } from '@/lib/services/onboarding.service'; + +/** + * GET /api/v1/admin/onboarding/status β€” returns the resolved checklist + * state for the caller's port. Drives the topbar discoverability banner + * + dashboard onboarding tile + the admin checklist page summary. + * + * Permission-gated on `admin.manage_settings` to mirror the rest of the + * admin surface; non-admin users never see the banner / tile anyway. + */ +export const GET = withAuth( + withPermission('admin', 'manage_settings', async (_req, ctx) => { + try { + const status = await resolveOnboardingStatus(ctx.portId); + return NextResponse.json({ data: status }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/public/supplemental-info/[token]/page.tsx b/src/app/public/supplemental-info/[token]/page.tsx index bca0a78d..e5bcb86b 100644 --- a/src/app/public/supplemental-info/[token]/page.tsx +++ b/src/app/public/supplemental-info/[token]/page.tsx @@ -151,7 +151,7 @@ export default function SupplementalInfoPage({ params }: PageProps) { if (error) { return ( -
+

{error}

diff --git a/src/components/admin/document-templates/template-list.tsx b/src/components/admin/document-templates/template-list.tsx index 820cace1..15f951df 100644 --- a/src/components/admin/document-templates/template-list.tsx +++ b/src/components/admin/document-templates/template-list.tsx @@ -129,7 +129,7 @@ export function TemplateList() { accessorKey: 'updatedAt', header: 'Last Updated', cell: ({ row }) => - new Date(row.original.updatedAt).toLocaleDateString('en-GB', { + new Date(row.original.updatedAt).toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric', @@ -221,7 +221,7 @@ export function TemplateList() { Β· Updated{' '} - {new Date(original.updatedAt).toLocaleDateString('en-GB', { + {new Date(original.updatedAt).toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric', diff --git a/src/components/admin/onboarding-banner.tsx b/src/components/admin/onboarding-banner.tsx new file mode 100644 index 00000000..1fd15205 --- /dev/null +++ b/src/components/admin/onboarding-banner.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import { X, Sparkles, ChevronRight } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { usePermissions } from '@/hooks/use-permissions'; +import { useOnboardingStatus } from '@/hooks/use-onboarding-status'; +import { cn } from '@/lib/utils'; + +const DISMISS_STORAGE_KEY = 'pn-crm.onboarding-banner-dismissed'; + +function getInitialDismissed(): boolean { + if (typeof window === 'undefined') return false; + return sessionStorage.getItem(DISMISS_STORAGE_KEY) === '1'; +} + +/** + * Topbar banner nudging super_admins to finish onboarding while the + * checklist is incomplete. Renders nothing for non-super-admin roles and + * disappears for everyone once the checklist hits 100%. + * + * Dismissible per browser session β€” flag stored in sessionStorage so it + * comes back on next sign-in (we want it visible until they actually + * finish, not just clicked-away forever). + */ +export function OnboardingBanner() { + const params = useParams<{ portSlug: string }>(); + const portSlug = params?.portSlug ?? ''; + const { isSuperAdmin } = usePermissions(); + const { data, isLoading } = useOnboardingStatus({ enabled: isSuperAdmin }); + const [dismissed, setDismissed] = useState(getInitialDismissed); + + if (!isSuperAdmin || isLoading || !data) return null; + if (data.isComplete) return null; + if (dismissed) return null; + if (!portSlug) return null; + + const next = data.nextStep; + return ( +
+
+ + + Setup is {data.percent}% complete. {data.completed} of {data.total} steps + done.{' '} + {next ? ( + <> + Next:{' '} + + {next.label} + + + ) : null} + +
+
+ + +
+
+ ); +} diff --git a/src/components/admin/onboarding-checklist.tsx b/src/components/admin/onboarding-checklist.tsx index 5a65e068..47b0d450 100644 --- a/src/components/admin/onboarding-checklist.tsx +++ b/src/components/admin/onboarding-checklist.tsx @@ -1,9 +1,11 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import Link from 'next/link'; import { useParams } from 'next/navigation'; import { Check, Circle, Loader2, ExternalLink } from 'lucide-react'; +import { toast } from 'sonner'; +import { useQueryClient } from '@tanstack/react-query'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; @@ -251,6 +253,25 @@ export function OnboardingChecklist() { const completed = STEPS.filter((s) => stepDone(s.id)).length; const percent = Math.round((completed / STEPS.length) * 100); + // Fire a celebration toast exactly once when the last item ticks. Both + // auto- and manual-driven completions trigger it. Guarded against + // refetch noise so reloading the page when already 100% doesn't re-fire. + const queryClient = useQueryClient(); + const prevCompletedRef = useRef(null); + useEffect(() => { + if (loading) return; + const prev = prevCompletedRef.current; + if (prev !== null && prev < STEPS.length && completed === STEPS.length) { + toast.success('πŸŽ‰ Setup complete β€” every onboarding step is checked off.', { + duration: 6000, + }); + // Invalidate the shared status query so the banner + tile collapse + // immediately instead of waiting for the 60s cache to expire. + queryClient.invalidateQueries({ queryKey: ['admin', 'onboarding-status'] }); + } + prevCompletedRef.current = completed; + }, [completed, loading, queryClient]); + return (
diff --git a/src/components/clients/client-pipeline-summary.tsx b/src/components/clients/client-pipeline-summary.tsx index 1712ef77..a4a04707 100644 --- a/src/components/clients/client-pipeline-summary.tsx +++ b/src/components/clients/client-pipeline-summary.tsx @@ -96,7 +96,7 @@ export function StageStepper({ see the ladder ahead. The `xs` size variant hides this row to keep the cramped table-cell footprint intact. */} {size !== 'xs' ? ( -
+
{PIPELINE_STAGES.map((stage, i) => { const isReached = i <= idx; return ( @@ -104,7 +104,7 @@ export function StageStepper({ key={stage} className={cn( 'flex-1 truncate text-center', - isReached ? 'text-foreground' : 'text-muted-foreground/60', + isReached ? 'text-foreground' : 'text-muted-foreground', )} > {STAGE_SHORT_LABELS[stage]} @@ -193,13 +193,11 @@ function HeroVariant({ clientId, portSlug }: { clientId: string; portSlug: strin return (
- + Sales pipeline {activeCount > 1 ? ( - - Β· {activeCount} active - + Β· {activeCount} active ) : null}
diff --git a/src/components/dashboard/dashboard-shell.tsx b/src/components/dashboard/dashboard-shell.tsx index 2d936cb7..fa01119f 100644 --- a/src/components/dashboard/dashboard-shell.tsx +++ b/src/components/dashboard/dashboard-shell.tsx @@ -30,8 +30,8 @@ function rangeLabel(range: DateRange): string { year: 'numeric', timeZone: 'UTC', }; - const from = new Date(`${range.from}T00:00:00.000Z`).toLocaleDateString('en-US', fmt); - const to = new Date(`${range.to}T00:00:00.000Z`).toLocaleDateString('en-US', fmt); + const from = new Date(`${range.from}T00:00:00.000Z`).toLocaleDateString(undefined, fmt); + const to = new Date(`${range.to}T00:00:00.000Z`).toLocaleDateString(undefined, fmt); return `${from} – ${to}`; } return PRESET_LABELS[range]; diff --git a/src/components/dashboard/date-range-picker.tsx b/src/components/dashboard/date-range-picker.tsx index 8a739f9a..ab07ab38 100644 --- a/src/components/dashboard/date-range-picker.tsx +++ b/src/components/dashboard/date-range-picker.tsx @@ -32,7 +32,7 @@ function formatCustom(range: { from: string; to: string }): string { const fmt: Intl.DateTimeFormatOptions = sameYear ? { month: 'short', day: 'numeric', timeZone: 'UTC' } : { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }; - return `${from.toLocaleDateString('en-US', fmt)} – ${to.toLocaleDateString('en-US', fmt)}`; + return `${from.toLocaleDateString(undefined, fmt)} – ${to.toLocaleDateString(undefined, fmt)}`; } /** diff --git a/src/components/dashboard/onboarding-tile.tsx b/src/components/dashboard/onboarding-tile.tsx new file mode 100644 index 00000000..d627be93 --- /dev/null +++ b/src/components/dashboard/onboarding-tile.tsx @@ -0,0 +1,83 @@ +'use client'; + +import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import { ChevronRight, Sparkles } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Progress } from '@/components/ui/progress'; +import { usePermissions } from '@/hooks/use-permissions'; +import { useOnboardingStatus } from '@/hooks/use-onboarding-status'; + +/** + * Compact dashboard tile that surfaces onboarding progress for super_admins + * while the checklist is incomplete. Collapses (returns null) once setup is + * 100% complete so the dashboard doesn't stay cluttered after the org is + * past the initial onboarding phase. Non-super-admin users never see it. + */ +export function OnboardingTile() { + const params = useParams<{ portSlug: string }>(); + const portSlug = params?.portSlug ?? ''; + const { isSuperAdmin } = usePermissions(); + const { data, isLoading } = useOnboardingStatus({ enabled: isSuperAdmin }); + + if (!isSuperAdmin) return null; + if (isLoading) { + return ( + + + + + Setup checklist + + + + + + + ); + } + if (!data || data.isComplete) return null; + + return ( + + + + + Setup checklist + + + +
+ +

+ {data.completed} of {data.total} steps complete ({data.percent}%) +

+
+ {data.nextStep ? ( +
+

+ Next step +

+

{data.nextStep.label}

+
+ ) : null} + {portSlug ? ( + + ) : null} +
+
+ ); +} diff --git a/src/components/dashboard/widget-registry.tsx b/src/components/dashboard/widget-registry.tsx index 612c6766..ba480681 100644 --- a/src/components/dashboard/widget-registry.tsx +++ b/src/components/dashboard/widget-registry.tsx @@ -15,6 +15,7 @@ import dynamic from 'next/dynamic'; import { ActiveDealsTile } from './active-deals-tile'; import { ActivityFeed } from './activity-feed'; +import { OnboardingTile } from './onboarding-tile'; import { BerthHeatWidget } from './berth-heat-widget'; import { ClientsByCountryWidget } from './clients-by-country-widget'; import { HotDealsCard } from './hot-deals-card'; @@ -105,6 +106,19 @@ export interface DashboardWidget { } export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [ + // ── Onboarding (rail, super_admin-only) ───────────────────────────── + // Self-collapses when the checklist hits 100% and self-hides for + // non-super-admin users so most reps never see this tile at all. + { + id: 'onboarding_checklist', + label: 'Setup checklist', + description: + 'Progress + next-step nudge while the org is still going through Documenso / branding / users setup. Hidden once 100% complete.', + render: () => , + group: 'rail', + defaultVisible: true, + }, + // ── KPI tiles (rail) ──────────────────────────────────────────────── // Off by default - keep the existing dashboard layout unchanged for // users on first paint after the upgrade; reps can flip them on from diff --git a/src/components/documents/document-detail.tsx b/src/components/documents/document-detail.tsx index f01f9b6e..a4f898ec 100644 --- a/src/components/documents/document-detail.tsx +++ b/src/components/documents/document-detail.tsx @@ -277,7 +277,7 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) { diff --git a/src/components/documents/document-list.tsx b/src/components/documents/document-list.tsx index 2c743e0a..3abe570d 100644 --- a/src/components/documents/document-list.tsx +++ b/src/components/documents/document-list.tsx @@ -109,7 +109,7 @@ function DocRow({ doc, onDelete, onSend, onPreview }: DocRowProps) { {signerProgress} - {new Date(doc.createdAt).toLocaleDateString('en-GB')} + {new Date(doc.createdAt).toLocaleDateString(undefined)} diff --git a/src/components/documents/documents-hub.tsx b/src/components/documents/documents-hub.tsx index fd8b2867..6d493448 100644 --- a/src/components/documents/documents-hub.tsx +++ b/src/components/documents/documents-hub.tsx @@ -389,7 +389,7 @@ function FlatFolderListing({ {totalSigners > 0 ? `${signedCount}/${totalSigners} signed` : '-'} - {new Date(doc.createdAt).toLocaleDateString('en-GB')} + {new Date(doc.createdAt).toLocaleDateString(undefined)}
{expanded && doc.signers && doc.signers.length > 0 ? ( diff --git a/src/components/documents/entity-folder-view.tsx b/src/components/documents/entity-folder-view.tsx index f43f17c6..0f1a02f3 100644 --- a/src/components/documents/entity-folder-view.tsx +++ b/src/components/documents/entity-folder-view.tsx @@ -116,7 +116,7 @@ export function EntityFolderView({ portSlug, entityType, entityId }: Props) { ) : null}
- {new Date(f.createdAt).toLocaleDateString('en-GB')} + {new Date(f.createdAt).toLocaleDateString(undefined)} {signedFromDocumentId ? ( - {new Date(f.createdAt).toLocaleDateString('en-GB')} + {new Date(f.createdAt).toLocaleDateString(undefined)} ))} diff --git a/src/components/documents/signing-details-dialog.tsx b/src/components/documents/signing-details-dialog.tsx index f41af3b3..c57319a6 100644 --- a/src/components/documents/signing-details-dialog.tsx +++ b/src/components/documents/signing-details-dialog.tsx @@ -100,7 +100,7 @@ export function SigningDetailsDialog({ documentId, open, onOpenChange }: Props)
{s.signedAt ? ( - {new Date(s.signedAt).toLocaleDateString('en-GB')} + {new Date(s.signedAt).toLocaleDateString(undefined)} ) : null} {s.status} diff --git a/src/components/expenses/expense-columns.tsx b/src/components/expenses/expense-columns.tsx index d909200c..97232975 100644 --- a/src/components/expenses/expense-columns.tsx +++ b/src/components/expenses/expense-columns.tsx @@ -84,7 +84,7 @@ export function getExpenseColumns({ enableSorting: false, cell: ({ row }) => ( - {Number(row.original.amount).toLocaleString('en-US', { + {Number(row.original.amount).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2, })}{' '} @@ -100,7 +100,7 @@ export function getExpenseColumns({ row.original.amountUsd ? ( $ - {Number(row.original.amountUsd).toLocaleString('en-US', { + {Number(row.original.amountUsd).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2, })} diff --git a/src/components/expenses/expense-detail.tsx b/src/components/expenses/expense-detail.tsx index 320ae8a8..d1c42595 100644 --- a/src/components/expenses/expense-detail.tsx +++ b/src/components/expenses/expense-detail.tsx @@ -188,7 +188,7 @@ export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailPr

- {Number(expense.amount).toLocaleString('en-US', { + {Number(expense.amount).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2, })}{' '} @@ -197,7 +197,7 @@ export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailPr {expense.amountUsd && expense.currency !== 'USD' && (

β‰ˆ $ - {Number(expense.amountUsd).toLocaleString('en-US', { + {Number(expense.amountUsd).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2, })}{' '} diff --git a/src/components/interests/external-eoi-upload-dialog.tsx b/src/components/interests/external-eoi-upload-dialog.tsx index 0274afc2..ed980395 100644 --- a/src/components/interests/external-eoi-upload-dialog.tsx +++ b/src/components/interests/external-eoi-upload-dialog.tsx @@ -51,9 +51,19 @@ interface Props { onOpenChange: (next: boolean) => void; interestId: string; onSuccess?: () => void; + /** When supplied, used as the initial signatory seed (typically derived + * from the active Documenso EOI's signers). Falls through to the + * client-only seed when omitted or empty. */ + prefillSignatories?: SignatoryRow[]; } -export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSuccess }: Props) { +export function ExternalEoiUploadDialog({ + open, + onOpenChange, + interestId, + onSuccess, + prefillSignatories, +}: Props) { const qc = useQueryClient(); const [file, setFile] = useState(null); const [title, setTitle] = useState(''); @@ -88,6 +98,7 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc // explicit override takes over. const signatories: SignatoryRow[] = useMemo(() => { if (signatoriesOverride !== null) return signatoriesOverride; + if (prefillSignatories && prefillSignatories.length > 0) return prefillSignatories; if (!interestData?.data) return []; return [ { @@ -96,7 +107,7 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc role: 'client' as const, }, ]; - }, [signatoriesOverride, interestData]); + }, [signatoriesOverride, prefillSignatories, interestData]); const { data: berthsData } = useQuery<{ data: Array<{ mooringNumber: string | null }> }>({ queryKey: ['interests', interestId, 'berths'], queryFn: () => diff --git a/src/components/interests/interest-documents-tab.tsx b/src/components/interests/interest-documents-tab.tsx index d993a221..5b03ff60 100644 --- a/src/components/interests/interest-documents-tab.tsx +++ b/src/components/interests/interest-documents-tab.tsx @@ -4,9 +4,7 @@ import { useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { FileSignature } from 'lucide-react'; -import { Button } from '@/components/ui/button'; import { DocumentList } from '@/components/documents/document-list'; -import { EoiGenerateDialog } from '@/components/documents/eoi-generate-dialog'; import { FileGrid, type FileRow } from '@/components/files/file-grid'; import { FileUploadZone } from '@/components/files/file-upload-zone'; import { FilePreviewDialog } from '@/components/files/file-preview-dialog'; @@ -35,7 +33,6 @@ interface InterestData { */ export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps) { const queryClient = useQueryClient(); - const [eoiDialogOpen, setEoiDialogOpen] = useState(false); const [previewFile, setPreviewFile] = useState(null); const { confirm, dialog: confirmDialog } = useConfirmation(); @@ -111,9 +108,6 @@ export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps)

Signature documents

-

No documents yet

- Generate the EOI to send it for signing in one click. + Generate the EOI from the Overview tab to send it for signing in one click.

-
} /> @@ -202,13 +193,6 @@ export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps) )} - - !open && setPreviewFile(null)} diff --git a/src/components/interests/interest-eoi-tab.tsx b/src/components/interests/interest-eoi-tab.tsx index 754f3179..9b50fb69 100644 --- a/src/components/interests/interest-eoi-tab.tsx +++ b/src/components/interests/interest-eoi-tab.tsx @@ -126,6 +126,37 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) { const activeDoc = useMemo(() => docs.find((d) => ACTIVE_STATUSES.has(d.status)) ?? null, [docs]); const completedDocs = useMemo(() => docs.filter((d) => !ACTIVE_STATUSES.has(d.status)), [docs]); + // Pulled at the parent so we can thread the active EOI's signers into the + // ExternalEoiUploadDialog as a prefill seed. ActiveEoiCard hits the same + // query key β€” react-query dedupes the actual fetch. + const { data: activeSignersRes } = useQuery<{ data: DocumentSigner[] }>({ + queryKey: ['documents', activeDoc?.id, 'signers'], + queryFn: () => + apiFetch<{ data: DocumentSigner[] }>(`/api/v1/documents/${activeDoc!.id}/signers`), + enabled: !!activeDoc, + staleTime: 30_000, + }); + + const externalUploadPrefill = useMemo(() => { + const rows = activeSignersRes?.data ?? []; + if (rows.length === 0) return undefined; + const ROLE_MAP: Record = { + client: 'client', + developer: 'developer', + approver: 'developer', + rep: 'rep', + witness: 'witness', + cc: 'cc', + viewer: 'cc', + other: 'witness', + }; + return rows.map((s) => ({ + name: s.signerName, + email: s.signerEmail, + role: ROLE_MAP[(s.signerRole ?? '').toLowerCase()] ?? ('witness' as const), + })); + }, [activeSignersRes]); + return (
{docsLoading ? ( @@ -201,6 +232,7 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) { open={uploadSignedOpen} onOpenChange={setUploadSignedOpen} interestId={interestId} + prefillSignatories={externalUploadPrefill} /> {/* Phase 4 parity - same upload-PDF + place-fields wizard as diff --git a/src/components/interests/interest-form.tsx b/src/components/interests/interest-form.tsx index 33cdbde0..e4525346 100644 --- a/src/components/interests/interest-form.tsx +++ b/src/components/interests/interest-form.tsx @@ -47,6 +47,7 @@ import { YachtForm } from '@/components/yachts/yacht-form'; import { YachtPicker } from '@/components/yachts/yacht-picker'; import { apiFetch } from '@/lib/api/client'; import { useEntityOptions } from '@/hooks/use-entity-options'; +import { formatBerthRange } from '@/lib/templates/berth-range'; import type { z } from 'zod'; import { createInterestSchema, type CreateInterestInput } from '@/lib/validators/interests'; import { PIPELINE_STAGES, STAGE_LABELS, LEAD_CATEGORIES, SOURCES } from '@/lib/constants'; @@ -438,11 +439,24 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }: > {selectedBerthId - ? `${selectedBerth?.label ?? interest?.berthMooringNumber ?? selectedBerthId}${ - additionalBerthIds.length > 0 - ? ` + ${additionalBerthIds.length} more` - : '' - }` + ? (() => { + const primaryLabel = + selectedBerth?.label ?? + interest?.berthMooringNumber ?? + selectedBerthId; + const additionalLabels = additionalBerthIds + .map((id) => berthOptions.find((b) => b.value === id)?.label) + .filter((label): label is string => Boolean(label)); + const allLabels = [primaryLabel, ...additionalLabels]; + const range = formatBerthRange(allLabels); + // Cap at 5 segments after range collapse so "A1-A3, B5, C2, D7, E4 +N more" + // doesn't overflow the trigger. + const segments = range ? range.split(', ') : []; + if (segments.length <= 5) return range || primaryLabel; + const head = segments.slice(0, 5).join(', '); + const overflow = segments.length - 5; + return `${head} +${overflow} more`; + })() : 'Select berths…'} @@ -791,6 +805,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }: open={createYachtOpen} onOpenChange={setCreateYachtOpen} initialOwner={{ type: 'client', id: selectedClientId }} + onCreated={(y) => setValue('yachtId', y.id, { shouldDirty: true })} /> )} diff --git a/src/components/interests/interest-tabs.tsx b/src/components/interests/interest-tabs.tsx index 55c7345d..187424b0 100644 --- a/src/components/interests/interest-tabs.tsx +++ b/src/components/interests/interest-tabs.tsx @@ -3,7 +3,7 @@ import Link from 'next/link'; import { useParams } from 'next/navigation'; import { format, formatDistanceToNowStrict } from 'date-fns'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useState } from 'react'; import { Anchor, CheckCircle2, Circle, FileSignature, Send, Wallet } from 'lucide-react'; @@ -31,6 +31,7 @@ import { RemindersInline } from '@/components/reminders/reminders-inline'; import { BerthRecommenderPanel } from '@/components/interests/berth-recommender-panel'; import { LinkedBerthsList } from '@/components/interests/linked-berths-list'; import { EoiGenerateDialog } from '@/components/documents/eoi-generate-dialog'; +import { SigningProgress } from '@/components/documents/signing-progress'; // Shared parser for the interest's stringly-typed numeric columns (Drizzle // returns Postgres numeric as string). Used by both the Overview milestone @@ -627,6 +628,76 @@ function FutureMilestones({ ); } +/** + * Compact per-signer progress widget for the Overview tab's EOI milestone + * card. Mounts inside the milestone footer when the EOI is sent but not + * yet fully signed, so reps see who's signed at a glance without leaving + * Overview. Heavy lifting (resend, void, etc.) stays on the EOI tab β€” a + * "View EOI" link below the widget routes the rep there. + */ +function EoiMilestoneActiveProgress({ + interestId, + portSlug, +}: { + interestId: string; + portSlug: string; +}) { + const { data: docsRes } = useQuery<{ + data: Array<{ id: string; status: string }>; + }>({ + queryKey: ['documents', { interestId, documentType: 'eoi' }], + queryFn: () => + apiFetch<{ data: Array<{ id: string; status: string }> }>( + `/api/v1/documents?interestId=${interestId}&documentType=eoi`, + ), + staleTime: 30_000, + }); + const activeDoc = (docsRes?.data ?? []).find( + (d) => d.status === 'sent' || d.status === 'partially_signed' || d.status === 'signed', + ); + const { data: signersRes } = useQuery<{ + data: Array<{ + id: string; + signerName: string; + signerEmail: string; + signerRole: string; + signingOrder: number; + status: string; + signedAt?: string | null; + invitedAt?: string | null; + openedAt?: string | null; + lastReminderSentAt?: string | null; + signingUrl?: string | null; + }>; + }>({ + queryKey: ['documents', activeDoc?.id, 'signers'], + queryFn: () => + apiFetch<{ data: Array }>(`/api/v1/documents/${activeDoc!.id}/signers`) as never, + enabled: !!activeDoc, + staleTime: 30_000, + }); + + if (!activeDoc) return null; + const signers = signersRes?.data ?? []; + if (signers.length === 0) return null; + + return ( +
+ +
+ +
+
+ ); +} + function OverviewTab({ interestId, interest, @@ -863,6 +934,8 @@ function OverviewTab({
+ ) : eoiPhase === 'current' && interest.dateEoiSent && !interest.dateEoiSigned ? ( + ) : null, pastSummary: interest.dateEoiSigned ? ( `Signed ${formatDate(interest.dateEoiSigned)}` diff --git a/src/components/interests/linked-berths-list.tsx b/src/components/interests/linked-berths-list.tsx index 6211865f..a6ea2ea6 100644 --- a/src/components/interests/linked-berths-list.tsx +++ b/src/components/interests/linked-berths-list.tsx @@ -117,11 +117,12 @@ function formatDimensions( }; const l = toNum(length); const w = toNum(width); - const d = toNum(draft); - if (l !== null) parts.push(`${l.toFixed(1)}ft L`); - if (w !== null) parts.push(`${w.toFixed(1)}ft W`); - if (d !== null) parts.push(`${d.toFixed(1)}ft D`); - return parts.length > 0 ? parts.join(' Β· ') : null; + // Draft intentionally omitted from the inline row strip per UAT 2026-05-24 + // β€” opaque to sales reps; still visible on the berth detail page. + void toNum(draft); + if (l !== null) parts.push(`${l.toFixed(1)} ft`); + if (w !== null) parts.push(`${w.toFixed(1)} ft`); + return parts.length > 0 ? parts.join(' Γ— ') : null; } const SPECIFIC_CONSEQUENCE_ON = diff --git a/src/components/invoices/invoice-columns.tsx b/src/components/invoices/invoice-columns.tsx index e88950fd..a90b5028 100644 --- a/src/components/invoices/invoice-columns.tsx +++ b/src/components/invoices/invoice-columns.tsx @@ -78,7 +78,7 @@ export function getInvoiceColumns({ enableSorting: false, cell: ({ row }) => ( - {Number(row.original.total).toLocaleString('en-US', { + {Number(row.original.total).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2, })}{' '} diff --git a/src/components/invoices/invoice-detail.tsx b/src/components/invoices/invoice-detail.tsx index dba5d4a3..29e6ec36 100644 --- a/src/components/invoices/invoice-detail.tsx +++ b/src/components/invoices/invoice-detail.tsx @@ -230,7 +230,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {

- {Number(invoice.total).toLocaleString('en-US', { + {Number(invoice.total).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2, })}{' '} diff --git a/src/components/reports/export-dashboard-pdf-button.tsx b/src/components/reports/export-dashboard-pdf-button.tsx index cfe81a7b..91f92ea3 100644 --- a/src/components/reports/export-dashboard-pdf-button.tsx +++ b/src/components/reports/export-dashboard-pdf-button.tsx @@ -65,7 +65,7 @@ export function ExportDashboardPdfButton({ } = {}) { const { can } = usePermissions(); const [open, setOpen] = useState(false); - const [title, setTitle] = useState(`Report - ${new Date().toLocaleDateString('en-GB')}`); + const [title, setTitle] = useState(`Report - ${new Date().toLocaleDateString(undefined)}`); const [selected, setSelected] = useState( PDF_DASHBOARD_WIDGETS.map((w) => w.id), ); diff --git a/src/components/reports/export-list-pdf-button.tsx b/src/components/reports/export-list-pdf-button.tsx index 6149063c..66396fa8 100644 --- a/src/components/reports/export-list-pdf-button.tsx +++ b/src/components/reports/export-list-pdf-button.tsx @@ -52,7 +52,7 @@ export function ExportListPdfButton({ kind, buttonLabel = 'Export PDF', defaultT const [open, setOpen] = useState(false); const [title, setTitle] = useState( defaultTitle ?? - `${KIND_LABEL[kind].charAt(0).toUpperCase() + KIND_LABEL[kind].slice(1)} report - ${new Date().toLocaleDateString('en-GB')}`, + `${KIND_LABEL[kind].charAt(0).toUpperCase() + KIND_LABEL[kind].slice(1)} report - ${new Date().toLocaleDateString(undefined)}`, ); const [includeArchived, setIncludeArchived] = useState(false); const [loading, setLoading] = useState(false); diff --git a/src/components/reservations/reservation-detail.tsx b/src/components/reservations/reservation-detail.tsx index 3fdfcf77..af470e88 100644 --- a/src/components/reservations/reservation-detail.tsx +++ b/src/components/reservations/reservation-detail.tsx @@ -276,7 +276,7 @@ export function ReservationDetail({ reservationId, portSlug }: ReservationDetail diff --git a/src/components/website-analytics/pageviews-chart.tsx b/src/components/website-analytics/pageviews-chart.tsx index 4cb2f4f9..0e4b5bc1 100644 --- a/src/components/website-analytics/pageviews-chart.tsx +++ b/src/components/website-analytics/pageviews-chart.tsx @@ -124,7 +124,7 @@ function formatTooltipLabel(value: unknown): string { const datePart = value.slice(0, 10); // "YYYY-MM-DD" const d = new Date(`${datePart}T00:00:00Z`); if (isNaN(d.getTime())) return datePart; - return d.toLocaleDateString('en-US', { + return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', diff --git a/src/hooks/use-onboarding-status.ts b/src/hooks/use-onboarding-status.ts new file mode 100644 index 00000000..82d056ce --- /dev/null +++ b/src/hooks/use-onboarding-status.ts @@ -0,0 +1,44 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; + +import { apiFetch } from '@/lib/api/client'; + +export interface OnboardingStatusStep { + id: string; + href: string; + label: string; + description: string; + done: boolean; + auto: boolean; +} + +export interface OnboardingStatusPayload { + steps: OnboardingStatusStep[]; + completed: number; + total: number; + percent: number; + isComplete: boolean; + nextStep: { id: string; label: string; href: string } | null; +} + +/** + * Shared onboarding-status query. Drives the topbar banner, dashboard tile, + * and the admin checklist summary. Cached for 60s so all three surfaces + * share a single fetch on first paint. + * + * Pass `enabled=false` to skip the network call (e.g. when the current + * user isn't a super_admin and the surface won't render anyway). + */ +export function useOnboardingStatus(opts: { enabled?: boolean } = {}) { + return useQuery({ + queryKey: ['admin', 'onboarding-status'], + queryFn: () => + apiFetch<{ data: OnboardingStatusPayload }>('/api/v1/admin/onboarding/status').then( + (r) => r.data, + ), + staleTime: 60_000, + enabled: opts.enabled ?? true, + retry: false, + }); +} diff --git a/src/lib/db/migrations/0083_primary_berth_in_bundle_backfill.sql b/src/lib/db/migrations/0083_primary_berth_in_bundle_backfill.sql new file mode 100644 index 00000000..c5cb6181 --- /dev/null +++ b/src/lib/db/migrations/0083_primary_berth_in_bundle_backfill.sql @@ -0,0 +1,7 @@ +-- Backfill: ensure every primary interest_berths row carries is_in_eoi_bundle=true. +-- The service layer now enforces this invariant on insert + update, but +-- historical rows from before the guard could still violate it. +UPDATE interest_berths +SET is_in_eoi_bundle = TRUE +WHERE is_primary = TRUE + AND is_in_eoi_bundle = FALSE; diff --git a/src/lib/services/interest-berths.service.ts b/src/lib/services/interest-berths.service.ts index 69e6a9d3..54d9a6ea 100644 --- a/src/lib/services/interest-berths.service.ts +++ b/src/lib/services/interest-berths.service.ts @@ -293,12 +293,23 @@ export async function upsertInterestBerthTx( if (opts.isInEoiBundle !== undefined) setForUpdate.isInEoiBundle = opts.isInEoiBundle; // Invariant: primary berth is ALWAYS in the EOI bundle. The primary IS // the canonical "berth for this deal" - excluding it from the signed - // envelope is semantically nonsense. If the caller is setting the row - // to primary OR opting to take out of the EOI bundle, force the bundle - // flag back on whenever the row is also (about to be) primary. + // envelope is semantically nonsense. + // β€’ If the caller is setting the row to primary + opting out of bundle, + // force the bundle flag back on. + // β€’ If the existing row is already primary and the caller is toggling + // the bundle off without changing primary, also force it back on. const willBePrimary = opts.isPrimary === true; if (willBePrimary && opts.isInEoiBundle === false) { setForUpdate.isInEoiBundle = true; + } else if (opts.isInEoiBundle === false && opts.isPrimary !== false) { + const existing = await tx + .select({ isPrimary: interestBerths.isPrimary }) + .from(interestBerths) + .where(and(eq(interestBerths.interestId, interestId), eq(interestBerths.berthId, berthId))) + .limit(1); + if (existing[0]?.isPrimary) { + setForUpdate.isInEoiBundle = true; + } } if (opts.addedBy !== undefined) setForUpdate.addedBy = opts.addedBy; if (opts.notes !== undefined) setForUpdate.notes = opts.notes; diff --git a/src/lib/services/interests.service.ts b/src/lib/services/interests.service.ts index 91355ff8..b37ae5f9 100644 --- a/src/lib/services/interests.service.ts +++ b/src/lib/services/interests.service.ts @@ -10,7 +10,7 @@ import { berthReservations } from '@/lib/db/schema/reservations'; import { yachts } from '@/lib/db/schema/yachts'; import { companyMemberships } from '@/lib/db/schema/companies'; import { tags } from '@/lib/db/schema/system'; -import { userProfiles } from '@/lib/db/schema/users'; +import { userProfiles, userPortRoles, roles } from '@/lib/db/schema/users'; import { createAuditLog, type AuditMeta } from '@/lib/audit'; import { activeInterestsWhere } from '@/lib/services/active-interest'; import { getPortReminderConfig } from '@/lib/services/port-config'; @@ -755,14 +755,25 @@ export async function createInterest(portId: string, data: CreateInterestInput, if (v?.userId) { resolvedAssignedTo = v.userId; } else { - // Tier 3: auto-assign to creator unless they're a super-admin. + // Tier 3: auto-assign to creator only when their role is a working + // sales rep β€” super_admin / director / residential_partner / viewer + // intentionally skip (they create on behalf of others or shouldn't + // own interests at all). + const AUTO_ASSIGN_ROLES = new Set(['sales_agent', 'sales_manager']); const [profile] = await db .select({ isSuperAdmin: userProfiles.isSuperAdmin }) .from(userProfiles) .where(eq(userProfiles.userId, meta.userId)) .limit(1); if (profile && !profile.isSuperAdmin) { - resolvedAssignedTo = meta.userId; + const userRoles = await db + .select({ name: roles.name }) + .from(userPortRoles) + .innerJoin(roles, eq(userPortRoles.roleId, roles.id)) + .where(and(eq(userPortRoles.userId, meta.userId), eq(userPortRoles.portId, portId))); + if (userRoles.some((r) => AUTO_ASSIGN_ROLES.has(r.name))) { + resolvedAssignedTo = meta.userId; + } } } } diff --git a/src/lib/services/onboarding.service.ts b/src/lib/services/onboarding.service.ts new file mode 100644 index 00000000..07f1dbbc --- /dev/null +++ b/src/lib/services/onboarding.service.ts @@ -0,0 +1,174 @@ +/** + * Server-side onboarding status resolver. Shared by the admin checklist + * page, the topbar discoverability banner, and the dashboard onboarding + * tile so all three surfaces always agree on what's "done." + * + * Steps + auto-check rules live here (single source of truth); the UI + * surfaces consume the resolved status via the `/api/v1/admin/onboarding/status` + * endpoint. + */ + +import { and, eq, isNull, or } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { systemSettings } from '@/lib/db/schema/system'; +import { resolveForAdminAPI } from '@/lib/settings/resolver'; + +export interface OnboardingStep { + id: string; + href: string; + label: string; + description: string; + autoCheckSettingKey?: string; + autoCheckSettingKeysAll?: readonly string[]; + /** When set, the step is marked done when the named list endpoint returns + * at least one row. The endpoint URL is read by the client only; + * server-side resolver treats these as manual-only. */ + autoCheckListEndpoint?: string; +} + +export const ONBOARDING_STEPS: readonly OnboardingStep[] = [ + { + id: 'branding', + href: 'branding', + label: 'Set port name, logo, primary colour', + description: 'Branding flows into the navbar, emails, EOI PDFs, and the public auth shell.', + autoCheckSettingKey: 'branding_logo_url', + }, + { + id: 'email', + href: 'email', + label: 'Configure outgoing email', + description: + 'From-address, signature, footer, plus per-port SMTP overrides if you don’t use the global account.', + autoCheckSettingKey: 'smtp_host_override', + }, + { + id: 'documenso', + href: 'documenso', + label: 'Connect Documenso for EOIs', + description: + 'API credentials, the EOI template id, plus the developer + approver identity that signs every EOI.', + autoCheckSettingKeysAll: [ + 'documenso_api_url_override', + 'documenso_developer_email', + 'documenso_approver_email', + 'documenso_eoi_template_id', + ], + }, + { + id: 'settings', + href: 'settings', + label: 'Tune business rules + recommender weights', + description: + 'Pipeline weights, net-10 discount, berth recommender knobs (heat weights, fall-through policy).', + autoCheckSettingKey: 'heat_weight_recency', + }, + { + id: 'roles', + href: 'roles', + label: 'Create roles & assign users', + description: 'Per-port roles inherit from system roles; override permissions here.', + autoCheckListEndpoint: '/api/v1/admin/roles', + }, + { + id: 'users', + href: 'users', + label: 'Invite the rest of the team', + description: + 'Invite users, assign roles, optionally grant residential access. Track pending vs accepted.', + autoCheckListEndpoint: '/api/v1/admin/users', + }, + { + id: 'tags', + href: 'tags', + label: 'Define starter tags', + description: 'Color-coded labels used across clients, yachts, companies, and interests.', + autoCheckListEndpoint: '/api/v1/tags/options', + }, + { + id: 'storage', + href: 'storage', + label: 'Configure storage backend', + description: + 'Verify S3/filesystem and run a test connection before going live so PDFs and avatars persist correctly.', + autoCheckSettingKey: 'storage_backend', + }, + { + id: 'forms', + href: 'forms', + label: 'Wire the website intake forms', + description: + 'Inquiry forms on the marketing site dual-write into the CRM via /api/public/website-inquiries. Manually mark complete when verified.', + }, +]; + +export interface OnboardingStatus { + steps: Array; + completed: number; + total: number; + percent: number; + isComplete: boolean; + /** The next undone step the admin should tackle. Null when complete. */ + nextStep: (OnboardingStep & { done: false }) | null; +} + +/** + * Resolves onboarding status for the given port. Auto-checks read the full + * setting chain (port β†’ global β†’ env β†’ default) via `resolveSettings`; + * list-endpoint checks are treated as not-auto-resolvable server-side and + * fall back to the manual-checkbox state in `onboarding_manual_status`. + */ +export async function resolveOnboardingStatus(portId: string): Promise { + const keys = new Set(); + for (const s of ONBOARDING_STEPS) { + if (s.autoCheckSettingKey) keys.add(s.autoCheckSettingKey); + if (s.autoCheckSettingKeysAll) for (const k of s.autoCheckSettingKeysAll) keys.add(k); + } + + const resolved = + keys.size > 0 + ? await resolveForAdminAPI(Array.from(keys), portId) + : new Map(); + + // Manual-checkbox state lives in a JSON blob row outside the registry. + // Same lookup pattern as the admin page: port-scoped row first, fall + // back to a global one when the port hasn't written its own. + const manualRow = await db + .select({ value: systemSettings.value }) + .from(systemSettings) + .where( + and( + eq(systemSettings.key, 'onboarding_manual_status'), + or(eq(systemSettings.portId, portId), isNull(systemSettings.portId)), + ), + ) + .limit(1); + const manual = (manualRow[0]?.value ?? {}) as Record; + + let firstUndone: (OnboardingStep & { done: false }) | null = null; + const steps = ONBOARDING_STEPS.map((step) => { + let autoDone = false; + if (step.autoCheckSettingKey) { + autoDone = Boolean(resolved.get(step.autoCheckSettingKey)?.isSet); + } else if (step.autoCheckSettingKeysAll) { + autoDone = step.autoCheckSettingKeysAll.every((k) => Boolean(resolved.get(k)?.isSet)); + } + const manualDone = Boolean(manual[step.id]); + const done = autoDone || manualDone; + if (!done && !firstUndone) firstUndone = { ...step, done: false }; + return { ...step, done, auto: autoDone }; + }); + + const completed = steps.filter((s) => s.done).length; + const total = steps.length; + const percent = total > 0 ? Math.round((completed / total) * 100) : 0; + return { + steps, + completed, + total, + percent, + isComplete: completed === total, + nextStep: firstUndone, + }; +} diff --git a/src/lib/utils/currency.ts b/src/lib/utils/currency.ts index 23f154ff..eb22fff1 100644 --- a/src/lib/utils/currency.ts +++ b/src/lib/utils/currency.ts @@ -37,10 +37,10 @@ const SUPPORTED_SET: ReadonlySet = new Set(SUPPORTED_CURRENCIES.map((c) * null/undefined (returns the empty string for the latter so callers * can short-circuit display logic). * - * Defaults to `en-US` locale because the CRM is single-locale today; - * pass `locale` explicitly when rendering for portal users in the - * future. Unknown ISO codes fall through to Intl unchanged so the - * function never throws on legacy data. + * When `options.locale` is omitted the runtime's default locale is used + * (Intl falls back to the browser/Node default). Pass `locale` explicitly + * to force a specific rendering. Unknown ISO codes fall through to Intl + * unchanged so the function never throws on legacy data. */ export function formatCurrency( amount: number | string | null | undefined, @@ -60,7 +60,7 @@ export function formatCurrency( // `toLocaleString` throws "Computed minimumFractionDigits is larger // than maximumFractionDigits". The same defensive clamp protects // Intl.NumberFormat too. - const { locale = 'en-US', maxFractionDigits = 2 } = options; + const { locale, maxFractionDigits = 2 } = options; const minFractionDigits = options.minFractionDigits ?? Math.min(2, maxFractionDigits); try { return new Intl.NumberFormat(locale, { diff --git a/tests/integration/documents-completion-email-fanout.test.ts b/tests/integration/documents-completion-email-fanout.test.ts new file mode 100644 index 00000000..49a52e22 --- /dev/null +++ b/tests/integration/documents-completion-email-fanout.test.ts @@ -0,0 +1,129 @@ +/** + * Regression test for the audit finding "branded post-completion email + * not firing when Documenso webhook is unreachable". + * + * Both delivery paths (webhook receiver + polling fallback) call + * handleDocumentCompleted, so the branded "all signed" email fan-out + * inside that function should fire identically regardless of which path + * triggered it. This test exercises the polling path explicitly and + * asserts the email is queued. + */ +import { describe, it, expect, beforeAll, vi } from 'vitest'; +import { eq } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { documents, documentFolders, documentSigners } from '@/lib/db/schema/documents'; +import { user } from '@/lib/db/schema/users'; +import { handleDocumentCompleted } from '@/lib/services/documents.service'; +import { ensureSystemRoots } from '@/lib/services/document-folders.service'; +import { makeClient, makePort } from '../helpers/factories'; + +// Stub Documenso download so we don't hit the network. +vi.mock('@/lib/services/documenso-client', async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + downloadSignedPdf: vi.fn(async () => Buffer.from('%PDF-1.4 stub\n')), + }; +}); + +const stubPuts = new Map(); +vi.mock('@/lib/storage', async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + getStorageBackend: vi.fn(async () => ({ + put: async (path: string, data: Buffer) => { + stubPuts.set(path, data); + }, + get: async (path: string) => stubPuts.get(path) ?? Buffer.alloc(0), + head: async (path: string) => { + const buf = stubPuts.get(path); + return buf ? { sizeBytes: buf.length, contentType: 'application/pdf' } : null; + }, + delete: async (path: string) => { + stubPuts.delete(path); + }, + presignedGet: async () => 'http://stub-url', + presignedPut: async () => ({ url: 'http://stub-url', fields: {} }), + })), + }; +}); + +// Spy on the email fan-out β€” replace it with a vi.fn we can assert on. +type SendArgs = Parameters< + (typeof import('@/lib/services/document-signing-emails.service'))['sendSigningCompleted'] +>[0]; +const sendSigningCompletedSpy = vi.fn<(args: SendArgs) => Promise>(async () => {}); +vi.mock('@/lib/services/document-signing-emails.service', async (importOriginal) => { + const real = + await importOriginal(); + return { + ...real, + sendSigningCompleted: (args: SendArgs) => sendSigningCompletedSpy(args), + }; +}); + +let TEST_USER_ID = ''; + +beforeAll(async () => { + const [u] = await db.select({ id: user.id }).from(user).limit(1); + if (!u) throw new Error('No user available; run pnpm db:seed first'); + TEST_USER_ID = u.id; +}); + +describe('handleDocumentCompleted Β· email fan-out (polling-path regression)', () => { + it('queues sendSigningCompleted exactly once for the polling-driven completion', async () => { + sendSigningCompletedSpy.mockClear(); + const port = await makePort(); + const portId = port.id; + await db.delete(documentFolders).where(eq(documentFolders.portId, portId)); + await ensureSystemRoots(portId, TEST_USER_ID); + const client = await makeClient({ portId }); + + const documensoId = `docu-email-fanout-${Date.now()}`; + const [doc] = await db + .insert(documents) + .values({ + portId, + clientId: client.id, + documensoId, + documentType: 'eoi', + title: 'Email fan-out test EOI', + status: 'sent', + createdBy: TEST_USER_ID, + }) + .returning(); + + await db.insert(documentSigners).values({ + documentId: doc!.id, + signerName: 'Test Signer', + signerEmail: 'signer@example.com', + signerRole: 'client', + signingOrder: 1, + status: 'signed', + }); + + // Polling path: invoked from src/jobs/processors/documenso-poll.ts the + // exact same way the webhook receiver invokes it. If this stops calling + // the fan-out, the audit symptom returns. + await handleDocumentCompleted({ documentId: documensoId, portId }); + + expect(sendSigningCompletedSpy).toHaveBeenCalledTimes(1); + const firstCall = sendSigningCompletedSpy.mock.calls[0]; + expect(firstCall).toBeDefined(); + const args = firstCall![0]; + expect(args.recipients.some((r: { email: string }) => r.email === 'signer@example.com')).toBe( + true, + ); + expect(args.documentLabel).toBeTruthy(); + expect(args.signedPdfFileId).toBeTruthy(); + + // Idempotency: a second call (e.g. webhook arriving after the poll + // already completed) must NOT re-fire the email. The status+signedFileId + // gate in handleDocumentCompleted short-circuits before reaching the + // fan-out branch. + await handleDocumentCompleted({ documentId: documensoId, portId }); + expect(sendSigningCompletedSpy).toHaveBeenCalledTimes(1); + }); +});