feat(vessel-registration): add vessel registration page with incident reporting and certificate download functionality

feat(waiver): implement waiver page with application tracking and letter download feature

feat(ui): introduce AdvancedTable component for enhanced table functionality across the application

chore: update package-lock.json to remove unnecessary dependencies
This commit is contained in:
Nati
2026-08-13 09:11:50 +00:00
parent a7e74cb7bd
commit 2af9e60800
67 changed files with 4209 additions and 3446 deletions

View File

@@ -13,3 +13,4 @@ export * from './lib/layout/BrandAvatar';
export * from './lib/layout/ColorSchemeToggle';
export * from './lib/layout/LanguageSwitcher';
export * from './lib/layout/PageHeader';
export * from './lib/table/AdvancedTable';

View File

@@ -0,0 +1,261 @@
import type { CSSProperties, ReactNode } from 'react';
import {
ActionIcon,
Box,
Checkbox,
Group,
LoadingOverlay,
Pagination,
Table,
Tooltip,
UnstyledButton,
type MantineSpacing,
} from '@mantine/core';
import {
IconChevronDown,
IconChevronUp,
IconRefresh,
IconSelector,
} from '@tabler/icons-react';
import { EmptyState } from '../feedback/EmptyState';
export interface AdvancedTableColumn<T> {
/** Unique column id; doubles as the sort field sent to `sort.onSort`. */
key: string;
header: ReactNode;
/** Cell renderer. Defaults to reading `row[key]`. */
render?: (row: T) => ReactNode;
sortable?: boolean;
width?: number | string;
align?: 'left' | 'center' | 'right';
}
export interface AdvancedTableAction<T> {
key: string;
/** Shown as tooltip and aria-label. */
label: string;
icon: ReactNode;
color?: string;
hidden?: (row: T) => boolean;
disabled?: (row: T) => boolean;
onClick: (row: T) => void;
}
export interface AdvancedTableSort {
sortBy?: string;
sortDir?: 'asc' | 'desc';
onSort: (field: string) => void;
}
export interface AdvancedTablePagination {
page: number;
totalPages: number;
onPageChange: (page: number) => void;
}
export interface AdvancedTableSelection {
selected: string[];
onChange: (ids: string[]) => void;
}
export interface AdvancedTableProps<T> {
columns: AdvancedTableColumn<T>[];
data: T[];
rowKey: (row: T) => string;
actions?: AdvancedTableAction<T>[];
sort?: AdvancedTableSort;
pagination?: AdvancedTablePagination;
/** Controlled row selection (checkbox column); ids come from `rowKey`. */
selection?: AdvancedTableSelection;
loading?: boolean;
/** Min table width before horizontal scroll kicks in. */
minWidth?: number;
/** Row density, e.g. 4 (compact) or 'sm' (comfortable). */
verticalSpacing?: MantineSpacing;
/** Per-row style override (e.g. focused-row highlight). */
rowStyle?: (row: T) => CSSProperties | undefined;
/** Rendered above the table, left-aligned (filters, search, tabs…). */
toolbar?: ReactNode;
/** Shows a refresh button above the table, right-aligned. */
onRefresh?: () => void;
emptyTitle?: string;
emptyDescription?: string;
onRowClick?: (row: T) => void;
}
function SortableHeader({
column,
sort,
}: {
column: AdvancedTableColumn<never>;
sort: AdvancedTableSort;
}) {
const active = sort.sortBy === column.key;
const Icon = active
? sort.sortDir === 'desc'
? IconChevronDown
: IconChevronUp
: IconSelector;
return (
<UnstyledButton onClick={() => sort.onSort(column.key)} fz="sm" fw={700}>
<Group gap={4} wrap="nowrap">
{column.header}
<Icon size={14} stroke={1.5} />
</Group>
</UnstyledButton>
);
}
/**
* Generic data table: column definitions and row actions come from the
* consumer as config (typically `*Columns.tsx` + `*ColumnActions.tsx` files);
* filters, search and refresh live in the parent component above the table.
* Sorting and pagination are controlled — the parent owns the state (URL,
* query params) and refetches.
*/
export function AdvancedTable<T>({
columns,
data,
rowKey,
actions,
sort,
pagination,
selection,
loading = false,
minWidth = 640,
verticalSpacing,
rowStyle,
toolbar,
onRefresh,
emptyTitle = 'Nothing here yet',
emptyDescription,
onRowClick,
}: AdvancedTableProps<T>) {
const allIds = data.map(rowKey);
const allSelected = selection
? allIds.length > 0 && allIds.every((id) => selection.selected.includes(id))
: false;
const toolbarRow = (toolbar || onRefresh) && (
<Group justify="space-between" mb="sm" wrap="nowrap" align="flex-end">
<Box style={{ flex: 1 }}>{toolbar}</Box>
{onRefresh && (
<Tooltip label="Refresh">
<ActionIcon variant="subtle" color="gray" onClick={onRefresh} aria-label="Refresh">
<IconRefresh size={18} />
</ActionIcon>
</Tooltip>
)}
</Group>
);
if (!loading && data.length === 0) {
return (
<Box>
{toolbarRow}
<EmptyState title={emptyTitle} description={emptyDescription} />
</Box>
);
}
return (
<Box pos="relative">
{toolbarRow}
<LoadingOverlay visible={loading} zIndex={10} />
<Table.ScrollContainer minWidth={minWidth}>
<Table striped highlightOnHover verticalSpacing={verticalSpacing}>
<Table.Thead>
<Table.Tr>
{selection && (
<Table.Th w={40}>
<Checkbox
checked={allSelected}
indeterminate={selection.selected.length > 0 && !allSelected}
onChange={() => selection.onChange(allSelected ? [] : allIds)}
aria-label="Select all"
/>
</Table.Th>
)}
{columns.map((col) => (
<Table.Th key={col.key} w={col.width} ta={col.align}>
{col.sortable && sort ? (
<SortableHeader column={col as AdvancedTableColumn<never>} sort={sort} />
) : (
col.header
)}
</Table.Th>
))}
{actions && <Table.Th w={1} />}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data.map((row) => (
<Table.Tr
key={rowKey(row)}
onClick={onRowClick ? () => onRowClick(row) : undefined}
style={{
...(onRowClick ? { cursor: 'pointer' } : undefined),
...rowStyle?.(row),
}}
>
{selection && (
<Table.Td onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selection.selected.includes(rowKey(row))}
onChange={(e) => {
const id = rowKey(row);
selection.onChange(
e.currentTarget.checked
? [...selection.selected, id]
: selection.selected.filter((s) => s !== id),
);
}}
aria-label="Select row"
/>
</Table.Td>
)}
{columns.map((col) => (
<Table.Td key={col.key} ta={col.align}>
{col.render
? col.render(row)
: ((row as Record<string, unknown>)[col.key] as ReactNode)}
</Table.Td>
))}
{actions && (
<Table.Td onClick={(e) => e.stopPropagation()}>
<Group gap="xs" wrap="nowrap" justify="flex-end">
{actions
.filter((a) => !a.hidden?.(row))
.map((a) => (
<Tooltip key={a.key} label={a.label}>
<ActionIcon
variant="subtle"
color={a.color}
disabled={a.disabled?.(row)}
aria-label={a.label}
onClick={() => a.onClick(row)}
>
{a.icon}
</ActionIcon>
</Tooltip>
))}
</Group>
</Table.Td>
)}
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
{pagination && pagination.totalPages > 1 && (
<Group justify="flex-end" mt="md">
<Pagination
value={pagination.page}
total={pagination.totalPages}
onChange={pagination.onPageChange}
/>
</Group>
)}
</Box>
);
}