Files
pn-new-crm/src/components/berths/berth-list.tsx
Matt 991e2223c7 feat(uat-batch): Group C Berth list features (3 new ships + 1 verified)
C20–C23 from the 2026-05-21 plan.

Shipped now:
  C21  Dimensions ft/m column toggle persisted to user prefs.
       `TablePreferences.dimensionUnit` ('ft' | 'm') added to the user-
       profiles JSONB. `useTablePreferences` returns `dimensionUnit` +
       `setDimensionUnit` alongside hidden/density. New
       `getBerthColumns(unit)` factory rewrites the dimensions /
       nominalBoatSize / waterDepth cells when ft is requested
       (waterDepth converts on-the-fly from the canonical meters
       column at 3.2808 ft/m). Berth-list toolbar gains a small
       ft/m toggle button next to the density toggle.
  C22  ft/m switching on Berth Requirements rows.
       `interest-tabs.tsx` Berth-requirements section now honours
       `interest.desiredLengthUnit`. Labels flip to "(m)" when set;
       value reads from `desired*M` columns; on save, both the chosen-
       unit and the canonical counterpart columns are PATCHed (3.28084
       ratio) so downstream surfaces (recommender, EOI merge fields)
       stay in lockstep. `InterestPatchField` widened with `desired*M`
       variants.
  C23  Berth list bulk-edit affordance.
       New `POST /api/v1/berths/bulk` (mirror of /interests/bulk):
       discriminated union of `change_status` / `change_tenure_type` /
       `add_tag` / `remove_tag` / `archive`, 500-id cap, per-row
       failure reporting, single `berths.edit` permission gate
       (no separate `archive` perm exists on berths today). Status
       mutations route through `updateBerthStatus` so under-offer /
       sold transitions still trigger the primary interest_berths
       auto-link + the rules-engine evaluation.
       BerthList toolbar wires `bulkActions` on the DataTable —
       Change status (Select dialog), Change tenure (permanent /
       fixed-term), Add tag, Remove tag, Archive (destructive +
       confirmation). Each dialog uses the same `bulkMutation` so
       toast + cache-invalidation behaviour is consistent across
       actions.

Already shipped (verified):
  C20  Berth list rates / pricing valid columns hidden by default —
       already in `BERTH_DEFAULT_HIDDEN`.

Verified: tsc clean, vitest 1454/1454.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:22:30 +02:00

