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,345 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { parseAsString } from 'nuqs';
|
||||
import { toast } from 'sonner';
|
||||
import { Edit, MoreHorizontal, Package, Plus, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@vector/ui';
|
||||
import { PageHeader } from '../components/layout/PageHeader.js';
|
||||
import { DataTable } from '../components/data-table/DataTable.js';
|
||||
import { PartStateBadge, partStateOptions } from '../components/parts/PartStateBadge.js';
|
||||
import { PartFormDialog } from '../components/parts/PartFormDialog.js';
|
||||
import { PartBulkStateDialog } from '../components/parts/PartBulkStateDialog.js';
|
||||
import { ConfirmDialog } from '../components/ConfirmDialog.js';
|
||||
import { listParts, deletePart } from '../lib/api/parts.js';
|
||||
import { listManufacturers } from '../lib/api/manufacturers.js';
|
||||
import { listTags } from '../lib/api/tags.js';
|
||||
import { ApiRequestError } from '../lib/api/client.js';
|
||||
import type { Part } from '../lib/api/types.js';
|
||||
import { queryKeys } from '../lib/queryKeys.js';
|
||||
import { useAuth } from '../contexts/AuthContext.js';
|
||||
|
||||
type PartsFilters = {
|
||||
state: string | null;
|
||||
manufacturerId: string | null;
|
||||
tagId: string | null;
|
||||
};
|
||||
|
||||
const filterParsers = {
|
||||
state: parseAsString,
|
||||
manufacturerId: parseAsString,
|
||||
tagId: parseAsString,
|
||||
};
|
||||
|
||||
const ALL = '__all__';
|
||||
|
||||
export default function Parts() {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const isAdmin = user?.role === 'ADMIN';
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Part | null>(null);
|
||||
const [deleting, setDeleting] = useState<Part | null>(null);
|
||||
const [bulkIds, setBulkIds] = useState<string[] | null>(null);
|
||||
const [clearSelection, setClearSelection] = useState<(() => void) | null>(null);
|
||||
|
||||
const manufacturers = useQuery({
|
||||
queryKey: queryKeys.manufacturers.list({ pageSize: 100 }),
|
||||
queryFn: () => listManufacturers({ pageSize: 100 }),
|
||||
});
|
||||
const tagsQuery = useQuery({
|
||||
queryKey: queryKeys.tags.list({ pageSize: 100 }),
|
||||
queryFn: () => listTags({ pageSize: 100 }),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deletePart(id),
|
||||
onSuccess: () => {
|
||||
toast.success('Part deleted');
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
|
||||
setDeleting(null);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed');
|
||||
},
|
||||
});
|
||||
|
||||
const columns = useMemo<ColumnDef<Part>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'serialNumber',
|
||||
header: 'Serial',
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
to={`/parts/${row.original.id}`}
|
||||
className="font-mono text-xs font-medium text-foreground hover:underline"
|
||||
>
|
||||
{row.original.serialNumber}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'mpn',
|
||||
header: 'MPN',
|
||||
cell: ({ row }) => <span className="text-sm">{row.original.mpn}</span>,
|
||||
},
|
||||
{
|
||||
id: 'manufacturer',
|
||||
header: 'Manufacturer',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">{row.original.manufacturer.name}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'state',
|
||||
header: 'State',
|
||||
cell: ({ row }) => <PartStateBadge state={row.original.state} />,
|
||||
},
|
||||
{
|
||||
id: 'location',
|
||||
header: 'Location',
|
||||
cell: ({ row }) => {
|
||||
const path = row.original.bin?.fullPath;
|
||||
return path ? (
|
||||
<span className="text-xs font-mono text-muted-foreground">{path}</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground italic">Unassigned</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'price',
|
||||
header: 'Price',
|
||||
cell: ({ row }) =>
|
||||
row.original.price != null ? (
|
||||
<span className="text-sm tabular-nums">${row.original.price.toFixed(2)}</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
size: 40,
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem onSelect={() => navigate(`/parts/${row.original.id}`)}>
|
||||
View
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setEditing(row.original)}>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setDeleting(row.original)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
},
|
||||
],
|
||||
[navigate, isAdmin],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title="Parts"
|
||||
description="Search, filter, and manage every tracked part."
|
||||
actions={
|
||||
isAdmin && (
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
New part
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTable<Part, PartsFilters>
|
||||
columns={columns}
|
||||
getRowId={(p) => p.id}
|
||||
queryKey={(params) =>
|
||||
queryKeys.parts.list({
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
q: params.q,
|
||||
sort: params.sort,
|
||||
state: params.filters.state,
|
||||
manufacturerId: params.filters.manufacturerId,
|
||||
tagId: params.filters.tagId,
|
||||
})
|
||||
}
|
||||
queryFn={(params) =>
|
||||
listParts({
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
q: params.q,
|
||||
sort: params.sort,
|
||||
state: params.filters.state ?? undefined,
|
||||
manufacturerId: params.filters.manufacturerId ?? undefined,
|
||||
tagId: params.filters.tagId ?? undefined,
|
||||
})
|
||||
}
|
||||
filterParsers={filterParsers}
|
||||
searchPlaceholder="Search serial, MPN, notes…"
|
||||
enableSelection={isAdmin}
|
||||
emptyState={
|
||||
<div className="flex flex-col items-center gap-1 py-8 text-muted-foreground">
|
||||
<Package className="h-6 w-6" />
|
||||
<span className="text-sm">No parts match these filters.</span>
|
||||
</div>
|
||||
}
|
||||
toolbar={({ filters, setFilter }) => (
|
||||
<PartsFilters
|
||||
manufacturers={manufacturers.data?.data ?? []}
|
||||
tags={tagsQuery.data?.data ?? []}
|
||||
state={filters.state ?? ALL}
|
||||
manufacturerId={filters.manufacturerId ?? ALL}
|
||||
tagId={filters.tagId ?? ALL}
|
||||
onState={(v) => setFilter('state', v === ALL ? null : v)}
|
||||
onManufacturer={(v) => setFilter('manufacturerId', v === ALL ? null : v)}
|
||||
onTag={(v) => setFilter('tagId', v === ALL ? null : v)}
|
||||
/>
|
||||
)}
|
||||
bulkActions={(ids, clear) => (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setBulkIds(ids);
|
||||
setClearSelection(() => clear);
|
||||
}}
|
||||
>
|
||||
Bulk edit
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<PartFormDialog open={createOpen} onOpenChange={setCreateOpen} />
|
||||
<PartFormDialog
|
||||
open={Boolean(editing)}
|
||||
onOpenChange={(o) => !o && setEditing(null)}
|
||||
part={editing}
|
||||
/>
|
||||
<PartBulkStateDialog
|
||||
open={Boolean(bulkIds)}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setBulkIds(null);
|
||||
}}
|
||||
partIds={bulkIds ?? []}
|
||||
onDone={() => clearSelection?.()}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleting)}
|
||||
onOpenChange={(o) => !o && setDeleting(null)}
|
||||
title="Delete part?"
|
||||
description={
|
||||
deleting
|
||||
? `Permanently remove part ${deleting.serialNumber}. Its history will be removed too.`
|
||||
: undefined
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
destructive
|
||||
pending={deleteMutation.isPending}
|
||||
onConfirm={() => deleting && deleteMutation.mutate(deleting.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PartsFiltersProps {
|
||||
manufacturers: { id: string; name: string }[];
|
||||
tags: { id: string; name: string }[];
|
||||
state: string;
|
||||
manufacturerId: string;
|
||||
tagId: string;
|
||||
onState: (v: string) => void;
|
||||
onManufacturer: (v: string) => void;
|
||||
onTag: (v: string) => void;
|
||||
}
|
||||
|
||||
function PartsFilters({
|
||||
manufacturers,
|
||||
tags,
|
||||
state,
|
||||
manufacturerId,
|
||||
tagId,
|
||||
onState,
|
||||
onManufacturer,
|
||||
onTag,
|
||||
}: PartsFiltersProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={state} onValueChange={onState}>
|
||||
<SelectTrigger className="h-8 w-36 text-xs">
|
||||
<SelectValue placeholder="State" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>All states</SelectItem>
|
||||
{partStateOptions.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={manufacturerId} onValueChange={onManufacturer}>
|
||||
<SelectTrigger className="h-8 w-48 text-xs">
|
||||
<SelectValue placeholder="Manufacturer" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>All manufacturers</SelectItem>
|
||||
{manufacturers.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={tagId} onValueChange={onTag}>
|
||||
<SelectTrigger className="h-8 w-36 text-xs">
|
||||
<SelectValue placeholder="Tag" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>All tags</SelectItem>
|
||||
{tags.map((t) => (
|
||||
<SelectItem key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user