'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({ 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('available'); const [tenureDialog, setTenureDialog] = useState<{ ids: string[] } | null>(null); const [tenureChoice, setTenureChoice] = useState('permanent'); const [tagDialog, setTagDialog] = useState<{ ids: string[]; mode: 'add' | 'remove' } | null>( null, ); const [tagChoice, setTagChoice] = useState([]); const [priceSheet, setPriceSheet] = useState<{ ids: string[] } | null>(null); const bulkMutation = useMutation({ mutationFn: async (body: Record) => 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 (
{/* 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. */}
d.key !== 'search')} values={filters} onChange={setFilter} onClear={clearFilters} /> setFilter('search', e.target.value || undefined)} className="h-8 min-w-0 flex-1 sm:max-w-xs" />
{ applyView({ filters: savedFilters, sort: savedSort }); }} /> {/* Table-only controls — hidden in card mode (
{canBulkAdd && ( )}
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; }> = []; 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) => } // 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 === '') ? ( ) : ( ) } /> {/* 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} !o && setStatusDialog(null)}> Change status Set the status for {statusDialog?.ids.length ?? 0} selected berth {statusDialog?.ids.length === 1 ? '' : 's'}.
!o && setTenureDialog(null)}> Change tenure type Set the tenure for {tenureDialog?.ids.length ?? 0} selected berth {tenureDialog?.ids.length === 1 ? '' : 's'}.
!o && setPriceSheet(null)} ids={priceSheet?.ids ?? []} /> !o && setTagDialog(null)}> {tagDialog?.mode === 'add' ? 'Add tag to' : 'Remove tag from'}{' '} {tagDialog?.ids.length ?? 0} berth {tagDialog?.ids.length === 1 ? '' : 's'} ); }