chore: initial Vector 2.0 monorepo
CI / Lint · Typecheck · Test · Build (push) Failing after 5m41s
CI / Playwright (smoke) (push) Has been skipped

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:
2026-04-16 20:52:32 -04:00
commit 7c0d422228
216 changed files with 19393 additions and 0 deletions
@@ -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>
))}
</>
);
}