From c6dcf49e18d5a86f82b42b4c5372a3f93f2e3671 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 21 May 2026 17:41:02 +0200 Subject: [PATCH] =?UTF-8?q?feat(uat-batch-7):=20Wave-2=20polish=20?= =?UTF-8?q?=E2=80=94=20Open-in-Documents,=20berth=20label,=20residential,?= =?UTF-8?q?=20NotesList=20parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - InterestEoiTab history link renamed "Open" → "Open in Documents" so the cross-section nav target is unambiguous. - DocumentDetail Interest link sub-text now shows the derived `berthLabel` (formatBerthRange of the in-EOI-bundle subset, falling back to primary, then all linked berths). The link no longer duplicates the Client name; falls back to clientName or "No berths linked" when no berths exist. - New //residential/page.tsx redirects to /residential/clients so the breadcrumb's Residential link works. - Residential interests list — whole row is now a Link target (was hidden behind a trailing "View" link); hover + border accent on the full row. - Expenses PageHeader description "Track and manage port expenses" → "Track and manage business expenses" (drop the redundant "port", same audit pattern flagged in the queue). - DropdownMenu base content capped at `max-h-96` (was the Radix available-height variable, which stretched menus edge-to-edge); the existing internal scroll handles overflow. - Yacht Overview Notes block: replaced the legacy single-field textarea with the threaded `` for parity with clients/interests/companies. Legacy `yacht.notes` column stays in schema for EOI/contract merge-field path. tsc clean. 1419/1419 vitest pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../(dashboard)/[portSlug]/expenses/page.tsx | 2 +- .../[portSlug]/residential/page.tsx | 16 ++++++++ src/components/documents/document-detail.tsx | 8 +++- src/components/interests/interest-eoi-tab.tsx | 2 +- .../residential/residential-client-tabs.tsx | 15 ++++---- src/components/ui/dropdown-menu.tsx | 5 ++- src/components/yachts/yacht-tabs.tsx | 27 ++++++++++---- src/lib/services/documents.service.ts | 37 ++++++++++++++++++- 8 files changed, 89 insertions(+), 23 deletions(-) create mode 100644 src/app/(dashboard)/[portSlug]/residential/page.tsx diff --git a/src/app/(dashboard)/[portSlug]/expenses/page.tsx b/src/app/(dashboard)/[portSlug]/expenses/page.tsx index 40088159..7c5c754d 100644 --- a/src/app/(dashboard)/[portSlug]/expenses/page.tsx +++ b/src/app/(dashboard)/[portSlug]/expenses/page.tsx @@ -105,7 +105,7 @@ export default function ExpensesPage() {
diff --git a/src/app/(dashboard)/[portSlug]/residential/page.tsx b/src/app/(dashboard)/[portSlug]/residential/page.tsx new file mode 100644 index 00000000..e3ea7480 --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/residential/page.tsx @@ -0,0 +1,16 @@ +import { redirect } from 'next/navigation'; + +/** + * //residential is a namespace segment — the actual landing is + * /residential/clients. Without a page.tsx here, the breadcrumb's + * "Residential" link 404s. Server-redirect to the Clients sub-page so + * the link works as a useful shortcut. + */ +export default async function ResidentialIndexPage({ + params, +}: { + params: Promise<{ portSlug: string }>; +}) { + const { portSlug } = await params; + redirect(`/${portSlug}/residential/clients`); +} diff --git a/src/components/documents/document-detail.tsx b/src/components/documents/document-detail.tsx index de7440b0..265a272b 100644 --- a/src/components/documents/document-detail.tsx +++ b/src/components/documents/document-detail.tsx @@ -93,7 +93,7 @@ interface DetailWatcher { } interface DetailLinked { - interest: { id: string; clientName: string | null } | null; + interest: { id: string; clientName: string | null; berthLabel: string | null } | null; client: { id: string; fullName: string } | null; yacht: { id: string; name: string } | null; company: { id: string; name: string } | null; @@ -238,7 +238,11 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) { linkedRows.push({ href: `/${portSlug}/interests/${linked.interest.id}`, label: 'Interest', - sub: linked.interest.clientName, + // Show the berth label (e.g. "A1-A3, B5-B7" or "A12") so the + // Interest link carries distinct information from the Client + // link rendered just below — otherwise both rows show the same + // client name and the Interest row reads as duplicate. + sub: linked.interest.berthLabel ?? linked.interest.clientName ?? 'No berths linked', }); } if (linked.client) { diff --git a/src/components/interests/interest-eoi-tab.tsx b/src/components/interests/interest-eoi-tab.tsx index a7c63a35..690256bc 100644 --- a/src/components/interests/interest-eoi-tab.tsx +++ b/src/components/interests/interest-eoi-tab.tsx @@ -177,7 +177,7 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) { href={`/${portSlug}/documents/${d.id}` as any} className="text-xs text-primary hover:underline inline-flex items-center gap-1" > - Open + Open in Documents )} diff --git a/src/components/residential/residential-client-tabs.tsx b/src/components/residential/residential-client-tabs.tsx index a5f271ca..930a5c3e 100644 --- a/src/components/residential/residential-client-tabs.tsx +++ b/src/components/residential/residential-client-tabs.tsx @@ -273,17 +273,16 @@ function InterestsTab({ ) : (
    {client.interests.map((i) => ( -
  • - - {stageLabels[i.pipelineStage] ?? i.pipelineStage} - - {i.preferences || i.notes || '-'} +
  • - View + + {stageLabels[i.pipelineStage] ?? i.pipelineStage} + + {i.preferences || i.notes || '-'}
  • ))} diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index 96b48cf9..469a16cf 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -63,7 +63,10 @@ const DropdownMenuContent = React.forwardRef< ref={ref} sideOffset={sideOffset} className={cn( - 'z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-32 overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md', + // Cap at 24rem (384px) so long menus don't visually stretch + // edge-to-edge of the viewport — internal scroll handles + // overflow. Consumers can override via the `className` prop. + 'z-50 max-h-96 min-w-32 overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md', 'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-dropdown-menu-content-transform-origin)', className, )} diff --git a/src/components/yachts/yacht-tabs.tsx b/src/components/yachts/yacht-tabs.tsx index b617856a..3f79e209 100644 --- a/src/components/yachts/yacht-tabs.tsx +++ b/src/components/yachts/yacht-tabs.tsx @@ -88,7 +88,15 @@ function EditableRow({ label, children }: { label: string; children: React.React ); } -function OverviewTab({ yachtId, yacht }: { yachtId: string; yacht: YachtTabsYacht }) { +function OverviewTab({ + yachtId, + yacht, + currentUserId, +}: { + yachtId: string; + yacht: YachtTabsYacht; + currentUserId?: string; +}) { const mutation = useYachtPatch(yachtId); const save = (field: YachtPatchField, transform?: (v: string | null) => string | number | null) => @@ -224,14 +232,17 @@ function OverviewTab({ yachtId, yacht }: { yachtId: string; yacht: YachtTabsYach
- {/* Notes */} + {/* Notes — threaded list (parity with clients/interests/companies). + The legacy single-field `yacht.notes` column stays in schema + for the EOI/contract merge-field path; OverviewTab no longer + exposes it for editing here. */}

Notes

-
@@ -327,7 +338,7 @@ export function getYachtTabs({ yachtId, currentUserId, yacht }: YachtTabsOptions { id: 'overview', label: 'Overview', - content: , + content: , }, { id: 'ownership-history', diff --git a/src/lib/services/documents.service.ts b/src/lib/services/documents.service.ts index ae27a524..803bcb87 100644 --- a/src/lib/services/documents.service.ts +++ b/src/lib/services/documents.service.ts @@ -12,6 +12,8 @@ import { interests, interestBerths } from '@/lib/db/schema/interests'; import { clients } from '@/lib/db/schema/clients'; import { companies } from '@/lib/db/schema/companies'; import { yachts } from '@/lib/db/schema/yachts'; +import { berths } from '@/lib/db/schema/berths'; +import { formatBerthRange } from '@/lib/templates/berth-range'; import { berthReservations } from '@/lib/db/schema/reservations'; import { ports } from '@/lib/db/schema/ports'; import { userProfiles, userPortRoles } from '@/lib/db/schema/users'; @@ -2019,7 +2021,7 @@ export interface DocumentDetailWatcher { * Each side is null when the FK is null or the row was deleted. */ export interface DocumentDetailLinkedEntities { - interest: { id: string; clientName: string | null } | null; + interest: { id: string; clientName: string | null; berthLabel: string | null } | null; client: { id: string; fullName: string } | null; yacht: { id: string; name: string } | null; company: { id: string; name: string } | null; @@ -2097,9 +2099,40 @@ export async function getDocumentDetail(id: string, portId: string): Promise 0) { + const bundled = berthRows.filter((r) => r.isInEoiBundle); + const primary = berthRows.filter((r) => r.isPrimary); + const subset = bundled.length > 0 ? bundled : primary.length > 0 ? primary : berthRows; + const moorings = subset.map((r) => r.mooringNumber).filter((m): m is string => !!m); + if (moorings.length > 0) { + interestBerthLabel = formatBerthRange(moorings); + } + } + } + const linked: DocumentDetailLinkedEntities = { interest: interestRow - ? { id: interestRow.id, clientName: interestRow.clientName ?? null } + ? { + id: interestRow.id, + clientName: interestRow.clientName ?? null, + berthLabel: interestBerthLabel, + } : null, client: clientRow ? { id: clientRow.id, fullName: clientRow.fullName } : null, yacht: yachtRow ? { id: yachtRow.id, name: yachtRow.name } : null,