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,325 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { z } from 'zod';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { PartState } from '@vector/shared';
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Textarea,
|
||||
} from '@vector/ui';
|
||||
import { listManufacturers } from '../../lib/api/manufacturers.js';
|
||||
import { listBins } from '../../lib/api/bins.js';
|
||||
import { createPart, updatePart } from '../../lib/api/parts.js';
|
||||
import type { Part } from '../../lib/api/types.js';
|
||||
import { ApiRequestError } from '../../lib/api/client.js';
|
||||
import { queryKeys } from '../../lib/queryKeys.js';
|
||||
import { partStateOptions } from './PartStateBadge.js';
|
||||
|
||||
// Schema reflects the server's CreatePartRequest but keeps strings for the form, letting the
|
||||
// submit handler coerce to the network shape.
|
||||
const PartFormSchema = z.object({
|
||||
serialNumber: z.string().min(1, 'Required').max(128),
|
||||
mpn: z.string().min(1, 'Required').max(128),
|
||||
manufacturerId: z.string().uuid('Select a manufacturer'),
|
||||
state: PartState,
|
||||
binId: z.string().optional(), // '' = none
|
||||
price: z.string().optional(), // empty string = null
|
||||
notes: z.string().max(4096).optional(),
|
||||
});
|
||||
type PartFormValues = z.infer<typeof PartFormSchema>;
|
||||
|
||||
const UNASSIGNED = '__none__';
|
||||
|
||||
interface PartFormDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
part?: Part | null;
|
||||
}
|
||||
|
||||
export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps) {
|
||||
const editing = Boolean(part);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const form = useForm<PartFormValues>({
|
||||
resolver: zodResolver(PartFormSchema),
|
||||
defaultValues: {
|
||||
serialNumber: '',
|
||||
mpn: '',
|
||||
manufacturerId: '',
|
||||
state: 'SPARE',
|
||||
binId: '',
|
||||
price: '',
|
||||
notes: '',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
form.reset(
|
||||
part
|
||||
? {
|
||||
serialNumber: part.serialNumber,
|
||||
mpn: part.mpn,
|
||||
manufacturerId: part.manufacturerId,
|
||||
state: part.state,
|
||||
binId: part.binId ?? '',
|
||||
price: part.price != null ? String(part.price) : '',
|
||||
notes: part.notes ?? '',
|
||||
}
|
||||
: {
|
||||
serialNumber: '',
|
||||
mpn: '',
|
||||
manufacturerId: '',
|
||||
state: 'SPARE',
|
||||
binId: '',
|
||||
price: '',
|
||||
notes: '',
|
||||
},
|
||||
);
|
||||
}, [open, part, form]);
|
||||
|
||||
const manufacturers = useQuery({
|
||||
queryKey: queryKeys.manufacturers.list({ pageSize: 100 }),
|
||||
queryFn: () => listManufacturers({ pageSize: 100 }),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const bins = useQuery({
|
||||
queryKey: queryKeys.bins.list({ pageSize: 100 }),
|
||||
queryFn: () => listBins({ pageSize: 100 }),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: PartFormValues) => {
|
||||
const payload = {
|
||||
serialNumber: values.serialNumber,
|
||||
mpn: values.mpn,
|
||||
manufacturerId: values.manufacturerId,
|
||||
state: values.state,
|
||||
binId: values.binId ? values.binId : null,
|
||||
price: values.price === '' ? null : Number(values.price),
|
||||
notes: values.notes ? values.notes : null,
|
||||
};
|
||||
return editing && part
|
||||
? updatePart(part.id, payload)
|
||||
: createPart(payload);
|
||||
},
|
||||
onSuccess: (saved) => {
|
||||
toast.success(editing ? 'Part updated' : 'Part created');
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
|
||||
if (editing) {
|
||||
queryClient.setQueryData(queryKeys.parts.detail(saved.id), saved);
|
||||
}
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
const msg =
|
||||
err instanceof ApiRequestError ? err.body.message : 'Could not save part';
|
||||
toast.error(msg);
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (values: PartFormValues) => {
|
||||
if (values.price !== '' && values.price !== undefined) {
|
||||
const n = Number(values.price);
|
||||
if (!Number.isFinite(n) || n < 0) {
|
||||
form.setError('price', { message: 'Must be a non-negative number' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
mutation.mutate(values);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? 'Edit part' : 'New part'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editing
|
||||
? 'Update this part. Changes are logged to its history.'
|
||||
: 'Add a part to inventory. Serial numbers must be unique.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="serialNumber"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Serial</FormLabel>
|
||||
<FormControl>
|
||||
<Input autoFocus {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="mpn"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>MPN</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="manufacturerId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Manufacturer</FormLabel>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select manufacturer" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{manufacturers.data?.data.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="state"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>State</FormLabel>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{partStateOptions.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="price"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Price (USD)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" step="0.01" min="0" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="binId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Location</FormLabel>
|
||||
<Select
|
||||
value={field.value ? field.value : UNASSIGNED}
|
||||
onValueChange={(v) => field.onChange(v === UNASSIGNED ? '' : v)}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Unassigned" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={UNASSIGNED}>Unassigned</SelectItem>
|
||||
{bins.data?.data.map((b) => (
|
||||
<SelectItem key={b.id} value={b.id}>
|
||||
{b.fullPath ?? b.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notes"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Notes</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={3} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{editing ? 'Save changes' : 'Create part'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user