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,313 @@
|
||||
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 {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Textarea,
|
||||
} from '@vector/ui';
|
||||
import { createRepair, updateRepair } from '../../lib/api/repairs.js';
|
||||
import { listHosts } from '../../lib/api/hosts.js';
|
||||
import { ApiRequestError } from '../../lib/api/client.js';
|
||||
import { queryKeys } from '../../lib/queryKeys.js';
|
||||
import type { RepairJob } from '../../lib/api/types.js';
|
||||
import { repairStatusOptions } from './RepairStatusBadge.js';
|
||||
|
||||
const NONE = '__none__';
|
||||
|
||||
const CreateSchema = z.object({
|
||||
partId: z.string().uuid('Pick a valid part id'),
|
||||
hostId: z.string().optional(),
|
||||
notes: z.string().max(4096).optional(),
|
||||
});
|
||||
const EditSchema = z.object({
|
||||
status: z.enum(['PENDING', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED']),
|
||||
hostId: z.string().optional(),
|
||||
notes: z.string().max(4096).optional(),
|
||||
});
|
||||
type CreateValues = z.infer<typeof CreateSchema>;
|
||||
type EditValues = z.infer<typeof EditSchema>;
|
||||
|
||||
interface RepairFormDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
repair?: RepairJob | null;
|
||||
defaultPartId?: string;
|
||||
}
|
||||
|
||||
export function RepairFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
repair,
|
||||
defaultPartId,
|
||||
}: RepairFormDialogProps) {
|
||||
const editing = Boolean(repair);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const hostsQuery = useQuery({
|
||||
queryKey: queryKeys.hosts.list({ pageSize: 100 }),
|
||||
queryFn: () => listHosts({ pageSize: 100 }),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const createForm = useForm<CreateValues>({
|
||||
resolver: zodResolver(CreateSchema),
|
||||
defaultValues: { partId: '', hostId: NONE, notes: '' },
|
||||
});
|
||||
const editForm = useForm<EditValues>({
|
||||
resolver: zodResolver(EditSchema),
|
||||
defaultValues: { status: 'PENDING', hostId: NONE, notes: '' },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (editing && repair) {
|
||||
editForm.reset({
|
||||
status: repair.status,
|
||||
hostId: repair.hostId ?? NONE,
|
||||
notes: repair.notes ?? '',
|
||||
});
|
||||
} else {
|
||||
createForm.reset({ partId: defaultPartId ?? '', hostId: NONE, notes: '' });
|
||||
}
|
||||
}, [open, editing, repair, defaultPartId, createForm, editForm]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (values: CreateValues) =>
|
||||
createRepair({
|
||||
partId: values.partId,
|
||||
hostId: values.hostId && values.hostId !== NONE ? values.hostId : null,
|
||||
notes: values.notes ? values.notes : null,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Repair opened');
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.repairs.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Save failed'),
|
||||
});
|
||||
|
||||
const editMutation = useMutation({
|
||||
mutationFn: async (values: EditValues) =>
|
||||
updateRepair(repair!.id, {
|
||||
status: values.status,
|
||||
hostId: values.hostId && values.hostId !== NONE ? values.hostId : null,
|
||||
notes: values.notes ? values.notes : null,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Repair updated');
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.repairs.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Save failed'),
|
||||
});
|
||||
|
||||
const pending = createMutation.isPending || editMutation.isPending;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? 'Edit repair' : 'Open repair'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editing
|
||||
? 'Advance status, re-assign the host, or update notes.'
|
||||
: 'Open a repair job for a part. Status starts as PENDING.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{editing ? (
|
||||
<Form {...editForm}>
|
||||
<form
|
||||
onSubmit={editForm.handleSubmit((v) => editMutation.mutate(v))}
|
||||
className="space-y-3"
|
||||
>
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Status</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{repairStatusOptions.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="hostId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Host</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value ?? NONE}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>None</SelectItem>
|
||||
{hostsQuery.data?.data.map((h) => (
|
||||
<SelectItem key={h.id} value={h.id}>
|
||||
{h.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={editForm.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={pending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Save changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
) : (
|
||||
<Form {...createForm}>
|
||||
<form
|
||||
onSubmit={createForm.handleSubmit((v) => createMutation.mutate(v))}
|
||||
className="space-y-3"
|
||||
>
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="partId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Part ID</FormLabel>
|
||||
<FormControl>
|
||||
<input
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
|
||||
placeholder="Part UUID"
|
||||
autoFocus
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Paste the part UUID to open a repair against it.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="hostId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Host (optional)</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value ?? NONE}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>None</SelectItem>
|
||||
{hostsQuery.data?.data.map((h) => (
|
||||
<SelectItem key={h.id} value={h.id}>
|
||||
{h.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={createForm.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={pending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Open repair
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { RepairStatus } from '@vector/shared';
|
||||
import { Badge } from '@vector/ui';
|
||||
|
||||
const LABELS: Record<RepairStatus, string> = {
|
||||
PENDING: 'Pending',
|
||||
IN_PROGRESS: 'In progress',
|
||||
COMPLETED: 'Completed',
|
||||
CANCELLED: 'Cancelled',
|
||||
};
|
||||
|
||||
const VARIANTS: Record<RepairStatus, 'outline' | 'warning' | 'success' | 'secondary'> = {
|
||||
PENDING: 'outline',
|
||||
IN_PROGRESS: 'warning',
|
||||
COMPLETED: 'success',
|
||||
CANCELLED: 'secondary',
|
||||
};
|
||||
|
||||
export const repairStatusOptions: { value: RepairStatus; label: string }[] = (
|
||||
Object.keys(LABELS) as RepairStatus[]
|
||||
).map((value) => ({ value, label: LABELS[value] }));
|
||||
|
||||
export function RepairStatusBadge({ status }: { status: RepairStatus }) {
|
||||
return <Badge variant={VARIANTS[status]}>{LABELS[status]}</Badge>;
|
||||
}
|
||||
Reference in New Issue
Block a user