feat(uat-batch-11): picker polish + BulkAddBerthsWizard currency + DocumentsHub root cleanup

- BulkAddBerthsWizard `priceCurrency` row + apply-to-all swapped from
  freetext Input to the shared CurrencySelect. Same idiom as
  berth-form + expense-form-dialog.
- /api/v1/yachts/autocomplete no longer short-circuits to `[]` when
  the search query is empty — the service returns the top 20
  most-recently-updated yachts so the picker has a useful default
  view the moment it opens. Saves the rep from a dead-end empty
  state.
- YachtPicker gains a fallback useQuery against `/api/v1/yachts/{id}`
  when the selected yacht isn't present in the current autocomplete
  window. Trigger label now shows the real name (was falling back to
  "Yacht <uuid-prefix>" when a parent pre-selected a value from a URL
  param).
- DocumentsHub: breadcrumb row only renders when a folder is
  selected. The "Home / All documents" placeholder was wasted
  vertical space above the PageHeader on the root view.

tsc clean. 1419/1419 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 18:06:41 +02:00
parent c18dbbd61b
commit 2bcf544cbc
5 changed files with 40 additions and 22 deletions

View File

@@ -6,10 +6,7 @@ import { autocomplete } from '@/lib/services/yachts.service';
export const autocompleteHandler: RouteHandler = async (req, ctx) => {
try {
const q = req.nextUrl.searchParams.get('q');
if (!q) {
return NextResponse.json({ data: [] });
}
const q = req.nextUrl.searchParams.get('q') ?? '';
const yachts = await autocomplete(ctx.portId, q);
return NextResponse.json({ data: yachts });
} catch (error) {

View File

@@ -36,6 +36,7 @@ import {
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { useVocabulary } from '@/hooks/use-vocabulary';
import { CurrencySelect } from '@/components/shared/currency-select';
const DOCK_LETTERS = ['A', 'B', 'C', 'D', 'E'] as const;
type DockLetter = (typeof DOCK_LETTERS)[number];
@@ -283,12 +284,10 @@ export function BulkAddBerthsWizard() {
/>
</td>
<td className="py-1 pr-2">
<Input
<CurrencySelect
value={undefined}
onValueChange={(v) => applyToAll('priceCurrency', v)}
className="h-7 text-xs"
onBlur={(e) => {
if (e.target.value) applyToAll('priceCurrency', e.target.value.toUpperCase());
}}
placeholder="all"
/>
</td>
<td />
@@ -351,12 +350,10 @@ export function BulkAddBerthsWizard() {
/>
</td>
<td className="py-1 pr-2">
<Input
className="h-7 w-20 text-xs"
value={row.priceCurrency}
onChange={(e) =>
setRowField(idx, 'priceCurrency', e.target.value.toUpperCase())
}
<CurrencySelect
value={row.priceCurrency || undefined}
onValueChange={(v) => setRowField(idx, 'priceCurrency', v)}
className="h-7 w-24 text-xs"
/>
</td>
<td className="py-1 pr-2">

View File

@@ -193,9 +193,9 @@ export function DocumentsHub({ portSlug }: DocumentsHubProps) {
const contentPane = (
<div className="flex-1 min-w-0 p-4 space-y-4">
<div className="flex items-center justify-between gap-3">
<FolderBreadcrumb selectedFolderId={selectedFolderId} onSelect={handleFolderSelect} />
{selectedFolderId !== undefined && (
{selectedFolderId !== undefined && (
<div className="flex items-center justify-between gap-3">
<FolderBreadcrumb selectedFolderId={selectedFolderId} onSelect={handleFolderSelect} />
<NewDocumentMenu
portSlug={portSlug}
folderId={selectedFolderId}
@@ -205,8 +205,8 @@ export function DocumentsHub({ portSlug }: DocumentsHubProps) {
entityId={isEntityFolder ? (selectedFolder!.entityId ?? undefined) : undefined}
size="sm"
/>
)}
</div>
</div>
)}
{selectedFolderId === undefined ? (
<>

View File

@@ -72,10 +72,23 @@ export function YachtPicker({
)
: rawOptions;
// When `value` is set but the selected yacht isn't in the current
// autocomplete window (e.g. parent pre-selected it from a URL param
// or the rep typed something that filtered it out), fetch its name
// by id so the trigger doesn't fall back to "Yacht <uuid-prefix>".
const fallbackQuery = useQuery<{ data: { name: string } }>({
queryKey: ['yacht-detail-label', value],
queryFn: () => apiFetch(`/api/v1/yachts/${value}`),
enabled: !!value && !rawOptions.some((o) => o.id === value),
staleTime: 60_000,
});
const selectedLabel = (() => {
if (!value) return placeholder;
const match = rawOptions.find((o) => o.id === value);
return match?.name ?? `Yacht ${value.slice(0, 8)}`;
if (match) return match.name;
if (fallbackQuery.data?.data.name) return fallbackQuery.data.data.name;
return `Yacht ${value.slice(0, 8)}`;
})();
return (

View File

@@ -1,4 +1,4 @@
import { and, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm';
import { and, desc, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { yachts, yachtOwnershipHistory, yachtTags, clients } from '@/lib/db/schema';
import type { Yacht } from '@/lib/db/schema/yachts';
@@ -393,6 +393,17 @@ export async function listOwnershipHistory(yachtId: string, portId: string) {
// ─── Autocomplete ─────────────────────────────────────────────────────────────
export async function autocomplete(portId: string, q: string) {
// Empty query returns the top 20 most-recently-updated yachts so the
// picker has something useful to show the moment it opens, instead of
// a dead-end empty state until the rep types something.
if (!q) {
return await db
.select()
.from(yachts)
.where(eq(yachts.portId, portId))
.orderBy(desc(yachts.updatedAt))
.limit(20);
}
const pattern = `%${q}%`;
return await db
.select()