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
+56
View File
@@ -0,0 +1,56 @@
import { Loader2 } from 'lucide-react';
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@vector/ui';
interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: string;
confirmLabel?: string;
destructive?: boolean;
pending?: boolean;
onConfirm: () => void;
}
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel = 'Confirm',
destructive,
pending,
onConfirm,
}: ConfirmDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
Cancel
</Button>
<Button
variant={destructive ? 'destructive' : 'default'}
onClick={onConfirm}
disabled={pending}
>
{pending && <Loader2 className="h-4 w-4 animate-spin" />}
{confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,86 @@
import { useEffect, useState } from 'react';
import { Loader2 } from 'lucide-react';
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
} from '@vector/ui';
interface NamePromptDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: string;
label?: string;
initialValue?: string;
confirmLabel?: string;
pending?: boolean;
onSubmit: (value: string) => void;
}
export function NamePromptDialog({
open,
onOpenChange,
title,
description,
label = 'Name',
initialValue = '',
confirmLabel = 'Save',
pending,
onSubmit,
}: NamePromptDialogProps) {
const [value, setValue] = useState(initialValue);
useEffect(() => {
if (open) setValue(initialValue);
}, [open, initialValue]);
const disabled = pending || value.trim().length === 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<form
onSubmit={(e) => {
e.preventDefault();
if (!disabled) onSubmit(value.trim());
}}
className="space-y-3"
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
<div className="space-y-1.5">
<Label>{label}</Label>
<Input
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
disabled={pending}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={pending}
>
Cancel
</Button>
<Button type="submit" disabled={disabled}>
{pending && <Loader2 className="h-4 w-4 animate-spin" />}
{confirmLabel}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,30 @@
import { Navigate, useLocation } from 'react-router-dom';
import type { ReactNode } from 'react';
import { Skeleton } from '@vector/ui';
import { useAuth } from '../../contexts/AuthContext.js';
import type { Role } from '@vector/shared';
interface RequireAuthProps {
children: ReactNode;
role?: Role;
}
export function RequireAuth({ children, role }: RequireAuthProps) {
const { user, status } = useAuth();
const location = useLocation();
if (status === 'loading') {
return (
<div className="flex min-h-screen items-center justify-center">
<Skeleton className="h-20 w-72" />
</div>
);
}
if (status === 'anonymous' || !user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
if (role && user.role !== role) {
return <Navigate to="/" replace />;
}
return <>{children}</>;
}
@@ -0,0 +1,87 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Boxes, LayoutDashboard, MapPinned, Package, Wrench } from 'lucide-react';
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@vector/ui';
interface PaletteItem {
id: string;
label: string;
to: string;
icon: React.ComponentType<{ className?: string }>;
group: 'Navigate' | 'Actions';
}
// Stub: nav-only entries for Phase 4. Phase 5+ will merge in recent-parts + saved-views.
const ITEMS: PaletteItem[] = [
{ id: 'nav-dashboard', label: 'Dashboard', to: '/', icon: LayoutDashboard, group: 'Navigate' },
{ id: 'nav-parts', label: 'Parts', to: '/parts', icon: Package, group: 'Navigate' },
{ id: 'nav-locations', label: 'Locations', to: '/locations', icon: MapPinned, group: 'Navigate' },
{ id: 'nav-manufacturers', label: 'Manufacturers', to: '/manufacturers', icon: Boxes, group: 'Navigate' },
{ id: 'nav-repairs', label: 'Repairs', to: '/repairs', icon: Wrench, group: 'Navigate' },
];
export interface CommandPaletteProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
const navigate = useNavigate();
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
onOpenChange(!open);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onOpenChange]);
const grouped = ITEMS.reduce<Record<string, PaletteItem[]>>((acc, i) => {
(acc[i.group] ||= []).push(i);
return acc;
}, {});
return (
<CommandDialog open={open} onOpenChange={onOpenChange}>
<CommandInput placeholder="Search pages, parts, actions…" />
<CommandList>
<CommandEmpty>No results.</CommandEmpty>
{Object.entries(grouped).map(([group, items], idx) => (
<CommandGroup key={group} heading={group}>
{idx > 0 && <CommandSeparator />}
{items.map((item) => (
<CommandItem
key={item.id}
value={item.label}
onSelect={() => {
navigate(item.to);
onOpenChange(false);
}}
>
<item.icon className="h-4 w-4 opacity-70" />
<span>{item.label}</span>
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</CommandDialog>
);
}
// Convenience hook: colocate open-state + keyboard trigger for AppShell.
export function useCommandPalette() {
const [open, setOpen] = useState(false);
return { open, setOpen, openPalette: () => setOpen(true) };
}
@@ -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>
))}
</>
);
}
@@ -0,0 +1,149 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, 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,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Textarea,
} from '@vector/ui';
import { createHost, updateHost } from '../../lib/api/hosts.js';
import { ApiRequestError } from '../../lib/api/client.js';
import { queryKeys } from '../../lib/queryKeys.js';
import type { Host } from '../../lib/api/types.js';
const Schema = z.object({
name: z.string().min(1, 'Required').max(128),
location: z.string().max(256).optional(),
notes: z.string().max(4096).optional(),
});
type Values = z.infer<typeof Schema>;
interface HostFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
host?: Host | null;
}
export function HostFormDialog({ open, onOpenChange, host }: HostFormDialogProps) {
const editing = Boolean(host);
const queryClient = useQueryClient();
const form = useForm<Values>({
resolver: zodResolver(Schema),
defaultValues: { name: '', location: '', notes: '' },
});
useEffect(() => {
if (!open) return;
form.reset({
name: host?.name ?? '',
location: host?.location ?? '',
notes: host?.notes ?? '',
});
}, [open, host, form]);
const mutation = useMutation({
mutationFn: async (values: Values) => {
const payload = {
name: values.name,
location: values.location ? values.location : null,
notes: values.notes ? values.notes : null,
};
return editing && host ? updateHost(host.id, payload) : createHost(payload);
},
onSuccess: () => {
toast.success(editing ? 'Host updated' : 'Host created');
queryClient.invalidateQueries({ queryKey: queryKeys.hosts.all });
onOpenChange(false);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Save failed'),
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{editing ? 'Edit host' : 'New host'}</DialogTitle>
<DialogDescription>
Hosts are the machines or racks where parts get installed for repair jobs.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit((v) => mutation.mutate(v))} className="space-y-3">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input autoFocus {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="location"
render={({ field }) => (
<FormItem>
<FormLabel>Location</FormLabel>
<FormControl>
<Input placeholder="Rack B3, Lab 2" {...field} />
</FormControl>
<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'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,24 @@
import { useState } from 'react';
import { Outlet } from 'react-router-dom';
import { Sidebar } from './Sidebar.js';
import { TopBar } from './TopBar.js';
import { CommandPalette, useCommandPalette } from '../command/CommandPalette.js';
import { cn } from '@vector/ui';
export function AppShell() {
const [collapsed, setCollapsed] = useState(false);
const palette = useCommandPalette();
return (
<div className="min-h-screen bg-background text-foreground">
<Sidebar collapsed={collapsed} onToggle={() => setCollapsed((v) => !v)} />
<div className={cn('transition-[padding] duration-200', collapsed ? 'pl-14' : 'pl-64')}>
<TopBar onOpenCommand={palette.openPalette} />
<main className="p-4 sm:p-6">
<Outlet />
</main>
</div>
<CommandPalette open={palette.open} onOpenChange={palette.setOpen} />
</div>
);
}
@@ -0,0 +1,41 @@
import { Link, useLocation } from 'react-router-dom';
import { ChevronRight } from 'lucide-react';
// Humanize a URL slug: "admin/users" -> "Users", "part-detail" -> "Part detail".
function humanize(slug: string): string {
const cleaned = slug.replace(/-/g, ' ');
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
}
export function Breadcrumbs() {
const { pathname } = useLocation();
const parts = pathname.split('/').filter(Boolean);
if (parts.length === 0) {
return <div className="text-sm font-medium text-foreground">Dashboard</div>;
}
return (
<nav aria-label="Breadcrumb" className="flex items-center gap-1 text-sm">
<Link to="/" className="text-muted-foreground hover:text-foreground">
Home
</Link>
{parts.map((part, i) => {
const to = '/' + parts.slice(0, i + 1).join('/');
const isLast = i === parts.length - 1;
return (
<span key={to} className="flex items-center gap-1">
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground/60" />
{isLast ? (
<span className="font-medium text-foreground">{humanize(part)}</span>
) : (
<Link to={to} className="text-muted-foreground hover:text-foreground">
{humanize(part)}
</Link>
)}
</span>
);
})}
</nav>
);
}
@@ -0,0 +1,50 @@
import { Component, type ErrorInfo, type ReactNode } from 'react';
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@vector/ui';
interface Props {
children: ReactNode;
}
interface State {
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
override state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
override componentDidCatch(error: Error, info: ErrorInfo) {
// eslint-disable-next-line no-console
console.error('ErrorBoundary caught', error, info.componentStack);
}
handleReset = () => this.setState({ error: null });
override render() {
if (!this.state.error) return this.props.children;
return (
<div className="flex min-h-screen items-center justify-center bg-background p-6">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Something broke</CardTitle>
<CardDescription>An unrecoverable error bubbled up.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<pre className="max-h-48 overflow-auto rounded-md bg-muted/40 p-3 text-xs text-muted-foreground">
{this.state.error.message}
</pre>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => window.location.reload()}>
Reload
</Button>
<Button onClick={this.handleReset}>Try again</Button>
</div>
</CardContent>
</Card>
</div>
);
}
}
@@ -0,0 +1,21 @@
import type { ReactNode } from 'react';
interface PageHeaderProps {
title: string;
description?: string;
actions?: ReactNode;
}
export function PageHeader({ title, description, actions }: PageHeaderProps) {
return (
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{description && (
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
)}
</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
);
}
+107
View File
@@ -0,0 +1,107 @@
import { NavLink } from 'react-router-dom';
import {
Boxes,
ChevronsLeft,
ChevronsRight,
LayoutDashboard,
type LucideIcon,
MapPinned,
Package,
Server,
Users as UsersIcon,
Webhook,
Wrench,
} from 'lucide-react';
import { cn, Button, Tooltip, TooltipContent, TooltipTrigger } from '@vector/ui';
import { useAuth } from '../../contexts/AuthContext.js';
interface NavItem {
to: string;
label: string;
icon: LucideIcon;
adminOnly?: boolean;
}
const NAV_ITEMS: NavItem[] = [
{ to: '/', label: 'Dashboard', icon: LayoutDashboard },
{ to: '/parts', label: 'Parts', icon: Package },
{ to: '/locations', label: 'Locations', icon: MapPinned },
{ to: '/manufacturers', label: 'Manufacturers', icon: Boxes },
{ to: '/repairs', label: 'Repairs', icon: Wrench },
{ to: '/hosts', label: 'Hosts', icon: Server },
{ to: '/admin/users', label: 'Users', icon: UsersIcon, adminOnly: true },
{ to: '/admin/webhooks', label: 'Webhooks', icon: Webhook, adminOnly: true },
];
export interface SidebarProps {
collapsed: boolean;
onToggle: () => void;
}
export function Sidebar({ collapsed, onToggle }: SidebarProps) {
const { user } = useAuth();
const items = NAV_ITEMS.filter((i) => !i.adminOnly || user?.role === 'ADMIN');
return (
<aside
className={cn(
'fixed inset-y-0 left-0 z-40 flex flex-col border-r border-border bg-card transition-[width] duration-200',
collapsed ? 'w-14' : 'w-64',
)}
>
<div className="flex h-13 items-center gap-2 border-b border-border px-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-brand text-brand-foreground font-semibold">
V
</div>
{!collapsed && <span className="truncate text-sm font-semibold">Vector</span>}
</div>
<nav className="flex-1 space-y-0.5 p-2">
{items.map((item) => (
<NavItemLink key={item.to} item={item} collapsed={collapsed} />
))}
</nav>
<div className="border-t border-border p-2">
<Button
variant="ghost"
size="icon"
className={cn('w-full', !collapsed && 'justify-start gap-2 px-2')}
onClick={onToggle}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{collapsed ? <ChevronsRight className="h-4 w-4" /> : <ChevronsLeft className="h-4 w-4" />}
{!collapsed && <span className="text-xs text-muted-foreground">Collapse</span>}
</Button>
</div>
</aside>
);
}
function NavItemLink({ item, collapsed }: { item: NavItem; collapsed: boolean }) {
const content = (
<NavLink
to={item.to}
end={item.to === '/'}
className={({ isActive }) =>
cn(
'flex items-center gap-3 rounded-md px-2.5 py-2 text-sm transition-colors',
isActive
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground',
collapsed && 'justify-center px-0',
)
}
>
<item.icon className="h-4 w-4 shrink-0" />
{!collapsed && <span className="truncate">{item.label}</span>}
</NavLink>
);
if (!collapsed) return content;
return (
<Tooltip>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent side="right">{item.label}</TooltipContent>
</Tooltip>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { useNavigate } from 'react-router-dom';
import { LogOut, Search } from 'lucide-react';
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
Badge,
} from '@vector/ui';
import { useAuth } from '../../contexts/AuthContext.js';
import { Breadcrumbs } from './Breadcrumbs.js';
import { toast } from 'sonner';
// Minimal avatar fallback since we haven't built the Avatar primitive yet. Inline circle.
function InitialsAvatar({ name }: { name: string }) {
const initials = name
.split(/\s+|\./)
.filter(Boolean)
.slice(0, 2)
.map((s) => s[0]?.toUpperCase() ?? '')
.join('');
return (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-xs font-semibold text-foreground">
{initials || '?'}
</div>
);
}
export interface TopBarProps {
onOpenCommand: () => void;
}
export function TopBar({ onOpenCommand }: TopBarProps) {
const { user, logout } = useAuth();
const navigate = useNavigate();
const handleLogout = async () => {
try {
await logout();
toast.success('Signed out');
navigate('/login', { replace: true });
} catch {
toast.error('Could not sign out');
}
};
return (
<header className="sticky top-0 z-30 flex h-13 items-center justify-between gap-3 border-b border-border bg-background/80 px-4 backdrop-blur">
<Breadcrumbs />
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={onOpenCommand}
className="h-8 gap-2 text-muted-foreground"
>
<Search className="h-4 w-4" />
<span className="hidden sm:inline">Search</span>
<kbd className="ml-2 hidden rounded bg-muted px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground sm:inline">
K
</kbd>
</Button>
{user && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="rounded-full">
<InitialsAvatar name={user.username} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium text-foreground">{user.username}</span>
<span className="text-xs text-muted-foreground">{user.email}</span>
<Badge variant="outline" className="mt-1 w-fit">
{user.role}
</Badge>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<LogOut className="h-4 w-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</header>
);
}
@@ -0,0 +1,196 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Archive, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
import {
Button,
Card,
CardContent,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Skeleton,
} from '@vector/ui';
import { createBin, deleteBin, listBins, updateBin } from '../../lib/api/bins.js';
import { ApiRequestError } from '../../lib/api/client.js';
import { queryKeys } from '../../lib/queryKeys.js';
import { NamePromptDialog } from '../NamePromptDialog.js';
import { ConfirmDialog } from '../ConfirmDialog.js';
import type { BinWithPath } from '../../lib/api/types.js';
interface BinGridProps {
roomId: string | null;
canEdit: boolean;
}
export function BinGrid({ roomId, canEdit }: BinGridProps) {
const queryClient = useQueryClient();
const [creating, setCreating] = useState(false);
const [renaming, setRenaming] = useState<BinWithPath | null>(null);
const [deleting, setDeleting] = useState<BinWithPath | null>(null);
const bins = useQuery({
queryKey: queryKeys.bins.list({ roomId, pageSize: 100 }),
queryFn: () => listBins({ roomId: roomId!, pageSize: 100 }),
enabled: Boolean(roomId),
});
const invalidate = () => queryClient.invalidateQueries({ queryKey: queryKeys.bins.all });
const createMutation = useMutation({
mutationFn: (name: string) => createBin({ name, roomId: roomId! }),
onSuccess: () => {
toast.success('Bin created');
invalidate();
setCreating(false);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Create failed'),
});
const renameMutation = useMutation({
mutationFn: (vars: { id: string; name: string }) => updateBin(vars.id, { name: vars.name }),
onSuccess: () => {
toast.success('Bin renamed');
invalidate();
setRenaming(null);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Rename failed'),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteBin(id),
onSuccess: () => {
toast.success('Bin deleted');
invalidate();
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
setDeleting(null);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed'),
});
if (!roomId) {
return (
<div className="flex h-full items-center justify-center p-8 text-sm text-muted-foreground">
Select a room to see its bins.
</div>
);
}
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between px-4 py-2">
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Bins
</h2>
{canEdit && (
<Button variant="outline" size="sm" onClick={() => setCreating(true)}>
<Plus className="h-4 w-4" />
New bin
</Button>
)}
</div>
<div className="flex-1 overflow-y-auto p-4 pt-0">
{bins.isPending ? (
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-20" />
))}
</div>
) : bins.isError ? (
<p className="text-sm text-destructive">Failed to load bins.</p>
) : bins.data && bins.data.data.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-10 text-muted-foreground">
<Archive className="h-6 w-6" />
<span className="text-sm">No bins in this room</span>
{canEdit && (
<Button variant="outline" size="sm" onClick={() => setCreating(true)}>
<Plus className="h-4 w-4" />
Create first bin
</Button>
)}
</div>
) : (
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-4">
{bins.data!.data.map((b) => (
<Card key={b.id} className="group relative">
<CardContent className="flex items-start gap-2 p-3">
<Archive className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-sm">{b.name}</p>
<p className="truncate font-mono text-[10px] text-muted-foreground">
{b.fullPath}
</p>
</div>
{canEdit && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 transition-opacity group-hover:opacity-100 data-[state=open]:opacity-100"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem onSelect={() => setRenaming(b)}>
Rename
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => setDeleting(b)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</CardContent>
</Card>
))}
</div>
)}
</div>
<NamePromptDialog
open={creating}
onOpenChange={setCreating}
title="New bin"
label="Bin name"
confirmLabel="Create"
pending={createMutation.isPending}
onSubmit={(name) => createMutation.mutate(name)}
/>
<NamePromptDialog
open={Boolean(renaming)}
onOpenChange={(o) => !o && setRenaming(null)}
title="Rename bin"
label="Bin name"
confirmLabel="Rename"
initialValue={renaming?.name ?? ''}
pending={renameMutation.isPending}
onSubmit={(name) => renaming && renameMutation.mutate({ id: renaming.id, name })}
/>
<ConfirmDialog
open={Boolean(deleting)}
onOpenChange={(o) => !o && setDeleting(null)}
title="Delete bin?"
description={
deleting
? `Remove ${deleting.name}. Parts in this bin become unassigned.`
: undefined
}
confirmLabel="Delete"
destructive
pending={deleteMutation.isPending}
onConfirm={() => deleting && deleteMutation.mutate(deleting.id)}
/>
</div>
);
}
@@ -0,0 +1,204 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { DoorOpen, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Skeleton,
cn,
} from '@vector/ui';
import { createRoom, deleteRoom, listRooms, updateRoom } from '../../lib/api/rooms.js';
import { ApiRequestError } from '../../lib/api/client.js';
import { queryKeys } from '../../lib/queryKeys.js';
import { NamePromptDialog } from '../NamePromptDialog.js';
import { ConfirmDialog } from '../ConfirmDialog.js';
import type { Room } from '../../lib/api/types.js';
interface RoomDrawerProps {
siteId: string | null;
selectedId: string | null;
onSelect: (id: string) => void;
canEdit: boolean;
}
export function RoomDrawer({ siteId, selectedId, onSelect, canEdit }: RoomDrawerProps) {
const queryClient = useQueryClient();
const [creating, setCreating] = useState(false);
const [renaming, setRenaming] = useState<Room | null>(null);
const [deleting, setDeleting] = useState<Room | null>(null);
const rooms = useQuery({
queryKey: queryKeys.rooms.list({ siteId, pageSize: 100 }),
queryFn: () => listRooms({ siteId: siteId!, pageSize: 100 }),
enabled: Boolean(siteId),
});
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: queryKeys.rooms.all });
const createMutation = useMutation({
mutationFn: (name: string) => createRoom({ name, siteId: siteId! }),
onSuccess: (r) => {
toast.success('Room created');
invalidate();
setCreating(false);
onSelect(r.id);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Create failed'),
});
const renameMutation = useMutation({
mutationFn: (vars: { id: string; name: string }) => updateRoom(vars.id, { name: vars.name }),
onSuccess: () => {
toast.success('Room renamed');
invalidate();
setRenaming(null);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Rename failed'),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteRoom(id),
onSuccess: (_, id) => {
toast.success('Room deleted');
invalidate();
queryClient.invalidateQueries({ queryKey: queryKeys.bins.all });
setDeleting(null);
if (selectedId === id) onSelect('');
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed'),
});
if (!siteId) {
return (
<div className="flex h-full items-center justify-center p-6 text-sm text-muted-foreground">
Select a site to see its rooms.
</div>
);
}
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between px-3 py-2">
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Rooms
</h2>
{canEdit && (
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setCreating(true)}>
<Plus className="h-4 w-4" />
</Button>
)}
</div>
<div className="flex-1 overflow-y-auto px-2 pb-2">
{rooms.isPending ? (
<div className="space-y-2 px-1">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
) : rooms.isError ? (
<p className="px-3 text-xs text-destructive">Failed to load rooms.</p>
) : rooms.data && rooms.data.data.length === 0 ? (
<div className="flex flex-col items-center gap-1 py-6 text-muted-foreground">
<DoorOpen className="h-5 w-5" />
<span className="text-xs">No rooms in this site</span>
</div>
) : (
<ul className="space-y-0.5">
{rooms.data!.data.map((r) => {
const active = r.id === selectedId;
return (
<li key={r.id}>
<div
className={cn(
'group flex items-center justify-between rounded-md px-2 py-1.5 text-sm',
active
? 'bg-accent text-accent-foreground'
: 'text-foreground hover:bg-accent/60',
)}
>
<button
type="button"
onClick={() => onSelect(r.id)}
className="flex flex-1 items-center gap-2 text-left"
>
<DoorOpen className="h-4 w-4 opacity-70" />
<span className="truncate">{r.name}</span>
</button>
{canEdit && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem onSelect={() => setRenaming(r)}>
Rename
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => setDeleting(r)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</li>
);
})}
</ul>
)}
</div>
<NamePromptDialog
open={creating}
onOpenChange={setCreating}
title="New room"
label="Room name"
confirmLabel="Create"
pending={createMutation.isPending}
onSubmit={(name) => createMutation.mutate(name)}
/>
<NamePromptDialog
open={Boolean(renaming)}
onOpenChange={(o) => !o && setRenaming(null)}
title="Rename room"
label="Room name"
confirmLabel="Rename"
initialValue={renaming?.name ?? ''}
pending={renameMutation.isPending}
onSubmit={(name) => renaming && renameMutation.mutate({ id: renaming.id, name })}
/>
<ConfirmDialog
open={Boolean(deleting)}
onOpenChange={(o) => !o && setDeleting(null)}
title="Delete room?"
description={
deleting
? `Remove ${deleting.name}. All bins inside will be deleted too. Parts in those bins become unassigned.`
: undefined
}
confirmLabel="Delete"
destructive
pending={deleteMutation.isPending}
onConfirm={() => deleting && deleteMutation.mutate(deleting.id)}
/>
</div>
);
}
@@ -0,0 +1,195 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Building2, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Skeleton,
cn,
} from '@vector/ui';
import { createSite, deleteSite, listSites, updateSite } from '../../lib/api/sites.js';
import { ApiRequestError } from '../../lib/api/client.js';
import { queryKeys } from '../../lib/queryKeys.js';
import { NamePromptDialog } from '../NamePromptDialog.js';
import { ConfirmDialog } from '../ConfirmDialog.js';
import type { Site } from '../../lib/api/types.js';
interface SiteListProps {
selectedId: string | null;
onSelect: (id: string) => void;
canEdit: boolean;
}
export function SiteList({ selectedId, onSelect, canEdit }: SiteListProps) {
const queryClient = useQueryClient();
const [creating, setCreating] = useState(false);
const [renaming, setRenaming] = useState<Site | null>(null);
const [deleting, setDeleting] = useState<Site | null>(null);
const sites = useQuery({
queryKey: queryKeys.sites.list({ pageSize: 100 }),
queryFn: () => listSites({ pageSize: 100 }),
});
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: queryKeys.sites.all });
const createMutation = useMutation({
mutationFn: (name: string) => createSite({ name }),
onSuccess: (s) => {
toast.success('Site created');
invalidate();
setCreating(false);
onSelect(s.id);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Create failed'),
});
const renameMutation = useMutation({
mutationFn: (vars: { id: string; name: string }) => updateSite(vars.id, { name: vars.name }),
onSuccess: () => {
toast.success('Site renamed');
invalidate();
setRenaming(null);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Rename failed'),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteSite(id),
onSuccess: (_, id) => {
toast.success('Site deleted');
invalidate();
queryClient.invalidateQueries({ queryKey: queryKeys.rooms.all });
queryClient.invalidateQueries({ queryKey: queryKeys.bins.all });
setDeleting(null);
if (selectedId === id) onSelect('');
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed'),
});
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between px-3 py-2">
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Sites
</h2>
{canEdit && (
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setCreating(true)}>
<Plus className="h-4 w-4" />
</Button>
)}
</div>
<div className="flex-1 overflow-y-auto px-2 pb-2">
{sites.isPending ? (
<div className="space-y-2 px-1">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
) : sites.isError ? (
<p className="px-3 text-xs text-destructive">Failed to load sites.</p>
) : sites.data && sites.data.data.length === 0 ? (
<div className="flex flex-col items-center gap-1 py-6 text-muted-foreground">
<Building2 className="h-5 w-5" />
<span className="text-xs">No sites yet</span>
</div>
) : (
<ul className="space-y-0.5">
{sites.data!.data.map((s) => {
const active = s.id === selectedId;
return (
<li key={s.id}>
<div
className={cn(
'group flex items-center justify-between rounded-md px-2 py-1.5 text-sm',
active
? 'bg-accent text-accent-foreground'
: 'text-foreground hover:bg-accent/60',
)}
>
<button
type="button"
onClick={() => onSelect(s.id)}
className="flex flex-1 items-center gap-2 text-left"
>
<Building2 className="h-4 w-4 opacity-70" />
<span className="truncate">{s.name}</span>
</button>
{canEdit && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem onSelect={() => setRenaming(s)}>
Rename
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => setDeleting(s)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</li>
);
})}
</ul>
)}
</div>
<NamePromptDialog
open={creating}
onOpenChange={setCreating}
title="New site"
label="Site name"
confirmLabel="Create"
pending={createMutation.isPending}
onSubmit={(name) => createMutation.mutate(name)}
/>
<NamePromptDialog
open={Boolean(renaming)}
onOpenChange={(o) => !o && setRenaming(null)}
title="Rename site"
label="Site name"
confirmLabel="Rename"
initialValue={renaming?.name ?? ''}
pending={renameMutation.isPending}
onSubmit={(name) => renaming && renameMutation.mutate({ id: renaming.id, name })}
/>
<ConfirmDialog
open={Boolean(deleting)}
onOpenChange={(o) => !o && setDeleting(null)}
title="Delete site?"
description={
deleting
? `Remove ${deleting.name}. All rooms and bins inside will be deleted too. Parts in those bins become unassigned.`
: undefined
}
confirmLabel="Delete"
destructive
pending={deleteMutation.isPending}
onConfirm={() => deleting && deleteMutation.mutate(deleting.id)}
/>
</div>
);
}
@@ -0,0 +1,157 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, 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,
Input,
} from '@vector/ui';
import {
createManufacturer,
updateManufacturer,
} from '../../lib/api/manufacturers.js';
import { ApiRequestError } from '../../lib/api/client.js';
import { queryKeys } from '../../lib/queryKeys.js';
import type { Manufacturer } from '../../lib/api/types.js';
const Schema = z.object({
name: z.string().min(1, 'Required').max(128),
eolDate: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, 'YYYY-MM-DD')
.or(z.literal(''))
.optional(),
});
type Values = z.infer<typeof Schema>;
interface ManufacturerFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
manufacturer?: Manufacturer | null;
}
function isoToDateInput(iso: string | null): string {
if (!iso) return '';
return new Date(iso).toISOString().slice(0, 10);
}
export function ManufacturerFormDialog({
open,
onOpenChange,
manufacturer,
}: ManufacturerFormDialogProps) {
const editing = Boolean(manufacturer);
const queryClient = useQueryClient();
const form = useForm<Values>({
resolver: zodResolver(Schema),
defaultValues: { name: '', eolDate: '' },
});
useEffect(() => {
if (!open) return;
form.reset({
name: manufacturer?.name ?? '',
eolDate: isoToDateInput(manufacturer?.eolDate ?? null),
});
}, [open, manufacturer, form]);
const mutation = useMutation({
mutationFn: async (values: Values) => {
const payload = {
name: values.name,
eolDate: values.eolDate ? values.eolDate : null,
};
return editing && manufacturer
? updateManufacturer(manufacturer.id, payload)
: createManufacturer(payload);
},
onSuccess: () => {
toast.success(editing ? 'Manufacturer updated' : 'Manufacturer created');
queryClient.invalidateQueries({ queryKey: queryKeys.manufacturers.all });
onOpenChange(false);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Save failed'),
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{editing ? 'Edit manufacturer' : 'New manufacturer'}</DialogTitle>
<DialogDescription>
{editing
? 'Update this manufacturer. EOL drives replacement alerts on parts.'
: 'Add a manufacturer. Names must be unique.'}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit((v) => mutation.mutate(v))} className="space-y-3">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input autoFocus {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="eolDate"
render={({ field }) => (
<FormItem>
<FormLabel>End-of-life date</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormDescription>
Optional. Parts from this manufacturer will show a replacement alert past this
date.
</FormDescription>
<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'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,210 @@
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Loader2 } from 'lucide-react';
import type { PartState } from '@vector/shared';
import {
Button,
Checkbox,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@vector/ui';
import { bulkUpdateParts } from '../../lib/api/bulk-parts.js';
import { ApiRequestError } from '../../lib/api/client.js';
import { queryKeys } from '../../lib/queryKeys.js';
import { listTags } from '../../lib/api/tags.js';
import { partStateOptions } from './PartStateBadge.js';
interface PartBulkStateDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
partIds: string[];
onDone: () => void;
}
const UNSET = '__unset__';
export function PartBulkStateDialog({
open,
onOpenChange,
partIds,
onDone,
}: PartBulkStateDialogProps) {
const [state, setState] = useState<string>(UNSET);
const [addTagIds, setAddTagIds] = useState<Set<string>>(new Set());
const [removeTagIds, setRemoveTagIds] = useState<Set<string>>(new Set());
const queryClient = useQueryClient();
const tagsQuery = useQuery({
queryKey: queryKeys.tags.list({ pageSize: 200 }),
queryFn: () => listTags({ pageSize: 200 }),
enabled: open,
});
useEffect(() => {
if (!open) {
setState(UNSET);
setAddTagIds(new Set());
setRemoveTagIds(new Set());
}
}, [open]);
const mutation = useMutation({
mutationFn: async () => {
return bulkUpdateParts({
ids: partIds,
state: state !== UNSET ? (state as PartState) : undefined,
addTagIds: addTagIds.size > 0 ? [...addTagIds] : undefined,
removeTagIds: removeTagIds.size > 0 ? [...removeTagIds] : undefined,
});
},
onSuccess: (res) => {
toast.success(
`Updated ${res.updated} part${res.updated === 1 ? '' : 's'}`,
);
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
onDone();
onOpenChange(false);
},
onError: (err) => {
toast.error(
err instanceof ApiRequestError ? err.body.message : 'Bulk update failed',
);
},
});
const toggleAdd = (id: string) => {
setAddTagIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else {
next.add(id);
setRemoveTagIds((r) => {
const rnext = new Set(r);
rnext.delete(id);
return rnext;
});
}
return next;
});
};
const toggleRemove = (id: string) => {
setRemoveTagIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else {
next.add(id);
setAddTagIds((a) => {
const anext = new Set(a);
anext.delete(id);
return anext;
});
}
return next;
});
};
const nothingToDo =
state === UNSET && addTagIds.size === 0 && removeTagIds.size === 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Bulk edit parts</DialogTitle>
<DialogDescription>
Update {partIds.length} selected part{partIds.length === 1 ? '' : 's'}.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>State</Label>
<Select value={state} onValueChange={setState}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNSET}>Leave unchanged</SelectItem>
{partStateOptions.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Tags</Label>
{tagsQuery.isPending ? (
<p className="text-xs text-muted-foreground">Loading tags</p>
) : !tagsQuery.data || tagsQuery.data.data.length === 0 ? (
<p className="text-xs text-muted-foreground">No tags defined yet.</p>
) : (
<div className="max-h-48 space-y-1 overflow-y-auto rounded-md border border-border p-2">
{tagsQuery.data.data.map((tag) => (
<div
key={tag.id}
className="flex items-center justify-between gap-2 text-sm"
>
<span className="flex items-center gap-2">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: tag.color ?? 'currentColor' }}
/>
{tag.name}
</span>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<label className="flex items-center gap-1">
<Checkbox
checked={addTagIds.has(tag.id)}
onCheckedChange={() => toggleAdd(tag.id)}
/>
Add
</label>
<label className="flex items-center gap-1">
<Checkbox
checked={removeTagIds.has(tag.id)}
onCheckedChange={() => toggleRemove(tag.id)}
/>
Remove
</label>
</div>
</div>
))}
</div>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={mutation.isPending}
>
Cancel
</Button>
<Button
onClick={() => mutation.mutate()}
disabled={mutation.isPending || nothingToDo}
>
{mutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Apply
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,141 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
ArrowRight,
CheckCircle2,
MapPin,
Package,
Pencil,
Tag,
Wrench,
type LucideIcon,
} from 'lucide-react';
import type { PartEventType } from '@vector/shared';
import { Button, Skeleton } from '@vector/ui';
import { listPartEvents } from '../../lib/api/parts.js';
import { queryKeys } from '../../lib/queryKeys.js';
const EVENT_ICON: Record<PartEventType, LucideIcon> = {
CREATED: Package,
STATE_CHANGED: CheckCircle2,
LOCATION_CHANGED: MapPin,
FIELD_UPDATED: Pencil,
REPAIR_STARTED: Wrench,
REPAIR_COMPLETED: Wrench,
TAG_ADDED: Tag,
TAG_REMOVED: Tag,
};
const EVENT_TITLE: Record<PartEventType, string> = {
CREATED: 'Created',
STATE_CHANGED: 'State changed',
LOCATION_CHANGED: 'Location changed',
FIELD_UPDATED: 'Field updated',
REPAIR_STARTED: 'Repair started',
REPAIR_COMPLETED: 'Repair completed',
TAG_ADDED: 'Tag added',
TAG_REMOVED: 'Tag removed',
};
function formatWhen(iso: string) {
const d = new Date(iso);
return d.toLocaleString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
export function PartEventTimeline({ partId }: { partId: string }) {
const [page, setPage] = useState(1);
const pageSize = 20;
const query = useQuery({
queryKey: queryKeys.parts.events(partId, { page, pageSize }),
queryFn: () => listPartEvents(partId, page, pageSize),
placeholderData: (prev) => prev,
});
if (query.isPending) {
return (
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
);
}
if (query.isError) {
return <p className="text-sm text-destructive">Could not load history.</p>;
}
const events = query.data?.data ?? [];
const total = query.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pageSize));
if (events.length === 0) {
return <p className="text-sm text-muted-foreground">No activity yet.</p>;
}
return (
<div className="space-y-1">
<ol className="relative ml-3 border-l border-border">
{events.map((e) => {
const Icon = EVENT_ICON[e.type];
return (
<li key={e.id} className="relative pl-6 pb-4 last:pb-0">
<span className="absolute -left-[11px] top-0 flex h-5 w-5 items-center justify-center rounded-full border border-border bg-background">
<Icon className="h-3 w-3 text-muted-foreground" />
</span>
<div className="flex flex-wrap items-center gap-x-2 text-sm">
<span className="font-medium text-foreground">{EVENT_TITLE[e.type]}</span>
{e.field && (
<span className="text-xs text-muted-foreground">· {e.field}</span>
)}
{(e.oldValue || e.newValue) && (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<span className="font-mono">{e.oldValue ?? '—'}</span>
<ArrowRight className="h-3 w-3" />
<span className="font-mono text-foreground">{e.newValue ?? '—'}</span>
</span>
)}
</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{formatWhen(e.createdAt)}
{e.user?.username ? ` · ${e.user.username}` : ''}
</div>
</li>
);
})}
</ol>
{pageCount > 1 && (
<div className="flex items-center justify-end gap-2 pt-2 text-xs text-muted-foreground">
<span>
Page {page} of {pageCount}
</span>
<Button
variant="ghost"
size="sm"
className="h-7"
disabled={page <= 1 || query.isFetching}
onClick={() => setPage(page - 1)}
>
Previous
</Button>
<Button
variant="ghost"
size="sm"
className="h-7"
disabled={page >= pageCount || query.isFetching}
onClick={() => setPage(page + 1)}
>
Next
</Button>
</div>
)}
</div>
);
}
@@ -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>
);
}
@@ -0,0 +1,74 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Plus } from 'lucide-react';
import { Button, Skeleton } from '@vector/ui';
import { listRepairsForPart } from '../../lib/api/repairs.js';
import { queryKeys } from '../../lib/queryKeys.js';
import { RepairStatusBadge } from '../repairs/RepairStatusBadge.js';
import { RepairFormDialog } from '../repairs/RepairFormDialog.js';
import type { RepairJob } from '../../lib/api/types.js';
interface PartRepairSectionProps {
partId: string;
}
export function PartRepairSection({ partId }: PartRepairSectionProps) {
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<RepairJob | null>(null);
const query = useQuery({
queryKey: [...queryKeys.parts.detail(partId), 'repairs'],
queryFn: () => listRepairsForPart(partId),
});
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">Repair history</p>
<Button variant="outline" size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-3.5 w-3.5" />
Open repair
</Button>
</div>
{query.isPending ? (
<Skeleton className="h-16 w-full" />
) : !query.data || query.data.length === 0 ? (
<p className="text-xs text-muted-foreground">No repair jobs yet.</p>
) : (
<ul className="divide-y divide-border rounded-md border border-border text-sm">
{query.data.map((repair) => (
<li
key={repair.id}
className="flex items-center justify-between gap-3 px-3 py-2"
>
<div className="flex items-center gap-2">
<RepairStatusBadge status={repair.status} />
<span className="text-xs text-muted-foreground">
Opened {new Date(repair.openedAt).toLocaleDateString()}
{repair.host ? ` · ${repair.host.name}` : ''}
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => setEditing(repair)}
>
Edit
</Button>
</li>
))}
</ul>
)}
<RepairFormDialog
open={createOpen}
onOpenChange={setCreateOpen}
defaultPartId={partId}
/>
<RepairFormDialog
open={Boolean(editing)}
onOpenChange={(o) => !o && setEditing(null)}
repair={editing}
/>
</div>
);
}
@@ -0,0 +1,27 @@
import type { PartState } from '@vector/shared';
import { Badge, type BadgeProps } from '@vector/ui';
const STATE_LABEL: Record<PartState, string> = {
SPARE: 'Spare',
DEPLOYED: 'Deployed',
BROKEN: 'Broken',
PENDING_DESTRUCTION: 'Pending destruction',
};
const STATE_VARIANT: Record<PartState, BadgeProps['variant']> = {
SPARE: 'secondary',
DEPLOYED: 'success',
BROKEN: 'warning',
PENDING_DESTRUCTION: 'destructive',
};
export function PartStateBadge({ state }: { state: PartState }) {
return <Badge variant={STATE_VARIANT[state]}>{STATE_LABEL[state]}</Badge>;
}
export const partStateOptions: { value: PartState; label: string }[] = [
{ value: 'SPARE', label: 'Spare' },
{ value: 'DEPLOYED', label: 'Deployed' },
{ value: 'BROKEN', label: 'Broken' },
{ value: 'PENDING_DESTRUCTION', label: 'Pending destruction' },
];
@@ -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>;
}
+189
View File
@@ -0,0 +1,189 @@
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Check, Plus, X } from 'lucide-react';
import { toast } from 'sonner';
import {
Badge,
Button,
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
Popover,
PopoverContent,
PopoverTrigger,
cn,
} from '@vector/ui';
import {
assignTagsToPart,
createTag,
listTags,
listTagsForPart,
unassignTagFromPart,
} from '../../lib/api/tags.js';
import { ApiRequestError } from '../../lib/api/client.js';
import { queryKeys } from '../../lib/queryKeys.js';
import type { Tag } from '../../lib/api/types.js';
interface TagPickerProps {
partId: string;
}
function TagChip({ tag, onRemove }: { tag: Tag; onRemove?: () => void }) {
return (
<Badge
variant="outline"
className="gap-1.5"
style={tag.color ? { borderColor: tag.color, color: tag.color } : undefined}
>
<span
className="h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: tag.color ?? 'currentColor' }}
/>
{tag.name}
{onRemove && (
<button
type="button"
onClick={onRemove}
className="ml-0.5 rounded hover:bg-accent"
aria-label={`Remove tag ${tag.name}`}
>
<X className="h-3 w-3" />
</button>
)}
</Badge>
);
}
export function TagPicker({ partId }: TagPickerProps) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const assignedQuery = useQuery({
queryKey: [...queryKeys.parts.detail(partId), 'tags'],
queryFn: () => listTagsForPart(partId),
});
const allQuery = useQuery({
queryKey: queryKeys.tags.list({ pageSize: 200 }),
queryFn: () => listTags({ pageSize: 200 }),
});
const assignedIds = useMemo(
() => new Set((assignedQuery.data ?? []).map((t) => t.id)),
[assignedQuery.data],
);
const available = useMemo(
() => (allQuery.data?.data ?? []).filter((t) => !assignedIds.has(t.id)),
[allQuery.data, assignedIds],
);
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: [...queryKeys.parts.detail(partId), 'tags'] });
queryClient.invalidateQueries({ queryKey: queryKeys.parts.detail(partId) });
};
const assignMutation = useMutation({
mutationFn: (tagId: string) => assignTagsToPart(partId, [tagId]),
onSuccess: () => invalidate(),
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Tag assign failed'),
});
const removeMutation = useMutation({
mutationFn: (tagId: string) => unassignTagFromPart(partId, tagId),
onSuccess: () => invalidate(),
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Tag remove failed'),
});
const createMutation = useMutation({
mutationFn: async (name: string) => {
const tag = await createTag({ name });
await assignTagsToPart(partId, [tag.id]);
return tag;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.tags.all });
invalidate();
setSearch('');
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Tag create failed'),
});
const hasExactMatch = available.some(
(t) => t.name.toLowerCase() === search.trim().toLowerCase(),
);
const canCreate = search.trim().length > 0 && !hasExactMatch;
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-1.5">
{(assignedQuery.data ?? []).map((tag) => (
<TagChip
key={tag.id}
tag={tag}
onRemove={() => removeMutation.mutate(tag.id)}
/>
))}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-6 gap-1 px-2 text-xs">
<Plus className="h-3 w-3" />
Add tag
</Button>
</PopoverTrigger>
<PopoverContent className="w-60 p-0" align="start">
<Command>
<CommandInput
placeholder="Search or create…"
value={search}
onValueChange={setSearch}
/>
<CommandList>
<CommandEmpty>No tags found.</CommandEmpty>
<CommandGroup>
{available.map((tag) => (
<CommandItem
key={tag.id}
value={tag.name}
onSelect={() => {
assignMutation.mutate(tag.id);
setOpen(false);
}}
>
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: tag.color ?? 'currentColor' }}
/>
<span>{tag.name}</span>
<Check
className={cn(
'ml-auto h-4 w-4 opacity-0',
assignedIds.has(tag.id) && 'opacity-100',
)}
/>
</CommandItem>
))}
{canCreate && (
<CommandItem
value={`__create__${search}`}
onSelect={() => {
createMutation.mutate(search.trim());
setOpen(false);
}}
>
<Plus className="h-3.5 w-3.5" />
Create "{search.trim()}"
</CommandItem>
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
);
}
@@ -0,0 +1,194 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { z } from 'zod';
import { Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Role } from '@vector/shared';
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@vector/ui';
import { createUser, updateUser } from '../../lib/api/users.js';
import { ApiRequestError } from '../../lib/api/client.js';
import { queryKeys } from '../../lib/queryKeys.js';
import type { User } from '../../lib/api/types.js';
const makeSchema = (editing: boolean) =>
z.object({
username: z.string().min(1, 'Required').max(64),
email: z.string().email('Valid email required').max(256),
password: editing
? z.string().min(6, 'At least 6 characters').or(z.literal(''))
: z.string().min(6, 'At least 6 characters'),
role: Role,
});
type Values = z.infer<ReturnType<typeof makeSchema>>;
interface UserFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
user?: User | null;
}
export function UserFormDialog({ open, onOpenChange, user }: UserFormDialogProps) {
const editing = Boolean(user);
const queryClient = useQueryClient();
const form = useForm<Values>({
resolver: zodResolver(makeSchema(editing)),
defaultValues: { username: '', email: '', password: '', role: 'TECHNICIAN' },
});
useEffect(() => {
if (!open) return;
form.reset({
username: user?.username ?? '',
email: user?.email ?? '',
password: '',
role: user?.role ?? 'TECHNICIAN',
});
}, [open, user, form]);
const mutation = useMutation({
mutationFn: async (values: Values) => {
if (editing && user) {
const payload: Partial<Values> = {
username: values.username,
email: values.email,
role: values.role,
};
if (values.password) payload.password = values.password;
return updateUser(user.id, payload);
}
return createUser({
username: values.username,
email: values.email,
password: values.password,
role: values.role,
});
},
onSuccess: () => {
toast.success(editing ? 'User updated' : 'User created');
queryClient.invalidateQueries({ queryKey: queryKeys.users.all });
onOpenChange(false);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Save failed'),
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{editing ? 'Edit user' : 'New user'}</DialogTitle>
<DialogDescription>
{editing ? 'Update account details. Leave password blank to keep the current one.' : 'Create a new account.'}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit((v) => mutation.mutate(v))} className="space-y-3">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input autoFocus autoComplete="off" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" autoComplete="off" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" autoComplete="new-password" {...field} />
</FormControl>
{editing && (
<FormDescription>Leave blank to keep the existing password.</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="TECHNICIAN">Technician</SelectItem>
<SelectItem value="ADMIN">Admin</SelectItem>
</SelectContent>
</Select>
<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 user'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,188 @@
import { useEffect } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { z } from 'zod';
import { Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { WebhookEventName } from '@vector/shared';
import {
Button,
Checkbox,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Label,
} from '@vector/ui';
import { createWebhook, updateWebhook } from '../../lib/api/webhooks.js';
import type { WebhookSubscription } from '../../lib/api/webhooks.js';
import { ApiRequestError } from '../../lib/api/client.js';
import { queryKeys } from '../../lib/queryKeys.js';
const Schema = z.object({
url: z.string().url('Must be a valid URL').max(2048),
events: z.array(WebhookEventName).min(1, 'Select at least one event'),
active: z.boolean(),
});
type Values = z.infer<typeof Schema>;
const ALL_EVENTS = WebhookEventName.options;
interface WebhookFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
subscription?: WebhookSubscription | null;
onCreated?: (created: WebhookSubscription) => void;
}
export function WebhookFormDialog({
open,
onOpenChange,
subscription,
onCreated,
}: WebhookFormDialogProps) {
const editing = Boolean(subscription);
const queryClient = useQueryClient();
const form = useForm<Values>({
resolver: zodResolver(Schema),
defaultValues: { url: '', events: [], active: true },
});
useEffect(() => {
if (!open) return;
form.reset({
url: subscription?.url ?? '',
events: subscription?.events ?? [],
active: subscription?.active ?? true,
});
}, [open, subscription, form]);
const mutation = useMutation({
mutationFn: async (values: Values) => {
if (editing && subscription) {
return updateWebhook(subscription.id, values);
}
return createWebhook(values);
},
onSuccess: (result) => {
toast.success(editing ? 'Subscription updated' : 'Subscription created');
queryClient.invalidateQueries({ queryKey: queryKeys.webhooks.all });
onOpenChange(false);
if (!editing && result.secret) onCreated?.(result);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Save failed'),
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{editing ? 'Edit subscription' : 'New subscription'}</DialogTitle>
<DialogDescription>
Vector signs each delivery with HMAC-SHA256. The signing secret is shown once on create.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit((v) => mutation.mutate(v))} className="space-y-3">
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>Endpoint URL</FormLabel>
<FormControl>
<Input
autoFocus
placeholder="https://receiver.example.com/vector"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Controller
control={form.control}
name="events"
render={({ field, fieldState }) => (
<FormItem>
<FormLabel>Events</FormLabel>
<div className="grid grid-cols-2 gap-x-4 gap-y-2 rounded-md border border-border p-3">
{ALL_EVENTS.map((event) => {
const checked = field.value.includes(event);
const id = `event-${event}`;
return (
<div key={event} className="flex items-center gap-2">
<Checkbox
id={id}
checked={checked}
onCheckedChange={(v) => {
if (v) field.onChange([...field.value, event]);
else field.onChange(field.value.filter((e) => e !== event));
}}
/>
<Label htmlFor={id} className="cursor-pointer font-mono text-xs">
{event}
</Label>
</div>
);
})}
</div>
{fieldState.error && (
<p className="text-xs text-destructive">{fieldState.error.message}</p>
)}
</FormItem>
)}
/>
<Controller
control={form.control}
name="active"
render={({ field }) => (
<div className="flex items-center gap-2">
<Checkbox
id="active"
checked={field.value}
onCheckedChange={(v) => field.onChange(Boolean(v))}
/>
<Label htmlFor="active" className="cursor-pointer text-sm">
Active deliver events to this endpoint
</Label>
</div>
)}
/>
<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'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,62 @@
import { Copy, KeyRound } from 'lucide-react';
import { toast } from 'sonner';
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@vector/ui';
interface WebhookSecretDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
secret: string | null;
title?: string;
}
export function WebhookSecretDialog({
open,
onOpenChange,
secret,
title = 'Signing secret',
}: WebhookSecretDialogProps) {
const copy = async () => {
if (!secret) return;
try {
await navigator.clipboard.writeText(secret);
toast.success('Secret copied');
} catch {
toast.error('Copy failed — select and copy manually');
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<KeyRound className="h-4 w-4" />
{title}
</DialogTitle>
<DialogDescription>
Store this secret now it will not be shown again. Receivers use it to verify the
<code className="mx-1 rounded bg-muted px-1 text-xs">x-vector-signature</code> header.
</DialogDescription>
</DialogHeader>
<div className="rounded-md border border-border bg-muted/40 p-3">
<code className="break-all font-mono text-xs">{secret ?? ''}</code>
</div>
<DialogFooter>
<Button variant="outline" onClick={copy}>
<Copy className="h-4 w-4" />
Copy
</Button>
<Button onClick={() => onOpenChange(false)}>Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}