chore: initial Vector 2.0 monorepo
Ground-up TypeScript rewrite of the Vector hardware parts inventory
system. Ships the full roadmap (Phases 0-8) in one initial commit:
- pnpm + Turbo monorepo: apps/{api,web,e2e}, packages/{db,shared,ui,config}
- Express 5 + Prisma 5 + zod validation + JWT w/ refresh-token rotation
- React 19 + Vite + shadcn/ui + TanStack Query/Table + nuqs URL state
- Repair/RMA, tags, bulk ops, saved views, CSV audit export
- Analytics dashboard on Recharts + EOL tracking
- Signed webhook subscriptions (HMAC-SHA256) with in-process emitter
- Vitest unit tests (shared schemas, api services/helpers) + Playwright skeleton
- Gitea Actions CI (lint, typecheck, test+coverage, build) + Renovate
Deferred follow-ups: Postgres cutover (data-migration script ready),
BullMQ worker for webhook delivery, @react-pdf PDF export, CSV import wizard.
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
import { useMemo, useState, type ReactNode } from 'react';
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
type OnChangeFn,
|
||||
type Row,
|
||||
type RowSelectionState,
|
||||
type SortingState,
|
||||
} from '@tanstack/react-table';
|
||||
import { useQuery, keepPreviousData } from '@tanstack/react-query';
|
||||
import {
|
||||
parseAsInteger,
|
||||
parseAsString,
|
||||
useQueryState,
|
||||
useQueryStates,
|
||||
type ParserBuilder,
|
||||
} from 'nuqs';
|
||||
import { ChevronDown, ChevronLeft, ChevronRight, ChevronsUpDown, ChevronUp, Search } from 'lucide-react';
|
||||
import type { PaginatedResponse } from '@vector/shared';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Input,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
cn,
|
||||
} from '@vector/ui';
|
||||
|
||||
// Common shape the DataTable forwards to the consumer's queryFn.
|
||||
export interface DataTableQueryParams<TFilters> {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
sort?: string; // "field:asc" or "field:desc"
|
||||
q?: string;
|
||||
filters: TFilters;
|
||||
}
|
||||
|
||||
export type FilterParsers<TFilters> = {
|
||||
[K in keyof TFilters]: ParserBuilder<TFilters[K]>;
|
||||
};
|
||||
|
||||
export interface DataTableProps<TData, TFilters extends Record<string, unknown>> {
|
||||
columns: ColumnDef<TData, unknown>[];
|
||||
queryKey: (params: DataTableQueryParams<TFilters>) => readonly unknown[];
|
||||
queryFn: (params: DataTableQueryParams<TFilters>) => Promise<PaginatedResponse<TData>>;
|
||||
/** How to get a stable string id per row (usually `(r) => r.id`). */
|
||||
getRowId: (row: TData) => string;
|
||||
/** nuqs parsers for resource-specific filters; each becomes a URL query param. */
|
||||
filterParsers?: FilterParsers<TFilters>;
|
||||
/** Default page size; the user may still adjust. */
|
||||
defaultPageSize?: number;
|
||||
searchPlaceholder?: string;
|
||||
enableSearch?: boolean;
|
||||
enableSelection?: boolean;
|
||||
/** Rendered when at least one row is selected. Receives the selected row IDs. */
|
||||
bulkActions?: (selectedIds: string[], clear: () => void) => ReactNode;
|
||||
/**
|
||||
* Rendered above the table on the right. Either a node, or a render prop that receives the
|
||||
* current filter state + a setter so consumers can drive URL-synced filters.
|
||||
*/
|
||||
toolbar?:
|
||||
| ReactNode
|
||||
| ((helpers: {
|
||||
filters: TFilters;
|
||||
setFilter: <K extends keyof TFilters>(name: K, value: TFilters[K] | null) => void;
|
||||
}) => ReactNode);
|
||||
emptyState?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Parse "field:dir" into a TanStack sorting state. Returns [] when empty.
|
||||
function parseSortState(sort: string | null): SortingState {
|
||||
if (!sort) return [];
|
||||
const [id, dir = 'asc'] = sort.split(':');
|
||||
if (!id) return [];
|
||||
return [{ id, desc: dir === 'desc' }];
|
||||
}
|
||||
function serializeSortState(state: SortingState): string | null {
|
||||
if (state.length === 0) return null;
|
||||
const [first] = state;
|
||||
return `${first.id}:${first.desc ? 'desc' : 'asc'}`;
|
||||
}
|
||||
|
||||
export function DataTable<TData, TFilters extends Record<string, unknown>>({
|
||||
columns,
|
||||
queryKey,
|
||||
queryFn,
|
||||
getRowId,
|
||||
filterParsers,
|
||||
defaultPageSize = 20,
|
||||
searchPlaceholder = 'Search…',
|
||||
enableSearch = true,
|
||||
enableSelection = false,
|
||||
bulkActions,
|
||||
toolbar,
|
||||
emptyState,
|
||||
className,
|
||||
}: DataTableProps<TData, TFilters>) {
|
||||
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
|
||||
const [pageSize, setPageSize] = useQueryState(
|
||||
'pageSize',
|
||||
parseAsInteger.withDefault(defaultPageSize),
|
||||
);
|
||||
const [q, setQ] = useQueryState(
|
||||
'q',
|
||||
parseAsString.withDefault('').withOptions({ throttleMs: 300 }),
|
||||
);
|
||||
const [sort, setSort] = useQueryState('sort', parseAsString);
|
||||
|
||||
// Resource-specific filters. When filterParsers is omitted, we still render but with no URL state.
|
||||
const [filters, setFilters] = useQueryStates(
|
||||
(filterParsers ?? ({} as FilterParsers<TFilters>)) as Record<string, ParserBuilder<unknown>>,
|
||||
);
|
||||
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
|
||||
const sortingState = useMemo(() => parseSortState(sort), [sort]);
|
||||
const handleSortingChange: OnChangeFn<SortingState> = (updater) => {
|
||||
const next = typeof updater === 'function' ? updater(sortingState) : updater;
|
||||
void setSort(serializeSortState(next));
|
||||
};
|
||||
|
||||
const params: DataTableQueryParams<TFilters> = {
|
||||
page,
|
||||
pageSize,
|
||||
sort: sort ?? undefined,
|
||||
q: q || undefined,
|
||||
filters: filters as TFilters,
|
||||
};
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: queryKey(params),
|
||||
queryFn: () => queryFn(params),
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
const rows = query.data?.data ?? [];
|
||||
const total = query.data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
const selectionColumn: ColumnDef<TData, unknown> | null = enableSelection
|
||||
? {
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
aria-label="Select all"
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected()
|
||||
? true
|
||||
: table.getIsSomePageRowsSelected()
|
||||
? 'indeterminate'
|
||||
: false
|
||||
}
|
||||
onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
aria-label="Select row"
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(v) => row.toggleSelected(!!v)}
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
size: 32,
|
||||
}
|
||||
: null;
|
||||
|
||||
const tableColumns = useMemo(
|
||||
() => (selectionColumn ? [selectionColumn, ...columns] : columns),
|
||||
[selectionColumn, columns],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: rows,
|
||||
columns: tableColumns,
|
||||
getRowId,
|
||||
state: { sorting: sortingState, rowSelection },
|
||||
onSortingChange: handleSortingChange,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
manualSorting: true,
|
||||
manualPagination: true,
|
||||
enableRowSelection: enableSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
pageCount,
|
||||
});
|
||||
|
||||
const selectedIds = Object.keys(rowSelection);
|
||||
|
||||
const clearSelection = () => setRowSelection({});
|
||||
const setFilter = <K extends keyof TFilters>(name: K, value: TFilters[K] | null) => {
|
||||
void setFilters(
|
||||
(prev) => ({ ...(prev as object), [name]: value } as Partial<typeof filters>),
|
||||
);
|
||||
void setPage(1);
|
||||
};
|
||||
|
||||
const toolbarNode =
|
||||
typeof toolbar === 'function'
|
||||
? toolbar({ filters: filters as TFilters, setFilter })
|
||||
: toolbar;
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-3', className)}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{enableSearch && (
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={q}
|
||||
onChange={(e) => {
|
||||
void setQ(e.target.value || null);
|
||||
void setPage(1);
|
||||
}}
|
||||
placeholder={searchPlaceholder}
|
||||
className="h-8 w-64 pl-8"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">{toolbarNode}</div>
|
||||
</div>
|
||||
|
||||
{enableSelection && selectedIds.length > 0 && (
|
||||
<div className="flex items-center justify-between rounded-md border border-border bg-muted/40 px-3 py-2 text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{selectedIds.length} selected
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{bulkActions?.(selectedIds, clearSelection)}
|
||||
<Button variant="ghost" size="sm" onClick={clearSelection}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const canSort = header.column.getCanSort();
|
||||
const sortDir = header.column.getIsSorted();
|
||||
return (
|
||||
<TableHead key={header.id} style={{ width: header.getSize() || undefined }}>
|
||||
{header.isPlaceholder ? null : canSort ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
className="inline-flex items-center gap-1 text-left text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
{sortDir === 'asc' ? (
|
||||
<ChevronUp className="h-3.5 w-3.5" />
|
||||
) : sortDir === 'desc' ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronsUpDown className="h-3.5 w-3.5 opacity-40" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
flexRender(header.column.columnDef.header, header.getContext())
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{query.isPending ? (
|
||||
<SkeletonRows columns={tableColumns.length} pageSize={pageSize} />
|
||||
) : query.isError ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={tableColumns.length}
|
||||
className="py-10 text-center text-sm text-destructive"
|
||||
>
|
||||
{(query.error as Error).message ?? 'Failed to load'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : table.getRowModel().rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={tableColumns.length} className="py-12 text-center">
|
||||
{emptyState ?? (
|
||||
<div className="text-sm text-muted-foreground">No results.</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
table.getRowModel().rows.map((row) => <DataRow key={row.id} row={row} />)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<div>
|
||||
{total === 0 ? '0 rows' : `${(page - 1) * pageSize + 1}–${Math.min(page * pageSize, total)} of ${total}`}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
disabled={page <= 1 || query.isFetching}
|
||||
onClick={() => void setPage(page - 1)}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="px-2">
|
||||
Page {page} of {pageCount}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
disabled={page >= pageCount || query.isFetching}
|
||||
onClick={() => void setPage(page + 1)}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => {
|
||||
void setPageSize(Number(e.target.value));
|
||||
void setPage(1);
|
||||
}}
|
||||
className="ml-2 h-7 rounded-md border border-input bg-transparent px-2 text-xs"
|
||||
>
|
||||
{[10, 20, 50, 100].map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s} / page
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DataRow<TData>({ row }: { row: Row<TData> }) {
|
||||
return (
|
||||
<TableRow data-state={row.getIsSelected() ? 'selected' : undefined}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonRows({ columns, pageSize }: { columns: number; pageSize: number }) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: Math.min(pageSize, 8) }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
{Array.from({ length: columns }).map((_, j) => (
|
||||
<TableCell key={j}>
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user