fix(ui): resolve yacht owner names server-side, real user in topbar
Yachts list page rendered each row's Current Owner via OwnerLink, which
fired its own /api/v1/clients/{id} or /companies/{id} fetch — N+1 round-
trips per page load (12+ for the harbor-royale fixture). Worse, until
those fetches resolved each cell showed "Client c68da7..." style raw IDs.
Fix: listYachts now resolves the polymorphic currentOwnerName in two
batched in-array queries after the page query (mirrors the listClients
yachtCount/companyCount pattern), and OwnerLink accepts an optional
preloadedName prop that suppresses the per-row fetch when supplied.
Topbar: show real user name + avatar initial from session/profile, and
expand the My-Account dropdown header to include the user's email.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -47,7 +47,13 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
||||||
<Topbar ports={ports} />
|
<Topbar
|
||||||
|
ports={ports}
|
||||||
|
user={{
|
||||||
|
name: profile?.displayName ?? session.user.name ?? session.user.email,
|
||||||
|
email: session.user.email,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<main className="flex-1 overflow-y-auto bg-background p-6">{children}</main>
|
<main className="flex-1 overflow-y-auto bg-background p-6">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,9 +23,10 @@ import type { Port } from '@/lib/db/schema/ports';
|
|||||||
|
|
||||||
interface TopbarProps {
|
interface TopbarProps {
|
||||||
ports: Port[];
|
ports: Port[];
|
||||||
|
user?: { name: string; email: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Topbar({ ports }: TopbarProps) {
|
export function Topbar({ ports, user }: TopbarProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
|
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
|
||||||
const darkMode = useUIStore((s) => s.darkMode);
|
const darkMode = useUIStore((s) => s.darkMode);
|
||||||
@@ -95,13 +96,18 @@ export function Topbar({ ports }: TopbarProps) {
|
|||||||
<Avatar className="w-7 h-7">
|
<Avatar className="w-7 h-7">
|
||||||
<AvatarImage src={undefined} />
|
<AvatarImage src={undefined} />
|
||||||
<AvatarFallback className="bg-brand text-white text-xs font-semibold">
|
<AvatarFallback className="bg-brand text-white text-xs font-semibold">
|
||||||
U
|
{(user?.name ?? 'U').slice(0, 1).toUpperCase()}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-52">
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
<DropdownMenuLabel>
|
||||||
|
<div className="font-medium">{user?.name ?? 'My Account'}</div>
|
||||||
|
{user?.email && (
|
||||||
|
<div className="text-xs text-muted-foreground font-normal">{user.email}</div>
|
||||||
|
)}
|
||||||
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
<DropdownMenuItem onClick={() => router.push(`${base}/settings/profile` as any)}>
|
<DropdownMenuItem onClick={() => router.push(`${base}/settings/profile` as any)}>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export interface YachtRow {
|
|||||||
registration: string | null;
|
registration: string | null;
|
||||||
currentOwnerType: 'client' | 'company';
|
currentOwnerType: 'client' | 'company';
|
||||||
currentOwnerId: string;
|
currentOwnerId: string;
|
||||||
|
currentOwnerName?: string | null;
|
||||||
lengthFt: string | null;
|
lengthFt: string | null;
|
||||||
widthFt: string | null;
|
widthFt: string | null;
|
||||||
draftFt: string | null;
|
draftFt: string | null;
|
||||||
@@ -92,6 +93,7 @@ export function getYachtColumns({
|
|||||||
portSlug={portSlug}
|
portSlug={portSlug}
|
||||||
type={row.original.currentOwnerType}
|
type={row.original.currentOwnerType}
|
||||||
id={row.original.currentOwnerId}
|
id={row.original.currentOwnerId}
|
||||||
|
preloadedName={row.original.currentOwnerName ?? null}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -58,20 +58,27 @@ export function OwnerLink({
|
|||||||
portSlug,
|
portSlug,
|
||||||
type,
|
type,
|
||||||
id,
|
id,
|
||||||
|
preloadedName,
|
||||||
}: {
|
}: {
|
||||||
portSlug: string;
|
portSlug: string;
|
||||||
type: 'client' | 'company';
|
type: 'client' | 'company';
|
||||||
id: string;
|
id: string;
|
||||||
|
/** Optional name supplied by the parent list/detail endpoint to skip the
|
||||||
|
* per-row fetch (avoids an N+1 round-trip on lists). */
|
||||||
|
preloadedName?: string | null;
|
||||||
}) {
|
}) {
|
||||||
|
// Only fetch when the parent didn't already supply a name — list endpoints
|
||||||
|
// batch-resolve owners server-side via a join.
|
||||||
const { data } = useQuery<{ fullName?: string; name?: string }>({
|
const { data } = useQuery<{ fullName?: string; name?: string }>({
|
||||||
queryKey: [type === 'client' ? 'clients' : 'companies', id, 'name-only'],
|
queryKey: [type === 'client' ? 'clients' : 'companies', id, 'name-only'],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
apiFetch<{ data: { fullName?: string; name?: string } }>(
|
apiFetch<{ data: { fullName?: string; name?: string } }>(
|
||||||
type === 'client' ? `/api/v1/clients/${id}` : `/api/v1/companies/${id}`,
|
type === 'client' ? `/api/v1/clients/${id}` : `/api/v1/companies/${id}`,
|
||||||
).then((r) => r.data),
|
).then((r) => r.data),
|
||||||
|
enabled: preloadedName === undefined || preloadedName === null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const label = type === 'client' ? data?.fullName : data?.name;
|
const label = preloadedName ?? (type === 'client' ? data?.fullName : data?.name);
|
||||||
const href = type === 'client' ? `/${portSlug}/clients/${id}` : `/${portSlug}/companies/${id}`;
|
const href = type === 'client' ? `/${portSlug}/clients/${id}` : `/${portSlug}/companies/${id}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { and, eq, ilike, or, sql } from 'drizzle-orm';
|
import { and, eq, ilike, inArray, or, sql } from 'drizzle-orm';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { yachts, yachtOwnershipHistory, yachtTags, clients } from '@/lib/db/schema';
|
import { yachts, yachtOwnershipHistory, yachtTags, clients } from '@/lib/db/schema';
|
||||||
import type { Yacht } from '@/lib/db/schema/yachts';
|
import type { Yacht } from '@/lib/db/schema/yachts';
|
||||||
@@ -307,7 +307,45 @@ export async function listYachts(portId: string, query: ListYachtsInput) {
|
|||||||
archivedAtColumn: yachts.archivedAt,
|
archivedAtColumn: yachts.archivedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
if (result.data.length === 0) return result;
|
||||||
|
|
||||||
|
// Resolve current owner names in two parallel batched queries instead of
|
||||||
|
// an N+1 fetch from the client (was 1 round-trip per row from yacht-columns).
|
||||||
|
const clientIds = result.data
|
||||||
|
.filter((y) => y.currentOwnerType === 'client')
|
||||||
|
.map((y) => y.currentOwnerId);
|
||||||
|
const companyIds = result.data
|
||||||
|
.filter((y) => y.currentOwnerType === 'company')
|
||||||
|
.map((y) => y.currentOwnerId);
|
||||||
|
|
||||||
|
const [clientRows, companyRows] = await Promise.all([
|
||||||
|
clientIds.length > 0
|
||||||
|
? db
|
||||||
|
.select({ id: clients.id, fullName: clients.fullName })
|
||||||
|
.from(clients)
|
||||||
|
.where(inArray(clients.id, clientIds))
|
||||||
|
: Promise.resolve([] as { id: string; fullName: string }[]),
|
||||||
|
companyIds.length > 0
|
||||||
|
? db
|
||||||
|
.select({ id: companies.id, name: companies.name })
|
||||||
|
.from(companies)
|
||||||
|
.where(inArray(companies.id, companyIds))
|
||||||
|
: Promise.resolve([] as { id: string; name: string }[]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const clientNames = new Map(clientRows.map((r) => [r.id, r.fullName]));
|
||||||
|
const companyNames = new Map(companyRows.map((r) => [r.id, r.name]));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
data: result.data.map((y) => ({
|
||||||
|
...y,
|
||||||
|
currentOwnerName:
|
||||||
|
y.currentOwnerType === 'client'
|
||||||
|
? (clientNames.get(y.currentOwnerId) ?? null)
|
||||||
|
: (companyNames.get(y.currentOwnerId) ?? null),
|
||||||
|
})),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── List for owner ───────────────────────────────────────────────────────────
|
// ─── List for owner ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user