feat(ui): yacht list page with columns and filters

This commit is contained in:
Matt Ciaccio
2026-04-24 13:44:15 +02:00
parent 76d2348873
commit f64a52b995
4 changed files with 385 additions and 0 deletions

View File

@@ -0,0 +1,176 @@
'use client';
import Link from 'next/link';
import { MoreHorizontal, Pencil, Archive, Eye } from 'lucide-react';
import type { ColumnDef } from '@tanstack/react-table';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { OwnerLink } from '@/components/yachts/yacht-detail-header';
export interface YachtRow {
id: string;
name: string;
hullNumber: string | null;
registration: string | null;
currentOwnerType: 'client' | 'company';
currentOwnerId: string;
lengthFt: string | null;
widthFt: string | null;
draftFt: string | null;
lengthM: string | null;
widthM: string | null;
status: string;
archivedAt: string | null;
updatedAt: string;
}
const STATUS_COLORS: Record<string, string> = {
active: 'bg-green-100 text-green-800 border-green-300',
retired: 'bg-gray-100 text-gray-800 border-gray-300',
sold_away: 'bg-amber-100 text-amber-800 border-amber-300',
};
const STATUS_LABELS: Record<string, string> = {
active: 'Active',
retired: 'Retired',
sold_away: 'Sold Away',
};
function formatDimensions(yacht: YachtRow): string | null {
if (yacht.lengthFt || yacht.widthFt) {
const length = yacht.lengthFt ?? '—';
const width = yacht.widthFt ?? '—';
return `${length} × ${width} ft`;
}
if (yacht.lengthM || yacht.widthM) {
const length = yacht.lengthM ?? '—';
const width = yacht.widthM ?? '—';
return `${length} × ${width} m`;
}
return null;
}
interface GetYachtColumnsOptions {
portSlug: string;
onEdit: (yacht: YachtRow) => void;
onArchive: (yacht: YachtRow) => void;
}
export function getYachtColumns({
portSlug,
onEdit,
onArchive,
}: GetYachtColumnsOptions): ColumnDef<YachtRow, unknown>[] {
return [
{
id: 'name',
accessorKey: 'name',
header: 'Name',
cell: ({ row }) => (
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/yachts/${row.original.id}` as any}
className="font-medium text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{row.original.name}
</Link>
),
},
{
id: 'currentOwner',
header: 'Current Owner',
enableSorting: false,
cell: ({ row }) => (
<OwnerLink
portSlug={portSlug}
type={row.original.currentOwnerType}
id={row.original.currentOwnerId}
/>
),
},
{
id: 'dimensions',
header: 'Dimensions',
enableSorting: false,
cell: ({ row }) => {
const dims = formatDimensions(row.original);
if (!dims) return <span className="text-muted-foreground"></span>;
return <span className="text-sm">{dims}</span>;
},
},
{
id: 'hullNumber',
accessorKey: 'hullNumber',
header: 'Hull Number',
enableSorting: false,
cell: ({ getValue }) => {
const value = getValue() as string | null;
if (!value) return <span className="text-muted-foreground"></span>;
return <span className="text-sm">{value}</span>;
},
},
{
id: 'status',
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const status = row.original.status;
const label = STATUS_LABELS[status] ?? status;
const color = STATUS_COLORS[status] ?? 'bg-muted text-muted-foreground border-muted';
return (
<span
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${color}`}
>
{label}
</span>
);
},
},
{
id: 'actions',
header: '',
enableSorting: false,
size: 48,
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/yachts/${row.original.id}` as any}
>
<Eye className="mr-2 h-3.5 w-3.5" />
View
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEdit(row.original)}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive" onClick={() => onArchive(row.original)}>
<Archive className="mr-2 h-3.5 w-3.5" />
Archive
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
];
}

View File

@@ -0,0 +1,34 @@
import type { FilterDefinition } from '@/components/shared/filter-bar';
export const yachtFilterDefinitions: FilterDefinition[] = [
{
key: 'search',
label: 'Search',
type: 'text',
placeholder: 'Search by name, hull, registration...',
},
{
key: 'status',
label: 'Status',
type: 'select',
options: [
{ label: 'Active', value: 'active' },
{ label: 'Retired', value: 'retired' },
{ label: 'Sold Away', value: 'sold_away' },
],
},
{
key: 'ownerType',
label: 'Owner Type',
type: 'select',
options: [
{ label: 'Client', value: 'client' },
{ label: 'Company', value: 'company' },
],
},
{
key: 'includeArchived',
label: 'Include Archived',
type: 'boolean',
},
];

View File

@@ -0,0 +1,170 @@
'use client';
import { useState } from 'react';
import { useParams } from 'next/navigation';
import { Plus } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { DataTable } from '@/components/shared/data-table';
import { FilterBar } from '@/components/shared/filter-bar';
import { SavedViewsDropdown } from '@/components/shared/saved-views-dropdown';
import { PageHeader } from '@/components/shared/page-header';
import { EmptyState } from '@/components/shared/empty-state';
import { TableSkeleton } from '@/components/shared/loading-skeleton';
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
import { PermissionGate } from '@/components/shared/permission-gate';
import { YachtForm } from '@/components/yachts/yacht-form';
import { yachtFilterDefinitions } from '@/components/yachts/yacht-filters';
import { getYachtColumns, type YachtRow } from '@/components/yachts/yacht-columns';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client';
export function YachtList() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [editYacht, setEditYacht] = useState<YachtRow | null>(null);
const [archiveYacht, setArchiveYacht] = useState<YachtRow | null>(null);
const {
data,
pagination,
isLoading,
isFetching,
sort,
setSort,
setPage,
setPageSize,
filters,
setFilter,
clearFilters,
} = usePaginatedQuery<YachtRow>({
queryKey: ['yachts'],
endpoint: '/api/v1/yachts',
filterDefinitions: yachtFilterDefinitions,
});
useRealtimeInvalidation({
'yacht:created': [['yachts']],
'yacht:updated': [['yachts']],
'yacht:archived': [['yachts']],
'yacht:ownership_transferred': [['yachts']],
});
const archiveMutation = useMutation({
mutationFn: (id: string) => apiFetch(`/api/v1/yachts/${id}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['yachts'] });
setArchiveYacht(null);
},
});
const columns = getYachtColumns({
portSlug,
onEdit: (yacht) => setEditYacht(yacht),
onArchive: (yacht) => setArchiveYacht(yacht),
});
return (
<div className="space-y-4">
<PageHeader
title="Yachts"
description="Manage yacht records"
actions={
<PermissionGate resource="yachts" action="create">
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="mr-1.5 h-4 w-4" />
New Yacht
</Button>
</PermissionGate>
}
/>
<div className="flex items-center gap-2">
<FilterBar
filters={yachtFilterDefinitions}
values={filters}
onChange={setFilter}
onClear={clearFilters}
/>
<SavedViewsDropdown
entityType="yachts"
currentFilters={filters}
currentSort={sort}
onApplyView={(savedFilters, _savedSort) => {
clearFilters();
Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val));
}}
/>
</div>
{isLoading ? (
<TableSkeleton />
) : !data.length ? (
<EmptyState
title="No yachts found"
description="Get started by adding your first yacht."
action={{ label: 'New Yacht', onClick: () => setCreateOpen(true) }}
/>
) : (
<DataTable
columns={columns}
data={data}
pagination={pagination}
onPaginationChange={(p, ps) => {
setPage(p);
setPageSize(ps);
}}
sort={sort}
onSortChange={setSort}
isLoading={isFetching && !isLoading}
getRowId={(row) => row.id}
emptyState={
<EmptyState
title="No yachts found"
description="Get started by adding your first yacht."
action={{ label: 'New Yacht', onClick: () => setCreateOpen(true) }}
/>
}
/>
)}
<YachtForm open={createOpen} onOpenChange={setCreateOpen} />
{editYacht && (
<YachtForm
open={!!editYacht}
onOpenChange={(open) => !open && setEditYacht(null)}
yacht={{
id: editYacht.id,
name: editYacht.name,
hullNumber: editYacht.hullNumber,
registration: editYacht.registration,
lengthFt: editYacht.lengthFt,
widthFt: editYacht.widthFt,
draftFt: editYacht.draftFt,
lengthM: editYacht.lengthM,
widthM: editYacht.widthM,
currentOwnerType: editYacht.currentOwnerType,
currentOwnerId: editYacht.currentOwnerId,
status: editYacht.status,
}}
/>
)}
<ArchiveConfirmDialog
open={!!archiveYacht}
onOpenChange={(open) => !open && setArchiveYacht(null)}
entityName={archiveYacht?.name ?? ''}
entityType="Yacht"
isArchived={false}
onConfirm={() => archiveYacht && archiveMutation.mutate(archiveYacht.id)}
isLoading={archiveMutation.isPending}
/>
</div>
);
}