feat(uat-batch-7): Wave-2 polish — Open-in-Documents, berth label, residential, NotesList parity
- 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 /<port>/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 `<NotesList entityType="yachts">` 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) <noreply@anthropic.com>
This commit is contained in:
@@ -105,7 +105,7 @@ export default function ExpensesPage() {
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Expenses"
|
||||
description="Track and manage port expenses"
|
||||
description="Track and manage business expenses"
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<PermissionGate resource="expenses" action="view">
|
||||
|
||||
16
src/app/(dashboard)/[portSlug]/residential/page.tsx
Normal file
16
src/app/(dashboard)/[portSlug]/residential/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* /<port>/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`);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
<ExternalLink className="size-3" aria-hidden />
|
||||
</Link>
|
||||
)}
|
||||
|
||||
@@ -273,17 +273,16 @@ function InterestsTab({
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{client.interests.map((i) => (
|
||||
<li key={i.id} className="flex items-center gap-3 p-3 rounded-md border bg-muted/30">
|
||||
<span className="text-xs font-medium uppercase text-muted-foreground w-32 shrink-0">
|
||||
{stageLabels[i.pipelineStage] ?? i.pipelineStage}
|
||||
</span>
|
||||
<span className="flex-1 truncate text-sm">{i.preferences || i.notes || '-'}</span>
|
||||
<li key={i.id}>
|
||||
<Link
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
href={`/${portSlug}/residential/interests/${i.id}` as any}
|
||||
className="text-xs text-primary hover:underline"
|
||||
className="flex items-center gap-3 p-3 rounded-md border bg-muted/30 hover:bg-muted/50 hover:border-primary/40 transition-colors"
|
||||
>
|
||||
View
|
||||
<span className="text-xs font-medium uppercase text-muted-foreground w-32 shrink-0">
|
||||
{stageLabels[i.pipelineStage] ?? i.pipelineStage}
|
||||
</span>
|
||||
<span className="flex-1 truncate text-sm">{i.preferences || i.notes || '-'}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<h3 className="text-sm font-medium mb-2">Notes</h3>
|
||||
<InlineEditableField
|
||||
variant="textarea"
|
||||
value={yacht.notes}
|
||||
onSave={save('notes')}
|
||||
emptyText="No notes - click to add"
|
||||
<NotesList
|
||||
entityType="yachts"
|
||||
entityId={yachtId}
|
||||
currentUserId={currentUserId}
|
||||
parentInvalidateKey={['yachts', yachtId]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -327,7 +338,7 @@ export function getYachtTabs({ yachtId, currentUserId, yacht }: YachtTabsOptions
|
||||
{
|
||||
id: 'overview',
|
||||
label: 'Overview',
|
||||
content: <OverviewTab yachtId={yachtId} yacht={yacht} />,
|
||||
content: <OverviewTab yachtId={yachtId} yacht={yacht} currentUserId={currentUserId} />,
|
||||
},
|
||||
{
|
||||
id: 'ownership-history',
|
||||
|
||||
@@ -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<Doc
|
||||
: Promise.resolve(undefined),
|
||||
]);
|
||||
|
||||
// Derive the berth label so the doc-detail Interest link carries
|
||||
// distinct information from the Client link (otherwise both render
|
||||
// the same client name). Prefer the in-EOI-bundle subset; fall back
|
||||
// to the primary; fall back to all linked berths if neither flag is
|
||||
// set anywhere.
|
||||
let interestBerthLabel: string | null = null;
|
||||
if (interestRow) {
|
||||
const berthRows = await db
|
||||
.select({
|
||||
mooringNumber: berths.mooringNumber,
|
||||
isPrimary: interestBerths.isPrimary,
|
||||
isInEoiBundle: interestBerths.isInEoiBundle,
|
||||
})
|
||||
.from(interestBerths)
|
||||
.innerJoin(berths, eq(berths.id, interestBerths.berthId))
|
||||
.where(eq(interestBerths.interestId, interestRow.id));
|
||||
if (berthRows.length > 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,
|
||||
|
||||
Reference in New Issue
Block a user