457 lines
18 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter, useParams } from 'next/navigation';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Anchor, Archive, Plus, Rows3, Rows4, Tag as TagIcon, TagsIcon } from 'lucide-react';
import { toast } from 'sonner';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { useConfirmation } from '@/hooks/use-confirmation';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { TagPicker } from '@/components/shared/tag-picker';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
import { DataTable } from '@/components/shared/data-table';
import { FilterBar } from '@/components/shared/filter-bar';
import { PageHeader } from '@/components/shared/page-header';
import { SavedViewsDropdown } from '@/components/shared/saved-views-dropdown';
import { ColumnPicker } from '@/components/shared/column-picker';
import { ExportListPdfButton } from '@/components/reports/export-list-pdf-button';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { EmptyState } from '@/components/shared/empty-state';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { usePermissions } from '@/hooks/use-permissions';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useTablePreferences } from '@/hooks/use-table-preferences';
import { BerthCard } from './berth-card';
import {
getBerthColumns,
BERTH_COLUMN_OPTIONS,
BERTH_DEFAULT_HIDDEN,
type BerthRow,
} from './berth-columns';
import { berthFilterDefinitions } from './berth-filters';
import { mooringLetterTone } from './mooring-letter-tone';
export function BerthList() {
const router = useRouter();
const params = useParams<{ portSlug: string }>();
// M-U14: surface the page title in the mobile topbar.
const { setChrome } = useMobileChrome();
useEffect(() => {
setChrome({ title: 'Berths', showBackButton: false });
return () => setChrome({ title: null, showBackButton: false });
}, [setChrome]);
// F13: bulk-add wizard had no UI entry point. Gate the CTA on
// `berths.import` (the existing permission used for adding berths)
// so non-admins don't see a button that 403s on click.
const { can } = usePermissions();
const canBulkAdd = can('berths', 'import');
const {
data,
pagination,
isLoading,
sort,
setSort,
filters,
setFilter,
setAllFilters,
clearFilters,
setPage,
setPageSize,
} = usePaginatedQuery<BerthRow>({
queryKey: ['berths'],
endpoint: '/api/v1/berths',
filterDefinitions: berthFilterDefinitions,
});
useRealtimeInvalidation({
'berth:updated': [['berths']],
'berth:statusChanged': [['berths']],
});
// Persisted column visibility + row density + dimension unit — same
// pattern as ClientList / InterestList; density falls back to
// 'comfortable' and dimensionUnit to 'ft' for users who haven't picked.
const { hidden, setHidden, density, setDensity, dimensionUnit, setDimensionUnit } =
useTablePreferences('berths', BERTH_DEFAULT_HIDDEN);
const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false]));
const berthColumns = getBerthColumns(dimensionUnit);
// Bulk-action state — one dialog per action (status / tenure type /
// tag add+remove). Mirrors the InterestList pattern so reps already
// know the idiom from there.
const qc = useQueryClient();
const { confirm, dialog: confirmDialog } = useConfirmation();
const [statusDialog, setStatusDialog] = useState<{ ids: string[] } | null>(null);
const [statusChoice, setStatusChoice] = useState<string>('available');
const [tenureDialog, setTenureDialog] = useState<{ ids: string[] } | null>(null);
const [tenureChoice, setTenureChoice] = useState<string>('permanent');
const [tagDialog, setTagDialog] = useState<{ ids: string[]; mode: 'add' | 'remove' } | null>(
null,
);
const [tagChoice, setTagChoice] = useState<string[]>([]);
const bulkMutation = useMutation({
mutationFn: async (body: Record<string, unknown>) =>
apiFetch<{ data: { ok: number; failed: number; total: number } }>('/api/v1/berths/bulk', {
method: 'POST',
body,
}),
onSuccess: (res) => {
if (res.data.failed > 0) {
toast.warning(
`${res.data.ok} of ${res.data.total} berths updated. ${res.data.failed} failed.`,
);
} else {
toast.success(`Updated ${res.data.ok} berth${res.data.ok === 1 ? '' : 's'}`);
}
void qc.invalidateQueries({ queryKey: ['berths'] });
setStatusDialog(null);
setTenureDialog(null);
setTagDialog(null);
setTagChoice([]);
},
onError: (err) => toastError(err),
});
return (
<div className="space-y-6">
<PageHeader
title="Berths"
description="View and manage berth allocations"
variant="gradient"
// No "New" button - berths are import-only
/>
{/* Toolbar — two halves separated by `justify-between` so the
Columns + Saved-views actions stay pinned to the right edge of
the row at every width. The previous `ml-auto` trick didn't
survive flex-wrap on intermediate widths — the actions ended
up centered. */}
<div className="flex items-center gap-2 flex-wrap justify-between">
<div className="flex items-center gap-2 flex-wrap min-w-0 flex-1">
<FilterBar
// Search is hoisted out of the popover into the inline input
// below — keeps the daily "find by mooring/area" lookup one
// tap away instead of buried behind the Filters dropdown.
filters={berthFilterDefinitions.filter((d) => d.key !== 'search')}
values={filters}
onChange={setFilter}
onClear={clearFilters}
/>
<Input
type="search"
inputMode="search"
placeholder="Search mooring or area…"
aria-label="Search berths"
value={(filters.search as string | undefined) ?? ''}
onChange={(e) => setFilter('search', e.target.value || undefined)}
className="h-8 min-w-0 flex-1 sm:max-w-xs"
/>
</div>
<div className="flex items-center gap-2 ml-auto">
<SavedViewsDropdown
entityType="berths"
onApplyView={(savedFilters, _savedSort) => {
setAllFilters(savedFilters);
}}
/>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setDensity(density === 'compact' ? 'comfortable' : 'compact')}
aria-label={
density === 'compact'
? 'Switch to comfortable row spacing'
: 'Switch to compact row spacing'
}
title={density === 'compact' ? 'Comfortable rows' : 'Compact rows'}
>
{density === 'compact' ? (
<Rows3 className="h-4 w-4" aria-hidden />
) : (
<Rows4 className="h-4 w-4" aria-hidden />
)}
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setDimensionUnit(dimensionUnit === 'ft' ? 'm' : 'ft')}
aria-label={`Switch to ${dimensionUnit === 'ft' ? 'metres' : 'feet'}`}
title={`Switch to ${dimensionUnit === 'ft' ? 'metres' : 'feet'}`}
className="font-mono text-xs"
>
{dimensionUnit === 'ft' ? 'ft' : 'm'}
</Button>
<ColumnPicker columns={BERTH_COLUMN_OPTIONS} hidden={hidden} onChange={setHidden} />
<ExportListPdfButton kind="berths" />
{canBulkAdd && (
<Button asChild size="sm" variant="default">
<Link href={`/${params.portSlug}/admin/berths/bulk-add`}>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">Bulk add</span>
</Link>
</Button>
)}
</div>
</div>
<DataTable<BerthRow>
columns={berthColumns}
columnVisibility={columnVisibility}
density={density}
data={data}
isLoading={isLoading}
pagination={{
page: pagination.page,
pageSize: pagination.pageSize,
total: pagination.total,
totalPages: pagination.totalPages,
}}
onPaginationChange={(page, pageSize) => {
setPage(page);
setPageSize(pageSize);
}}
sort={sort}
onSortChange={setSort}
getRowId={(row) => row.id}
onRowClick={(row) => router.push(`/${params.portSlug}/berths/${row.id}`)}
getRowClassName={(row) => mooringLetterTone(row.mooringNumber)}
bulkActions={
can('berths', 'edit')
? [
...(can('berths', 'edit')
? [
{
label: 'Change status',
icon: Anchor,
onClick: (ids: string[]) => {
if (ids.length === 0) return;
setStatusChoice('available');
setStatusDialog({ ids });
},
},
{
label: 'Change tenure',
icon: Anchor,
onClick: (ids: string[]) => {
if (ids.length === 0) return;
setTenureChoice('permanent');
setTenureDialog({ ids });
},
},
{
label: 'Add tag',
icon: TagIcon,
onClick: (ids: string[]) => {
if (ids.length === 0) return;
setTagChoice([]);
setTagDialog({ ids, mode: 'add' });
},
},
{
label: 'Remove tag',
icon: TagsIcon,
onClick: (ids: string[]) => {
if (ids.length === 0) return;
setTagChoice([]);
setTagDialog({ ids, mode: 'remove' });
},
},
]
: []),
{
label: 'Archive',
icon: Archive,
variant: 'destructive' as const,
onClick: async (ids: string[]) => {
if (ids.length === 0) return;
const ok = await confirm({
title: `Archive ${ids.length} berth${ids.length === 1 ? '' : 's'}`,
description:
'Archived berths are hidden from option pickers. Existing interests + audit trail are preserved.',
confirmLabel: 'Archive',
});
if (!ok) return;
bulkMutation.mutate({ action: 'archive', ids });
},
},
]
: undefined
}
cardRender={(row) => <BerthCard berth={row.original} />}
// Group adjacent cards by dock letter (area) on mobile — adds a
// dim divider + uppercased label above the first card of each
// group. Data is already sorted by mooringNumber (A1, A2, …, B1,
// B2, …) so consecutive rows naturally share dock letters.
mobileGroupBy={(row) => row.area ?? 'Unassigned'}
emptyState={
// Distinguish "no data at all" (fresh port, run the importer)
// from "no rows after filtering" (adjust filters). The original
// copy implied data existed but was hidden, which misled fresh-
// port admins for whom there is literally nothing yet.
Object.values(filters).every((v) => v === undefined || v === '') ? (
<EmptyState
icon={Anchor}
title="No berths yet"
description="Berths are imported from external sources. Run the importer once the source data is ready: pnpm tsx scripts/import-berths-from-nocodb.ts --apply --port-slug <slug>."
/>
) : (
<EmptyState
icon={Anchor}
title="No berths match these filters"
description="Adjust your filters or clear them to see every berth."
/>
)
}
/>
{/* Bulk-action dialogs. Each one is mounted in the JSX tree
unconditionally; the dialog state controls open + the rendered
ids list. Sharing one bulkMutation keeps the toast + cache-
invalidation behaviour identical across actions. */}
{confirmDialog}
<Dialog open={Boolean(statusDialog)} onOpenChange={(o) => !o && setStatusDialog(null)}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Change status</DialogTitle>
<DialogDescription>
Set the status for {statusDialog?.ids.length ?? 0} selected berth
{statusDialog?.ids.length === 1 ? '' : 's'}.
</DialogDescription>
</DialogHeader>
<div className="space-y-1">
<Label>Status</Label>
<Select value={statusChoice} onValueChange={setStatusChoice}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="available">Available</SelectItem>
<SelectItem value="under_offer">Under offer</SelectItem>
<SelectItem value="sold">Sold</SelectItem>
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setStatusDialog(null)}>
Cancel
</Button>
<Button
onClick={() =>
bulkMutation.mutate({
action: 'change_status',
ids: statusDialog?.ids ?? [],
status: statusChoice,
})
}
disabled={bulkMutation.isPending}
>
Apply
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={Boolean(tenureDialog)} onOpenChange={(o) => !o && setTenureDialog(null)}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Change tenure type</DialogTitle>
<DialogDescription>
Set the tenure for {tenureDialog?.ids.length ?? 0} selected berth
{tenureDialog?.ids.length === 1 ? '' : 's'}.
</DialogDescription>
</DialogHeader>
<div className="space-y-1">
<Label>Tenure</Label>
<Select value={tenureChoice} onValueChange={setTenureChoice}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="permanent">Permanent</SelectItem>
<SelectItem value="fixed_term">Fixed-term</SelectItem>
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setTenureDialog(null)}>
Cancel
</Button>
<Button
onClick={() =>
bulkMutation.mutate({
action: 'change_tenure_type',
ids: tenureDialog?.ids ?? [],
tenureType: tenureChoice,
})
}
disabled={bulkMutation.isPending}
>
Apply
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={Boolean(tagDialog)} onOpenChange={(o) => !o && setTagDialog(null)}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{tagDialog?.mode === 'add' ? 'Add tag to' : 'Remove tag from'}{' '}
{tagDialog?.ids.length ?? 0} berth
{tagDialog?.ids.length === 1 ? '' : 's'}
</DialogTitle>
</DialogHeader>
<TagPicker selectedIds={tagChoice} onChange={setTagChoice} />
<DialogFooter>
<Button variant="outline" onClick={() => setTagDialog(null)}>
Cancel
</Button>
<Button
onClick={() => {
if (tagChoice.length === 0) {
toast.error('Pick at least one tag.');
return;
}
// Per-tag bulk call — the endpoint takes one tagId at a
// time. For the typical 1-2 tag case the round-trips are
// cheap; multi-tag UX can come later.
const action = tagDialog?.mode === 'add' ? 'add_tag' : 'remove_tag';
for (const tagId of tagChoice) {
bulkMutation.mutate({ action, ids: tagDialog?.ids ?? [], tagId });
}
}}
disabled={bulkMutation.isPending || tagChoice.length === 0}
>
Apply
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}