Files
pn-new-crm/src/components/berths/berth-list.tsx
Matt 95724c8e3a fix(uat): prod UAT batch — reports, sidebar, search, berths, breakpoint
- financial report: drop Expenses KPI, Net Contribution, cash-flow chart,
  expense donut + ledger (expenses are business-trip costs, not net contribution)
- dashboard report PDF: pagination-safe tables (TableSection + per-row wrap)
  so long doc lists no longer overlap/crush
- clients PDF report: rename "Nationality" -> "Country"
- sidebar: hide a section header when all its items gate off (FINANCIAL orphan)
- topbar: move global search into the 1fr grid track so it can't overlap "New"
- clients card: show all linked berths (not just latest interest's primary)
- berths list: hide table-only toggles (ft/m, density, columns) in card mode
- lists: lower table/card breakpoint lg -> md so narrow desktops get tables
- alert-rules: stale floor created_at -> updated_at (survives created_at backfill)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:41:31 +02:00

495 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,
CircleDollarSign,
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 { BulkPriceEditSheet } from './bulk-price-edit-sheet';
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,
applyView,
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 [priceSheet, setPriceSheet] = useState<{ ids: string[] } | null>(null);
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) => {
applyView({ filters: savedFilters, sort: savedSort });
}}
/>
{/* Table-only controls — hidden in card mode (<lg, matching
DataTable's table/card switch). The BerthCard ignores row
density + dimension unit and renders no column set, so these
toggles have no visible effect there and read as broken. */}
<div className="hidden items-center gap-2 md:flex">
<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} />
</div>
<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={(() => {
const actions: Array<{
label: string;
icon: typeof Anchor;
variant?: 'destructive';
onClick: (ids: string[]) => void | Promise<void>;
}> = [];
if (can('berths', 'edit')) {
actions.push(
{
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' });
},
},
);
}
if (can('berths', 'update_prices')) {
actions.push({
label: 'Update prices',
icon: CircleDollarSign,
onClick: (ids: string[]) => {
if (ids.length === 0) return;
setPriceSheet({ ids });
},
});
}
if (can('berths', 'edit')) {
actions.push({
label: 'Archive',
icon: Archive,
variant: 'destructive',
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 });
},
});
}
return actions.length > 0 ? actions : 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>
<BulkPriceEditSheet
open={Boolean(priceSheet)}
onOpenChange={(o) => !o && setPriceSheet(null)}
ids={priceSheet?.ids ?? []}
/>
<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>
);
}