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,86 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { NuqsAdapter } from 'nuqs/adapters/react-router/v7';
|
||||
import { TooltipProvider, Toaster } from '@vector/ui';
|
||||
import { AuthProvider } from './contexts/AuthContext.js';
|
||||
import { RequireAuth } from './components/auth/RequireAuth.js';
|
||||
import { AppShell } from './components/layout/AppShell.js';
|
||||
import { ErrorBoundary } from './components/layout/ErrorBoundary.js';
|
||||
import Login from './pages/Login.js';
|
||||
import Dashboard from './pages/Dashboard.js';
|
||||
import Parts from './pages/Parts.js';
|
||||
import PartDetail from './pages/PartDetail.js';
|
||||
import Locations from './pages/Locations.js';
|
||||
import Manufacturers from './pages/Manufacturers.js';
|
||||
import Repairs from './pages/Repairs.js';
|
||||
import Hosts from './pages/Hosts.js';
|
||||
import Users from './pages/admin/Users.js';
|
||||
import Webhooks from './pages/admin/Webhooks.js';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: (failureCount, error) => {
|
||||
// Don't retry auth failures — the refresh interceptor handles those once already.
|
||||
const status = (error as { status?: number })?.status;
|
||||
if (status === 401 || status === 403) return false;
|
||||
return failureCount < 2;
|
||||
},
|
||||
staleTime: 10_000,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<NuqsAdapter>
|
||||
<AuthProvider>
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
element={
|
||||
<RequireAuth>
|
||||
<AppShell />
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/parts" element={<Parts />} />
|
||||
<Route path="/parts/:id" element={<PartDetail />} />
|
||||
<Route path="/locations" element={<Locations />} />
|
||||
<Route path="/manufacturers" element={<Manufacturers />} />
|
||||
<Route path="/repairs" element={<Repairs />} />
|
||||
<Route path="/hosts" element={<Hosts />} />
|
||||
<Route
|
||||
path="/admin/users"
|
||||
element={
|
||||
<RequireAuth role="ADMIN">
|
||||
<Users />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/webhooks"
|
||||
element={
|
||||
<RequireAuth role="ADMIN">
|
||||
<Webhooks />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
<Toaster position="bottom-right" />
|
||||
</TooltipProvider>
|
||||
</AuthProvider>
|
||||
</NuqsAdapter>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { login as apiLogin, logout as apiLogout, me } from '../lib/api/auth.js';
|
||||
import type { AuthUser } from '../lib/api/auth.js';
|
||||
import { ApiRequestError } from '../lib/api/client.js';
|
||||
|
||||
interface AuthContextValue {
|
||||
user: AuthUser | null;
|
||||
status: 'loading' | 'authenticated' | 'anonymous';
|
||||
login: (username: string, password: string) => Promise<AuthUser>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [status, setStatus] = useState<AuthContextValue['status']>('loading');
|
||||
const qc = useQueryClient();
|
||||
|
||||
// Bootstrap: try /me once. If the refresh interceptor revives a dead access token, this
|
||||
// round-trips once; on a hard 401 the server returns anonymous and we show the Login page.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const u = await me();
|
||||
if (!cancelled) {
|
||||
setUser(u);
|
||||
setStatus('authenticated');
|
||||
}
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
if (err instanceof ApiRequestError && err.status === 401) {
|
||||
setStatus('anonymous');
|
||||
} else {
|
||||
setStatus('anonymous');
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const login: AuthContextValue['login'] = async (username, password) => {
|
||||
const u = await apiLogin(username, password);
|
||||
setUser(u);
|
||||
setStatus('authenticated');
|
||||
return u;
|
||||
};
|
||||
|
||||
const logout: AuthContextValue['logout'] = async () => {
|
||||
try {
|
||||
await apiLogout();
|
||||
} catch {
|
||||
// Ignore — tokens are best-effort-revoked server side.
|
||||
}
|
||||
setUser(null);
|
||||
setStatus('anonymous');
|
||||
qc.clear();
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, status, login, logout }}>{children}</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be used within <AuthProvider>');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
@import 'tailwindcss';
|
||||
@import '@vector/config/tailwind/tokens.css';
|
||||
|
||||
@source "../../../packages/ui/src/**/*.{ts,tsx}";
|
||||
@source "./**/*.{ts,tsx}";
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { DashboardAnalytics } from '@vector/shared';
|
||||
import { api } from './client.js';
|
||||
|
||||
export async function getDashboardAnalytics(): Promise<DashboardAnalytics> {
|
||||
const res = await api.get<DashboardAnalytics>('/analytics/dashboard');
|
||||
return res.data;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { api } from './client.js';
|
||||
import type { Role } from '@vector/shared';
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
role: Role;
|
||||
}
|
||||
|
||||
export async function login(username: string, password: string): Promise<AuthUser> {
|
||||
const res = await api.post<AuthUser>('/auth/login', { username, password });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await api.post('/auth/logout');
|
||||
}
|
||||
|
||||
export async function refresh(): Promise<void> {
|
||||
await api.post('/auth/refresh');
|
||||
}
|
||||
|
||||
export async function me(): Promise<AuthUser> {
|
||||
const res = await api.get<AuthUser>('/auth/me');
|
||||
return res.data;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { CreateBinRequest, UpdateBinRequest } from '@vector/shared';
|
||||
import { api } from './client.js';
|
||||
import { getList } from './paginated.js';
|
||||
import type { Bin, BinWithPath } from './types.js';
|
||||
|
||||
export function listBins(
|
||||
filters: { page?: number; pageSize?: number; roomId?: string; siteId?: string } = {},
|
||||
) {
|
||||
return getList<BinWithPath>('/bins', filters);
|
||||
}
|
||||
|
||||
export async function createBin(input: CreateBinRequest): Promise<BinWithPath> {
|
||||
const res = await api.post<BinWithPath>('/bins', input);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function updateBin(id: string, input: UpdateBinRequest): Promise<Bin> {
|
||||
const res = await api.patch<Bin>(`/bins/${id}`, input);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteBin(id: string): Promise<void> {
|
||||
await api.delete(`/bins/${id}`);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { BulkPartsRequest } from '@vector/shared';
|
||||
import { api } from './client.js';
|
||||
|
||||
export interface BulkPartsResult {
|
||||
updated: number;
|
||||
}
|
||||
|
||||
export async function bulkUpdateParts(input: BulkPartsRequest): Promise<BulkPartsResult> {
|
||||
const res = await api.post<BulkPartsResult>('/parts/bulk', input);
|
||||
return res.data;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { CreateCategoryRequest, UpdateCategoryRequest } from '@vector/shared';
|
||||
import { api } from './client.js';
|
||||
import { getList } from './paginated.js';
|
||||
import type { Category } from './types.js';
|
||||
|
||||
export function listCategories(filters: { page?: number; pageSize?: number } = {}) {
|
||||
return getList<Category>('/categories', filters);
|
||||
}
|
||||
|
||||
export async function createCategory(input: CreateCategoryRequest): Promise<Category> {
|
||||
const res = await api.post<Category>('/categories', input);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function updateCategory(
|
||||
id: string,
|
||||
input: UpdateCategoryRequest,
|
||||
): Promise<Category> {
|
||||
const res = await api.patch<Category>(`/categories/${id}`, input);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteCategory(id: string): Promise<void> {
|
||||
await api.delete(`/categories/${id}`);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import axios, { AxiosError, AxiosHeaders, type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios';
|
||||
|
||||
// Read a non-httpOnly cookie by name. The API sets `csrf` path=/ so we can always grab it.
|
||||
function readCookie(name: string): string | null {
|
||||
const prefix = `${name}=`;
|
||||
for (const part of document.cookie.split(';')) {
|
||||
const v = part.trim();
|
||||
if (v.startsWith(prefix)) return decodeURIComponent(v.slice(prefix.length));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
code: string;
|
||||
message: string;
|
||||
requestId?: string;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
export class ApiRequestError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
public readonly body: ApiError,
|
||||
) {
|
||||
super(body.message);
|
||||
this.name = 'ApiRequestError';
|
||||
}
|
||||
}
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: '/api',
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
// Attach CSRF token to mutating requests. GET/HEAD/OPTIONS skip this.
|
||||
api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
const method = (config.method ?? 'get').toLowerCase();
|
||||
if (!['get', 'head', 'options'].includes(method)) {
|
||||
const token = readCookie('csrf');
|
||||
if (token) {
|
||||
const headers = config.headers ?? new AxiosHeaders();
|
||||
if (headers instanceof AxiosHeaders) headers.set('X-CSRF-Token', token);
|
||||
else (headers as Record<string, string>)['X-CSRF-Token'] = token;
|
||||
config.headers = headers;
|
||||
}
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Refresh-token rotation. If a protected call returns 401, attempt one refresh, then retry.
|
||||
// A single in-flight refresh is shared across concurrent 401s to avoid thrashing the server.
|
||||
let refreshInflight: Promise<void> | null = null;
|
||||
const AUTH_UNPROTECTED = ['/auth/login', '/auth/refresh', '/auth/logout'];
|
||||
|
||||
function refreshOnce(): Promise<void> {
|
||||
if (!refreshInflight) {
|
||||
refreshInflight = axios
|
||||
.post('/api/auth/refresh', null, { withCredentials: true })
|
||||
.then(() => undefined)
|
||||
.finally(() => {
|
||||
refreshInflight = null;
|
||||
});
|
||||
}
|
||||
return refreshInflight;
|
||||
}
|
||||
|
||||
type RetryableConfig = AxiosRequestConfig & { _retry?: boolean };
|
||||
|
||||
api.interceptors.response.use(
|
||||
(res) => res,
|
||||
async (error: AxiosError<ApiError>) => {
|
||||
const original = error.config as RetryableConfig | undefined;
|
||||
const status = error.response?.status;
|
||||
const body = error.response?.data;
|
||||
const url = original?.url ?? '';
|
||||
|
||||
// Never try to refresh the unauthenticated auth endpoints themselves.
|
||||
const isAuthUnprotected = AUTH_UNPROTECTED.some((p) => url.endsWith(p));
|
||||
|
||||
if (status === 401 && !isAuthUnprotected && original && !original._retry) {
|
||||
original._retry = true;
|
||||
try {
|
||||
await refreshOnce();
|
||||
return api.request(original);
|
||||
} catch {
|
||||
// Refresh failed — fall through to reject with the original error.
|
||||
}
|
||||
}
|
||||
|
||||
if (body && typeof body === 'object' && 'code' in body && 'message' in body) {
|
||||
return Promise.reject(new ApiRequestError(status ?? 0, body));
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { CreateHostRequest, UpdateHostRequest } from '@vector/shared';
|
||||
import { api } from './client.js';
|
||||
import { getList } from './paginated.js';
|
||||
import type { Host } from './types.js';
|
||||
|
||||
export type HostListFilters = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
q?: string;
|
||||
};
|
||||
|
||||
export function listHosts(filters: HostListFilters = {}) {
|
||||
return getList<Host>('/hosts', filters);
|
||||
}
|
||||
|
||||
export async function getHost(id: string): Promise<Host> {
|
||||
const res = await api.get<Host>(`/hosts/${id}`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createHost(input: CreateHostRequest): Promise<Host> {
|
||||
const res = await api.post<Host>('/hosts', input);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function updateHost(id: string, input: UpdateHostRequest): Promise<Host> {
|
||||
const res = await api.patch<Host>(`/hosts/${id}`, input);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteHost(id: string): Promise<void> {
|
||||
await api.delete(`/hosts/${id}`);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type {
|
||||
CreateManufacturerRequest,
|
||||
UpdateManufacturerRequest,
|
||||
} from '@vector/shared';
|
||||
import { api } from './client.js';
|
||||
import { getList } from './paginated.js';
|
||||
import type { Manufacturer } from './types.js';
|
||||
|
||||
export type ManufacturerListFilters = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
export function listManufacturers(filters: ManufacturerListFilters = {}) {
|
||||
return getList<Manufacturer>('/manufacturers', filters);
|
||||
}
|
||||
|
||||
export async function createManufacturer(input: CreateManufacturerRequest): Promise<Manufacturer> {
|
||||
const res = await api.post<Manufacturer>('/manufacturers', input);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function updateManufacturer(
|
||||
id: string,
|
||||
input: UpdateManufacturerRequest,
|
||||
): Promise<Manufacturer> {
|
||||
const res = await api.patch<Manufacturer>(`/manufacturers/${id}`, input);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteManufacturer(id: string): Promise<void> {
|
||||
await api.delete(`/manufacturers/${id}`);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { PaginatedResponse } from '@vector/shared';
|
||||
import { api } from './client.js';
|
||||
|
||||
// Minimal helper: turn a filter object into a query-string payload, skipping undefined/null/''
|
||||
// and coercing booleans/numbers cleanly. Reuse across resource fetchers to keep them tiny.
|
||||
export function toQueryParams(filters: Record<string, unknown> = {}): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(filters)) {
|
||||
if (v === undefined || v === null || v === '') continue;
|
||||
out[k] = String(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function getList<T>(
|
||||
path: string,
|
||||
filters: Record<string, unknown> = {},
|
||||
): Promise<PaginatedResponse<T>> {
|
||||
const res = await api.get<PaginatedResponse<T>>(path, { params: toQueryParams(filters) });
|
||||
return res.data;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type {
|
||||
CreatePartRequest,
|
||||
PaginatedResponse,
|
||||
UpdatePartRequest,
|
||||
} from '@vector/shared';
|
||||
import { api } from './client.js';
|
||||
import { getList } from './paginated.js';
|
||||
import type { Part, PartEvent } from './types.js';
|
||||
|
||||
export type PartListFilters = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sort?: string;
|
||||
q?: string;
|
||||
state?: string;
|
||||
manufacturerId?: string;
|
||||
categoryId?: string;
|
||||
binId?: string;
|
||||
tagId?: string;
|
||||
eolOnly?: boolean;
|
||||
};
|
||||
|
||||
export function listParts(filters: PartListFilters) {
|
||||
return getList<Part>('/parts', filters);
|
||||
}
|
||||
|
||||
export async function getPart(id: string): Promise<Part> {
|
||||
const res = await api.get<Part>(`/parts/${id}`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createPart(input: CreatePartRequest): Promise<Part> {
|
||||
const res = await api.post<Part>('/parts', input);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function updatePart(id: string, input: UpdatePartRequest): Promise<Part> {
|
||||
const res = await api.patch<Part>(`/parts/${id}`, input);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deletePart(id: string): Promise<void> {
|
||||
await api.delete(`/parts/${id}`);
|
||||
}
|
||||
|
||||
export async function listPartEvents(
|
||||
partId: string,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
): Promise<PaginatedResponse<PartEvent>> {
|
||||
const res = await api.get<PaginatedResponse<PartEvent>>(`/parts/${partId}/events`, {
|
||||
params: { page, pageSize },
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type {
|
||||
CreateRepairJobRequest,
|
||||
RepairStatus,
|
||||
UpdateRepairJobRequest,
|
||||
} from '@vector/shared';
|
||||
import { api } from './client.js';
|
||||
import { getList } from './paginated.js';
|
||||
import type { RepairJob } from './types.js';
|
||||
|
||||
export type RepairListFilters = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: RepairStatus;
|
||||
partId?: string;
|
||||
hostId?: string;
|
||||
assigneeId?: string;
|
||||
openOnly?: boolean;
|
||||
};
|
||||
|
||||
export function listRepairs(filters: RepairListFilters = {}) {
|
||||
return getList<RepairJob>('/repairs', filters);
|
||||
}
|
||||
|
||||
export async function getRepair(id: string): Promise<RepairJob> {
|
||||
const res = await api.get<RepairJob>(`/repairs/${id}`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function listRepairsForPart(partId: string): Promise<RepairJob[]> {
|
||||
const res = await api.get<RepairJob[]>(`/parts/${partId}/repairs`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createRepair(input: CreateRepairJobRequest): Promise<RepairJob> {
|
||||
const res = await api.post<RepairJob>('/repairs', input);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function updateRepair(
|
||||
id: string,
|
||||
input: UpdateRepairJobRequest,
|
||||
): Promise<RepairJob> {
|
||||
const res = await api.patch<RepairJob>(`/repairs/${id}`, input);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteRepair(id: string): Promise<void> {
|
||||
await api.delete(`/repairs/${id}`);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { CreateRoomRequest, UpdateRoomRequest } from '@vector/shared';
|
||||
import { api } from './client.js';
|
||||
import { getList } from './paginated.js';
|
||||
import type { Room } from './types.js';
|
||||
|
||||
export function listRooms(filters: { page?: number; pageSize?: number; siteId?: string } = {}) {
|
||||
return getList<Room>('/rooms', filters);
|
||||
}
|
||||
|
||||
export async function createRoom(input: CreateRoomRequest): Promise<Room> {
|
||||
const res = await api.post<Room>('/rooms', input);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function updateRoom(id: string, input: UpdateRoomRequest): Promise<Room> {
|
||||
const res = await api.patch<Room>(`/rooms/${id}`, input);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteRoom(id: string): Promise<void> {
|
||||
await api.delete(`/rooms/${id}`);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type {
|
||||
CreateSavedViewRequest,
|
||||
SavedViewResource,
|
||||
UpdateSavedViewRequest,
|
||||
} from '@vector/shared';
|
||||
import { api } from './client.js';
|
||||
import { getList } from './paginated.js';
|
||||
import type { SavedView } from './types.js';
|
||||
|
||||
export function listSavedViews(resource: SavedViewResource) {
|
||||
return getList<SavedView>('/saved-views', { resource });
|
||||
}
|
||||
|
||||
export async function createSavedView(input: CreateSavedViewRequest): Promise<SavedView> {
|
||||
const res = await api.post<SavedView>('/saved-views', input);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function updateSavedView(
|
||||
id: string,
|
||||
input: UpdateSavedViewRequest,
|
||||
): Promise<SavedView> {
|
||||
const res = await api.patch<SavedView>(`/saved-views/${id}`, input);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteSavedView(id: string): Promise<void> {
|
||||
await api.delete(`/saved-views/${id}`);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { CreateSiteRequest, UpdateSiteRequest } from '@vector/shared';
|
||||
import { api } from './client.js';
|
||||
import { getList } from './paginated.js';
|
||||
import type { Site } from './types.js';
|
||||
|
||||
export function listSites(filters: { page?: number; pageSize?: number } = {}) {
|
||||
return getList<Site>('/sites', filters);
|
||||
}
|
||||
|
||||
export async function createSite(input: CreateSiteRequest): Promise<Site> {
|
||||
const res = await api.post<Site>('/sites', input);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function updateSite(id: string, input: UpdateSiteRequest): Promise<Site> {
|
||||
const res = await api.patch<Site>(`/sites/${id}`, input);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteSite(id: string): Promise<void> {
|
||||
await api.delete(`/sites/${id}`);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { CreateTagRequest, UpdateTagRequest } from '@vector/shared';
|
||||
import { api } from './client.js';
|
||||
import { getList } from './paginated.js';
|
||||
import type { Tag } from './types.js';
|
||||
|
||||
export type TagListFilters = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
q?: string;
|
||||
};
|
||||
|
||||
export function listTags(filters: TagListFilters = {}) {
|
||||
return getList<Tag>('/tags', filters);
|
||||
}
|
||||
|
||||
export async function createTag(input: CreateTagRequest): Promise<Tag> {
|
||||
const res = await api.post<Tag>('/tags', input);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function updateTag(id: string, input: UpdateTagRequest): Promise<Tag> {
|
||||
const res = await api.patch<Tag>(`/tags/${id}`, input);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteTag(id: string): Promise<void> {
|
||||
await api.delete(`/tags/${id}`);
|
||||
}
|
||||
|
||||
export async function listTagsForPart(partId: string): Promise<Tag[]> {
|
||||
const res = await api.get<Tag[]>(`/parts/${partId}/tags`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function assignTagsToPart(partId: string, tagIds: string[]): Promise<Tag[]> {
|
||||
const res = await api.post<Tag[]>(`/parts/${partId}/tags`, { tagIds });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function unassignTagFromPart(partId: string, tagId: string): Promise<Tag[]> {
|
||||
const res = await api.delete<Tag[]>(`/parts/${partId}/tags/${tagId}`);
|
||||
return res.data;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { PartEventType, PartState, RepairStatus, Role } from '@vector/shared';
|
||||
|
||||
// Shapes mirror Prisma rows the API returns (dates serialized as ISO strings).
|
||||
// Keep these in sync with apps/api/src/services responses.
|
||||
|
||||
export interface Manufacturer {
|
||||
id: string;
|
||||
name: string;
|
||||
eolDate: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Site {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Room {
|
||||
id: string;
|
||||
name: string;
|
||||
siteId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Bin {
|
||||
id: string;
|
||||
name: string;
|
||||
roomId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface BinWithPath extends Bin {
|
||||
room: Room & { site: Site };
|
||||
fullPath?: string;
|
||||
}
|
||||
|
||||
export interface Part {
|
||||
id: string;
|
||||
serialNumber: string;
|
||||
mpn: string;
|
||||
manufacturerId: string;
|
||||
price: number | null;
|
||||
state: PartState;
|
||||
binId: string | null;
|
||||
categoryId: string | null;
|
||||
replacementPartId: string | null;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
manufacturer: Manufacturer;
|
||||
bin: BinWithPath | null;
|
||||
}
|
||||
|
||||
export interface PartEvent {
|
||||
id: string;
|
||||
partId: string;
|
||||
userId: string | null;
|
||||
type: PartEventType;
|
||||
field: string | null;
|
||||
oldValue: string | null;
|
||||
newValue: string | null;
|
||||
createdAt: string;
|
||||
user: { username: string } | null;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
role: Role;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Host {
|
||||
id: string;
|
||||
name: string;
|
||||
location: string | null;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface RepairJob {
|
||||
id: string;
|
||||
partId: string;
|
||||
hostId: string | null;
|
||||
assigneeId: string | null;
|
||||
status: RepairStatus;
|
||||
notes: string | null;
|
||||
openedAt: string;
|
||||
closedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
part: Part;
|
||||
host: Host | null;
|
||||
assignee: Pick<User, 'id' | 'username' | 'email' | 'role'> | null;
|
||||
}
|
||||
|
||||
export interface SavedView {
|
||||
id: string;
|
||||
userId: string;
|
||||
resource: 'parts' | 'repairs' | 'hosts' | 'manufacturers';
|
||||
name: string;
|
||||
filterJson: unknown;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { CreateUserRequest, UpdateUserRequest } from '@vector/shared';
|
||||
import { api } from './client.js';
|
||||
import { getList } from './paginated.js';
|
||||
import type { User } from './types.js';
|
||||
|
||||
export function listUsers(filters: { page?: number; pageSize?: number } = {}) {
|
||||
return getList<User>('/users', filters);
|
||||
}
|
||||
|
||||
export async function createUser(input: CreateUserRequest): Promise<User> {
|
||||
const res = await api.post<User>('/users', input);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function updateUser(id: string, input: UpdateUserRequest): Promise<User> {
|
||||
const res = await api.patch<User>(`/users/${id}`, input);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteUser(id: string): Promise<void> {
|
||||
await api.delete(`/users/${id}`);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type {
|
||||
CreateWebhookSubscriptionRequest,
|
||||
UpdateWebhookSubscriptionRequest,
|
||||
WebhookEventName,
|
||||
} from '@vector/shared';
|
||||
import { api } from './client.js';
|
||||
import { getList } from './paginated.js';
|
||||
|
||||
export interface WebhookSubscription {
|
||||
id: string;
|
||||
url: string;
|
||||
events: WebhookEventName[];
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
secret?: string;
|
||||
}
|
||||
|
||||
export type WebhookListFilters = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
export function listWebhooks(filters: WebhookListFilters = {}) {
|
||||
return getList<WebhookSubscription>('/admin/webhooks', filters);
|
||||
}
|
||||
|
||||
export async function createWebhook(
|
||||
input: CreateWebhookSubscriptionRequest,
|
||||
): Promise<WebhookSubscription> {
|
||||
const res = await api.post<WebhookSubscription>('/admin/webhooks', input);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function updateWebhook(
|
||||
id: string,
|
||||
input: UpdateWebhookSubscriptionRequest,
|
||||
): Promise<WebhookSubscription> {
|
||||
const res = await api.patch<WebhookSubscription>(`/admin/webhooks/${id}`, input);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteWebhook(id: string): Promise<void> {
|
||||
await api.delete(`/admin/webhooks/${id}`);
|
||||
}
|
||||
|
||||
export async function rotateWebhookSecret(id: string): Promise<WebhookSubscription> {
|
||||
const res = await api.post<WebhookSubscription>(`/admin/webhooks/${id}/rotate-secret`);
|
||||
return res.data;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Hierarchical query keys for TanStack Query. Consumers should import this factory rather than
|
||||
// hard-coding tuples inline so invalidations can be surgical (e.g. invalidate everything under
|
||||
// `parts.list()` without touching `parts.detail(id)`).
|
||||
|
||||
export const queryKeys = {
|
||||
auth: {
|
||||
all: ['auth'] as const,
|
||||
me: () => [...queryKeys.auth.all, 'me'] as const,
|
||||
},
|
||||
parts: {
|
||||
all: ['parts'] as const,
|
||||
list: (filters?: Record<string, unknown>) =>
|
||||
[...queryKeys.parts.all, 'list', filters ?? {}] as const,
|
||||
detail: (id: string) => [...queryKeys.parts.all, 'detail', id] as const,
|
||||
events: (id: string, filters?: Record<string, unknown>) =>
|
||||
[...queryKeys.parts.all, 'events', id, filters ?? {}] as const,
|
||||
},
|
||||
manufacturers: {
|
||||
all: ['manufacturers'] as const,
|
||||
list: (filters?: Record<string, unknown>) =>
|
||||
[...queryKeys.manufacturers.all, 'list', filters ?? {}] as const,
|
||||
detail: (id: string) => [...queryKeys.manufacturers.all, 'detail', id] as const,
|
||||
},
|
||||
sites: {
|
||||
all: ['sites'] as const,
|
||||
list: (filters?: Record<string, unknown>) =>
|
||||
[...queryKeys.sites.all, 'list', filters ?? {}] as const,
|
||||
detail: (id: string) => [...queryKeys.sites.all, 'detail', id] as const,
|
||||
},
|
||||
rooms: {
|
||||
all: ['rooms'] as const,
|
||||
list: (filters?: Record<string, unknown>) =>
|
||||
[...queryKeys.rooms.all, 'list', filters ?? {}] as const,
|
||||
},
|
||||
bins: {
|
||||
all: ['bins'] as const,
|
||||
list: (filters?: Record<string, unknown>) =>
|
||||
[...queryKeys.bins.all, 'list', filters ?? {}] as const,
|
||||
},
|
||||
users: {
|
||||
all: ['users'] as const,
|
||||
list: (filters?: Record<string, unknown>) =>
|
||||
[...queryKeys.users.all, 'list', filters ?? {}] as const,
|
||||
},
|
||||
hosts: {
|
||||
all: ['hosts'] as const,
|
||||
list: (filters?: Record<string, unknown>) =>
|
||||
[...queryKeys.hosts.all, 'list', filters ?? {}] as const,
|
||||
detail: (id: string) => [...queryKeys.hosts.all, 'detail', id] as const,
|
||||
},
|
||||
repairs: {
|
||||
all: ['repairs'] as const,
|
||||
list: (filters?: Record<string, unknown>) =>
|
||||
[...queryKeys.repairs.all, 'list', filters ?? {}] as const,
|
||||
detail: (id: string) => [...queryKeys.repairs.all, 'detail', id] as const,
|
||||
},
|
||||
tags: {
|
||||
all: ['tags'] as const,
|
||||
list: (filters?: Record<string, unknown>) =>
|
||||
[...queryKeys.tags.all, 'list', filters ?? {}] as const,
|
||||
},
|
||||
categories: {
|
||||
all: ['categories'] as const,
|
||||
list: (filters?: Record<string, unknown>) =>
|
||||
[...queryKeys.categories.all, 'list', filters ?? {}] as const,
|
||||
},
|
||||
webhooks: {
|
||||
all: ['webhooks'] as const,
|
||||
list: (filters?: Record<string, unknown>) =>
|
||||
[...queryKeys.webhooks.all, 'list', filters ?? {}] as const,
|
||||
},
|
||||
savedViews: {
|
||||
all: ['savedViews'] as const,
|
||||
list: (resource: string) => [...queryKeys.savedViews.all, resource] as const,
|
||||
},
|
||||
analytics: {
|
||||
all: ['analytics'] as const,
|
||||
dashboard: () => [...queryKeys.analytics.all, 'dashboard'] as const,
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App.js';
|
||||
|
||||
const rootEl = document.getElementById('root');
|
||||
if (!rootEl) throw new Error('#root element not found');
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,337 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { AlertTriangle, Download, Package, Wrench } from 'lucide-react';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
Cell,
|
||||
Legend,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import type { PartState } from '@vector/shared';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Skeleton,
|
||||
} from '@vector/ui';
|
||||
import { PageHeader } from '../components/layout/PageHeader.js';
|
||||
import { getDashboardAnalytics } from '../lib/api/analytics.js';
|
||||
import { queryKeys } from '../lib/queryKeys.js';
|
||||
import { useAuth } from '../contexts/AuthContext.js';
|
||||
|
||||
const STATE_LABELS: Record<PartState, string> = {
|
||||
SPARE: 'Spare',
|
||||
DEPLOYED: 'Deployed',
|
||||
BROKEN: 'Broken',
|
||||
PENDING_DESTRUCTION: 'Pending destruction',
|
||||
};
|
||||
|
||||
const STATE_COLORS: Record<PartState, string> = {
|
||||
SPARE: 'hsl(217 91% 60%)',
|
||||
DEPLOYED: 'hsl(142 71% 45%)',
|
||||
BROKEN: 'hsl(0 84% 60%)',
|
||||
PENDING_DESTRUCTION: 'hsl(38 92% 50%)',
|
||||
};
|
||||
|
||||
function currency(cents: number): string {
|
||||
return (cents / 100).toLocaleString(undefined, { style: 'currency', currency: 'USD' });
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const { user } = useAuth();
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: queryKeys.analytics.dashboard(),
|
||||
queryFn: getDashboardAnalytics,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
description="Fleet-wide view of parts, repairs, and EOL risk."
|
||||
actions={
|
||||
user?.role === 'ADMIN' ? (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<a href="/api/admin/audit/events.csv" target="_blank" rel="noreferrer">
|
||||
<Download className="h-4 w-4" />
|
||||
Export audit CSV
|
||||
</a>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{isError && (
|
||||
<Card>
|
||||
<CardContent className="pt-5 text-sm text-destructive">
|
||||
Failed to load dashboard analytics.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isLoading && !data && <DashboardSkeleton />}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<KpiCard
|
||||
icon={<Package className="h-4 w-4" />}
|
||||
label="Total parts"
|
||||
value={data.totalParts.toLocaleString()}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Wrench className="h-4 w-4" />}
|
||||
label="Open repairs"
|
||||
value={data.openRepairs.toLocaleString()}
|
||||
href="/repairs"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Deployed value"
|
||||
value={currency(
|
||||
data.byState
|
||||
.filter((s) => s.state === 'DEPLOYED')
|
||||
.reduce((sum, s) => sum + s.totalPrice, 0),
|
||||
)}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Past-EOL deployments"
|
||||
value={data.deployedPastEol
|
||||
.reduce((sum, m) => sum + m.deployedCount, 0)
|
||||
.toLocaleString()}
|
||||
tone={data.deployedPastEol.length > 0 ? 'warn' : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{data.deployedPastEol.length > 0 && <PastEolBanner rows={data.deployedPastEol} />}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Parts by state</CardTitle>
|
||||
<CardDescription>Count in each lifecycle state.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-72">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={data.byState.map((s) => ({
|
||||
name: STATE_LABELS[s.state],
|
||||
state: s.state,
|
||||
count: s.count,
|
||||
}))}
|
||||
>
|
||||
<XAxis dataKey="name" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} />
|
||||
<Tooltip cursor={{ fill: 'hsl(var(--accent) / 0.2)' }} />
|
||||
<Bar dataKey="count" radius={[4, 4, 0, 0]}>
|
||||
{data.byState.map((s) => (
|
||||
<Cell key={s.state} fill={STATE_COLORS[s.state]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Value by state</CardTitle>
|
||||
<CardDescription>Aggregate part price per state.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-72">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data.byState
|
||||
.filter((s) => s.totalPrice > 0)
|
||||
.map((s) => ({
|
||||
name: STATE_LABELS[s.state],
|
||||
state: s.state,
|
||||
value: s.totalPrice / 100,
|
||||
}))}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
innerRadius={55}
|
||||
outerRadius={90}
|
||||
paddingAngle={2}
|
||||
>
|
||||
{data.byState
|
||||
.filter((s) => s.totalPrice > 0)
|
||||
.map((s) => (
|
||||
<Cell key={s.state} fill={STATE_COLORS[s.state]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(v: number) =>
|
||||
v.toLocaleString(undefined, { style: 'currency', currency: 'USD' })
|
||||
}
|
||||
/>
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Part age</CardTitle>
|
||||
<CardDescription>
|
||||
Days since each part first entered inventory.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-72">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data.ageBuckets}>
|
||||
<XAxis dataKey="label" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} />
|
||||
<Tooltip cursor={{ fill: 'hsl(var(--accent) / 0.2)' }} />
|
||||
<Bar dataKey="count" fill="hsl(217 91% 60%)" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Busiest bins</CardTitle>
|
||||
<CardDescription>Top 8 bins by part count.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-72">
|
||||
{data.topBins.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
No bins populated yet.
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data.topBins} layout="vertical" margin={{ left: 16 }}>
|
||||
<XAxis type="number" tick={{ fontSize: 12 }} allowDecimals={false} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 11 }}
|
||||
width={180}
|
||||
/>
|
||||
<Tooltip cursor={{ fill: 'hsl(var(--accent) / 0.2)' }} />
|
||||
<Bar dataKey="count" fill="hsl(262 83% 58%)" radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KpiCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
href,
|
||||
}: {
|
||||
icon?: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: 'warn';
|
||||
href?: string;
|
||||
}) {
|
||||
const body = (
|
||||
<Card className={tone === 'warn' ? 'border-warning/50' : undefined}>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
{icon && (
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-accent text-accent-foreground">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
<div className="truncate text-2xl font-semibold tracking-tight">{value}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
if (href) {
|
||||
return (
|
||||
<Link to={href} className="block transition-opacity hover:opacity-90">
|
||||
{body}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function PastEolBanner({
|
||||
rows,
|
||||
}: {
|
||||
rows: { manufacturerId: string; name: string; eolDate: string | null; deployedCount: number }[];
|
||||
}) {
|
||||
return (
|
||||
<Card className="border-warning/50 bg-warning/5">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<AlertTriangle className="h-4 w-4 text-warning" />
|
||||
Deployed past manufacturer EOL
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
These manufacturers have passed their end-of-life date — plan replacements for any parts
|
||||
still in production.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 pb-5">
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.manufacturerId}
|
||||
className="flex items-center justify-between rounded-md border border-border bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{row.name}</div>
|
||||
{row.eolDate && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
EOL {new Date(row.eolDate).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{row.deployedCount} deployed
|
||||
</span>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to={`/parts?manufacturerId=${row.manufacturerId}&state=DEPLOYED`}>View</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-20" />
|
||||
))}
|
||||
</div>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-72" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Edit, MoreHorizontal, Plus, Server, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@vector/ui';
|
||||
import { PageHeader } from '../components/layout/PageHeader.js';
|
||||
import { DataTable } from '../components/data-table/DataTable.js';
|
||||
import { HostFormDialog } from '../components/hosts/HostFormDialog.js';
|
||||
import { ConfirmDialog } from '../components/ConfirmDialog.js';
|
||||
import { deleteHost, listHosts } from '../lib/api/hosts.js';
|
||||
import { ApiRequestError } from '../lib/api/client.js';
|
||||
import type { Host } from '../lib/api/types.js';
|
||||
import { queryKeys } from '../lib/queryKeys.js';
|
||||
import { useAuth } from '../contexts/AuthContext.js';
|
||||
|
||||
export default function Hosts() {
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.role === 'ADMIN';
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Host | null>(null);
|
||||
const [deleting, setDeleting] = useState<Host | null>(null);
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteHost(id),
|
||||
onSuccess: () => {
|
||||
toast.success('Host deleted');
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.hosts.all });
|
||||
setDeleting(null);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed'),
|
||||
});
|
||||
|
||||
const columns = useMemo<ColumnDef<Host>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Name',
|
||||
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'location',
|
||||
header: 'Location',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{row.original.location ?? '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'notes',
|
||||
header: 'Notes',
|
||||
cell: ({ row }) => (
|
||||
<span className="line-clamp-1 text-sm text-muted-foreground">
|
||||
{row.original.notes ?? '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
size: 40,
|
||||
cell: ({ row }) =>
|
||||
isAdmin ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-36">
|
||||
<DropdownMenuItem onSelect={() => setEditing(row.original)}>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setDeleting(row.original)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null,
|
||||
},
|
||||
],
|
||||
[isAdmin],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title="Hosts"
|
||||
description="Machines and racks where parts are installed for repair work."
|
||||
actions={
|
||||
isAdmin && (
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
New host
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTable<Host, Record<string, never>>
|
||||
columns={columns}
|
||||
getRowId={(h) => h.id}
|
||||
queryKey={(params) =>
|
||||
queryKeys.hosts.list({ page: params.page, pageSize: params.pageSize, q: params.q })
|
||||
}
|
||||
queryFn={(params) =>
|
||||
listHosts({ page: params.page, pageSize: params.pageSize, q: params.q })
|
||||
}
|
||||
searchPlaceholder="Search hosts..."
|
||||
emptyState={
|
||||
<div className="flex flex-col items-center gap-1 py-8 text-muted-foreground">
|
||||
<Server className="h-6 w-6" />
|
||||
<span className="text-sm">No hosts yet.</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<HostFormDialog open={createOpen} onOpenChange={setCreateOpen} />
|
||||
<HostFormDialog
|
||||
open={Boolean(editing)}
|
||||
onOpenChange={(o) => !o && setEditing(null)}
|
||||
host={editing}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleting)}
|
||||
onOpenChange={(o) => !o && setDeleting(null)}
|
||||
title="Delete host?"
|
||||
description={
|
||||
deleting
|
||||
? `Remove ${deleting.name}. Fails if any repair jobs reference it.`
|
||||
: undefined
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
destructive
|
||||
pending={deleteMutation.isPending}
|
||||
onConfirm={() => deleting && deleteMutation.mutate(deleting.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import { PageHeader } from '../components/layout/PageHeader.js';
|
||||
import { SiteList } from '../components/locations/SiteList.js';
|
||||
import { RoomDrawer } from '../components/locations/RoomDrawer.js';
|
||||
import { BinGrid } from '../components/locations/BinGrid.js';
|
||||
import { useAuth } from '../contexts/AuthContext.js';
|
||||
|
||||
export default function Locations() {
|
||||
const { user } = useAuth();
|
||||
const canEdit = user?.role === 'ADMIN';
|
||||
|
||||
const [siteId, setSiteId] = useQueryState('site', parseAsString);
|
||||
const [roomId, setRoomId] = useQueryState('room', parseAsString);
|
||||
|
||||
const handleSite = (id: string) => {
|
||||
void setSiteId(id || null);
|
||||
void setRoomId(null);
|
||||
};
|
||||
const handleRoom = (id: string) => {
|
||||
void setRoomId(id || null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-var(--spacing-topbar,3.25rem)-3rem)] flex-col gap-4">
|
||||
<PageHeader
|
||||
title="Locations"
|
||||
description="Sites → Rooms → Bins. Select a site to drill in."
|
||||
/>
|
||||
<div className="grid min-h-0 flex-1 grid-cols-[16rem_16rem_1fr] overflow-hidden rounded-lg border border-border bg-card">
|
||||
<div className="border-r border-border">
|
||||
<SiteList selectedId={siteId} onSelect={handleSite} canEdit={canEdit} />
|
||||
</div>
|
||||
<div className="border-r border-border">
|
||||
<RoomDrawer siteId={siteId} selectedId={roomId} onSelect={handleRoom} canEdit={canEdit} />
|
||||
</div>
|
||||
<div>
|
||||
<BinGrid roomId={roomId} canEdit={canEdit} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Loader2, LogIn } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
} from '@vector/ui';
|
||||
import { toast } from 'sonner';
|
||||
import { useAuth } from '../contexts/AuthContext.js';
|
||||
import { ApiRequestError } from '../lib/api/client.js';
|
||||
|
||||
const LoginSchema = z.object({
|
||||
username: z.string().min(1, 'Username is required'),
|
||||
password: z.string().min(1, 'Password is required'),
|
||||
});
|
||||
type LoginValues = z.infer<typeof LoginSchema>;
|
||||
|
||||
export default function Login() {
|
||||
const { status, login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const form = useForm<LoginValues>({
|
||||
resolver: zodResolver(LoginSchema),
|
||||
defaultValues: { username: '', password: '' },
|
||||
});
|
||||
|
||||
if (status === 'authenticated') {
|
||||
const next = (location.state as { from?: { pathname?: string } } | null)?.from?.pathname ?? '/';
|
||||
return <Navigate to={next} replace />;
|
||||
}
|
||||
|
||||
const onSubmit = async (values: LoginValues) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await login(values.username, values.password);
|
||||
const next = (location.state as { from?: { pathname?: string } } | null)?.from?.pathname ?? '/';
|
||||
navigate(next, { replace: true });
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err instanceof ApiRequestError
|
||||
? err.body.message
|
||||
: 'Could not sign in. Please try again.';
|
||||
toast.error(msg);
|
||||
form.setError('password', { message: msg });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-6">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="items-center text-center">
|
||||
<div className="mb-2 flex h-10 w-10 items-center justify-center rounded-md bg-brand text-brand-foreground font-bold">
|
||||
V
|
||||
</div>
|
||||
<CardTitle>Sign in to Vector</CardTitle>
|
||||
<CardDescription>Parts inventory, repair & deployment.</CardDescription>
|
||||
</CardHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<CardContent className="space-y-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input autoComplete="username" autoFocus {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" autoComplete="current-password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-2">
|
||||
<Button type="submit" variant="brand" className="w-full" disabled={submitting}>
|
||||
{submitting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<LogIn className="h-4 w-4" />
|
||||
)}
|
||||
Sign in
|
||||
</Button>
|
||||
<Link to="/" className="text-xs text-muted-foreground hover:text-foreground">
|
||||
Need help?
|
||||
</Link>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Building, Edit, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@vector/ui';
|
||||
import { PageHeader } from '../components/layout/PageHeader.js';
|
||||
import { DataTable } from '../components/data-table/DataTable.js';
|
||||
import { ManufacturerFormDialog } from '../components/manufacturers/ManufacturerFormDialog.js';
|
||||
import { ConfirmDialog } from '../components/ConfirmDialog.js';
|
||||
import { deleteManufacturer, listManufacturers } from '../lib/api/manufacturers.js';
|
||||
import { ApiRequestError } from '../lib/api/client.js';
|
||||
import type { Manufacturer } from '../lib/api/types.js';
|
||||
import { queryKeys } from '../lib/queryKeys.js';
|
||||
import { useAuth } from '../contexts/AuthContext.js';
|
||||
|
||||
export default function Manufacturers() {
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.role === 'ADMIN';
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Manufacturer | null>(null);
|
||||
const [deleting, setDeleting] = useState<Manufacturer | null>(null);
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteManufacturer(id),
|
||||
onSuccess: () => {
|
||||
toast.success('Manufacturer deleted');
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.manufacturers.all });
|
||||
setDeleting(null);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed'),
|
||||
});
|
||||
|
||||
const columns = useMemo<ColumnDef<Manufacturer>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Name',
|
||||
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'eolDate',
|
||||
header: 'EOL',
|
||||
cell: ({ row }) => {
|
||||
if (!row.original.eolDate) {
|
||||
return <span className="text-xs text-muted-foreground">—</span>;
|
||||
}
|
||||
const d = new Date(row.original.eolDate);
|
||||
const past = d.getTime() < Date.now();
|
||||
return (
|
||||
<Badge variant={past ? 'warning' : 'outline'}>{d.toLocaleDateString()}</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
header: 'Added',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(row.original.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
size: 40,
|
||||
cell: ({ row }) =>
|
||||
isAdmin ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-36">
|
||||
<DropdownMenuItem onSelect={() => setEditing(row.original)}>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setDeleting(row.original)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null,
|
||||
},
|
||||
],
|
||||
[isAdmin],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title="Manufacturers"
|
||||
description="Vendors and their end-of-life dates."
|
||||
actions={
|
||||
isAdmin && (
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
New manufacturer
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTable<Manufacturer, Record<string, never>>
|
||||
columns={columns}
|
||||
getRowId={(m) => m.id}
|
||||
queryKey={(params) =>
|
||||
queryKeys.manufacturers.list({ page: params.page, pageSize: params.pageSize })
|
||||
}
|
||||
queryFn={(params) =>
|
||||
listManufacturers({ page: params.page, pageSize: params.pageSize })
|
||||
}
|
||||
enableSearch={false}
|
||||
emptyState={
|
||||
<div className="flex flex-col items-center gap-1 py-8 text-muted-foreground">
|
||||
<Building className="h-6 w-6" />
|
||||
<span className="text-sm">No manufacturers yet.</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<ManufacturerFormDialog open={createOpen} onOpenChange={setCreateOpen} />
|
||||
<ManufacturerFormDialog
|
||||
open={Boolean(editing)}
|
||||
onOpenChange={(o) => !o && setEditing(null)}
|
||||
manufacturer={editing}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleting)}
|
||||
onOpenChange={(o) => !o && setDeleting(null)}
|
||||
title="Delete manufacturer?"
|
||||
description={
|
||||
deleting
|
||||
? `Remove ${deleting.name}. This fails if any parts reference it.`
|
||||
: undefined
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
destructive
|
||||
pending={deleteMutation.isPending}
|
||||
onConfirm={() => deleting && deleteMutation.mutate(deleting.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { AlertTriangle, ArrowLeft, Edit, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Separator,
|
||||
Skeleton,
|
||||
} from '@vector/ui';
|
||||
import { getPart, deletePart } from '../lib/api/parts.js';
|
||||
import { ApiRequestError } from '../lib/api/client.js';
|
||||
import { queryKeys } from '../lib/queryKeys.js';
|
||||
import { useAuth } from '../contexts/AuthContext.js';
|
||||
import { PartStateBadge } from '../components/parts/PartStateBadge.js';
|
||||
import { PartEventTimeline } from '../components/parts/PartEventTimeline.js';
|
||||
import { PartFormDialog } from '../components/parts/PartFormDialog.js';
|
||||
import { PartRepairSection } from '../components/parts/PartRepairSection.js';
|
||||
import { TagPicker } from '../components/tags/TagPicker.js';
|
||||
import { ConfirmDialog } from '../components/ConfirmDialog.js';
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[10rem_1fr] gap-2 text-sm">
|
||||
<dt className="text-muted-foreground">{label}</dt>
|
||||
<dd className="text-foreground">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PartDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.role === 'ADMIN';
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
const { data: part, isPending, isError, error } = useQuery({
|
||||
queryKey: queryKeys.parts.detail(id!),
|
||||
queryFn: () => getPart(id!),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => deletePart(id!),
|
||||
onSuccess: () => {
|
||||
toast.success('Part deleted');
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
|
||||
navigate('/parts', { replace: true });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed');
|
||||
},
|
||||
});
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !part) {
|
||||
const msg = error instanceof ApiRequestError ? error.body.message : 'Part not found.';
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Part unavailable</CardTitle>
|
||||
<CardDescription>{msg}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="outline" onClick={() => navigate('/parts')}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to parts
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const eolDate = part.manufacturer.eolDate ? new Date(part.manufacturer.eolDate) : null;
|
||||
const pastEol = eolDate ? eolDate.getTime() < Date.now() : false;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate('/parts')} aria-label="Back">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="font-mono text-lg font-semibold tracking-tight">{part.serialNumber}</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{part.manufacturer.name} · {part.mpn}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<PartStateBadge state={part.state} />
|
||||
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
Edit
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pastEol && eolDate && (
|
||||
<Card className="border-warning/60 bg-warning/10">
|
||||
<CardContent className="flex items-start gap-3 py-3">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 text-warning" />
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-foreground">
|
||||
{part.manufacturer.name} reached EOL {eolDate.toLocaleDateString()}.
|
||||
</span>{' '}
|
||||
<span className="text-muted-foreground">
|
||||
Plan a replacement for this part.
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-[1fr_1.2fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Summary</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="space-y-2">
|
||||
<DetailRow label="Serial" value={<span className="font-mono text-xs">{part.serialNumber}</span>} />
|
||||
<DetailRow label="MPN" value={part.mpn} />
|
||||
<DetailRow
|
||||
label="Manufacturer"
|
||||
value={
|
||||
<Link
|
||||
to="/manufacturers"
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
{part.manufacturer.name}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<DetailRow label="State" value={<PartStateBadge state={part.state} />} />
|
||||
<DetailRow
|
||||
label="Location"
|
||||
value={
|
||||
part.bin?.fullPath ? (
|
||||
<span className="font-mono text-xs">{part.bin.fullPath}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground italic">Unassigned</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Price"
|
||||
value={
|
||||
part.price != null ? (
|
||||
<span className="tabular-nums">${part.price.toFixed(2)}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<DetailRow
|
||||
label="EOL"
|
||||
value={
|
||||
eolDate ? (
|
||||
<span className={pastEol ? 'text-warning' : ''}>
|
||||
{eolDate.toLocaleDateString()}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Separator className="my-2" />
|
||||
<DetailRow
|
||||
label="Created"
|
||||
value={new Date(part.createdAt).toLocaleString()}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Updated"
|
||||
value={new Date(part.updatedAt).toLocaleString()}
|
||||
/>
|
||||
</dl>
|
||||
{part.notes && (
|
||||
<>
|
||||
<Separator className="my-3" />
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-muted-foreground">Notes</p>
|
||||
<p className="whitespace-pre-wrap text-sm text-foreground">{part.notes}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Separator className="my-3" />
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium text-muted-foreground">Tags</p>
|
||||
<TagPicker partId={part.id} />
|
||||
</div>
|
||||
<Separator className="my-3" />
|
||||
<PartRepairSection partId={part.id} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">History</CardTitle>
|
||||
<CardDescription>Every field change is logged here.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PartEventTimeline partId={part.id} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<PartFormDialog open={editOpen} onOpenChange={setEditOpen} part={part} />
|
||||
<ConfirmDialog
|
||||
open={confirmDelete}
|
||||
onOpenChange={setConfirmDelete}
|
||||
title="Delete part?"
|
||||
description={`Permanently remove part ${part.serialNumber}. Its history will be removed too.`}
|
||||
confirmLabel="Delete"
|
||||
destructive
|
||||
pending={deleteMutation.isPending}
|
||||
onConfirm={() => deleteMutation.mutate()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { parseAsString } from 'nuqs';
|
||||
import { toast } from 'sonner';
|
||||
import { Edit, MoreHorizontal, Package, Plus, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@vector/ui';
|
||||
import { PageHeader } from '../components/layout/PageHeader.js';
|
||||
import { DataTable } from '../components/data-table/DataTable.js';
|
||||
import { PartStateBadge, partStateOptions } from '../components/parts/PartStateBadge.js';
|
||||
import { PartFormDialog } from '../components/parts/PartFormDialog.js';
|
||||
import { PartBulkStateDialog } from '../components/parts/PartBulkStateDialog.js';
|
||||
import { ConfirmDialog } from '../components/ConfirmDialog.js';
|
||||
import { listParts, deletePart } from '../lib/api/parts.js';
|
||||
import { listManufacturers } from '../lib/api/manufacturers.js';
|
||||
import { listTags } from '../lib/api/tags.js';
|
||||
import { ApiRequestError } from '../lib/api/client.js';
|
||||
import type { Part } from '../lib/api/types.js';
|
||||
import { queryKeys } from '../lib/queryKeys.js';
|
||||
import { useAuth } from '../contexts/AuthContext.js';
|
||||
|
||||
type PartsFilters = {
|
||||
state: string | null;
|
||||
manufacturerId: string | null;
|
||||
tagId: string | null;
|
||||
};
|
||||
|
||||
const filterParsers = {
|
||||
state: parseAsString,
|
||||
manufacturerId: parseAsString,
|
||||
tagId: parseAsString,
|
||||
};
|
||||
|
||||
const ALL = '__all__';
|
||||
|
||||
export default function Parts() {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const isAdmin = user?.role === 'ADMIN';
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Part | null>(null);
|
||||
const [deleting, setDeleting] = useState<Part | null>(null);
|
||||
const [bulkIds, setBulkIds] = useState<string[] | null>(null);
|
||||
const [clearSelection, setClearSelection] = useState<(() => void) | null>(null);
|
||||
|
||||
const manufacturers = useQuery({
|
||||
queryKey: queryKeys.manufacturers.list({ pageSize: 100 }),
|
||||
queryFn: () => listManufacturers({ pageSize: 100 }),
|
||||
});
|
||||
const tagsQuery = useQuery({
|
||||
queryKey: queryKeys.tags.list({ pageSize: 100 }),
|
||||
queryFn: () => listTags({ pageSize: 100 }),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deletePart(id),
|
||||
onSuccess: () => {
|
||||
toast.success('Part deleted');
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
|
||||
setDeleting(null);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed');
|
||||
},
|
||||
});
|
||||
|
||||
const columns = useMemo<ColumnDef<Part>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'serialNumber',
|
||||
header: 'Serial',
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
to={`/parts/${row.original.id}`}
|
||||
className="font-mono text-xs font-medium text-foreground hover:underline"
|
||||
>
|
||||
{row.original.serialNumber}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'mpn',
|
||||
header: 'MPN',
|
||||
cell: ({ row }) => <span className="text-sm">{row.original.mpn}</span>,
|
||||
},
|
||||
{
|
||||
id: 'manufacturer',
|
||||
header: 'Manufacturer',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">{row.original.manufacturer.name}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'state',
|
||||
header: 'State',
|
||||
cell: ({ row }) => <PartStateBadge state={row.original.state} />,
|
||||
},
|
||||
{
|
||||
id: 'location',
|
||||
header: 'Location',
|
||||
cell: ({ row }) => {
|
||||
const path = row.original.bin?.fullPath;
|
||||
return path ? (
|
||||
<span className="text-xs font-mono text-muted-foreground">{path}</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground italic">Unassigned</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'price',
|
||||
header: 'Price',
|
||||
cell: ({ row }) =>
|
||||
row.original.price != null ? (
|
||||
<span className="text-sm tabular-nums">${row.original.price.toFixed(2)}</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
size: 40,
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem onSelect={() => navigate(`/parts/${row.original.id}`)}>
|
||||
View
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setEditing(row.original)}>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setDeleting(row.original)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
},
|
||||
],
|
||||
[navigate, isAdmin],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title="Parts"
|
||||
description="Search, filter, and manage every tracked part."
|
||||
actions={
|
||||
isAdmin && (
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
New part
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTable<Part, PartsFilters>
|
||||
columns={columns}
|
||||
getRowId={(p) => p.id}
|
||||
queryKey={(params) =>
|
||||
queryKeys.parts.list({
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
q: params.q,
|
||||
sort: params.sort,
|
||||
state: params.filters.state,
|
||||
manufacturerId: params.filters.manufacturerId,
|
||||
tagId: params.filters.tagId,
|
||||
})
|
||||
}
|
||||
queryFn={(params) =>
|
||||
listParts({
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
q: params.q,
|
||||
sort: params.sort,
|
||||
state: params.filters.state ?? undefined,
|
||||
manufacturerId: params.filters.manufacturerId ?? undefined,
|
||||
tagId: params.filters.tagId ?? undefined,
|
||||
})
|
||||
}
|
||||
filterParsers={filterParsers}
|
||||
searchPlaceholder="Search serial, MPN, notes…"
|
||||
enableSelection={isAdmin}
|
||||
emptyState={
|
||||
<div className="flex flex-col items-center gap-1 py-8 text-muted-foreground">
|
||||
<Package className="h-6 w-6" />
|
||||
<span className="text-sm">No parts match these filters.</span>
|
||||
</div>
|
||||
}
|
||||
toolbar={({ filters, setFilter }) => (
|
||||
<PartsFilters
|
||||
manufacturers={manufacturers.data?.data ?? []}
|
||||
tags={tagsQuery.data?.data ?? []}
|
||||
state={filters.state ?? ALL}
|
||||
manufacturerId={filters.manufacturerId ?? ALL}
|
||||
tagId={filters.tagId ?? ALL}
|
||||
onState={(v) => setFilter('state', v === ALL ? null : v)}
|
||||
onManufacturer={(v) => setFilter('manufacturerId', v === ALL ? null : v)}
|
||||
onTag={(v) => setFilter('tagId', v === ALL ? null : v)}
|
||||
/>
|
||||
)}
|
||||
bulkActions={(ids, clear) => (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setBulkIds(ids);
|
||||
setClearSelection(() => clear);
|
||||
}}
|
||||
>
|
||||
Bulk edit
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<PartFormDialog open={createOpen} onOpenChange={setCreateOpen} />
|
||||
<PartFormDialog
|
||||
open={Boolean(editing)}
|
||||
onOpenChange={(o) => !o && setEditing(null)}
|
||||
part={editing}
|
||||
/>
|
||||
<PartBulkStateDialog
|
||||
open={Boolean(bulkIds)}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setBulkIds(null);
|
||||
}}
|
||||
partIds={bulkIds ?? []}
|
||||
onDone={() => clearSelection?.()}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleting)}
|
||||
onOpenChange={(o) => !o && setDeleting(null)}
|
||||
title="Delete part?"
|
||||
description={
|
||||
deleting
|
||||
? `Permanently remove part ${deleting.serialNumber}. Its history will be removed too.`
|
||||
: undefined
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
destructive
|
||||
pending={deleteMutation.isPending}
|
||||
onConfirm={() => deleting && deleteMutation.mutate(deleting.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PartsFiltersProps {
|
||||
manufacturers: { id: string; name: string }[];
|
||||
tags: { id: string; name: string }[];
|
||||
state: string;
|
||||
manufacturerId: string;
|
||||
tagId: string;
|
||||
onState: (v: string) => void;
|
||||
onManufacturer: (v: string) => void;
|
||||
onTag: (v: string) => void;
|
||||
}
|
||||
|
||||
function PartsFilters({
|
||||
manufacturers,
|
||||
tags,
|
||||
state,
|
||||
manufacturerId,
|
||||
tagId,
|
||||
onState,
|
||||
onManufacturer,
|
||||
onTag,
|
||||
}: PartsFiltersProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={state} onValueChange={onState}>
|
||||
<SelectTrigger className="h-8 w-36 text-xs">
|
||||
<SelectValue placeholder="State" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>All states</SelectItem>
|
||||
{partStateOptions.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={manufacturerId} onValueChange={onManufacturer}>
|
||||
<SelectTrigger className="h-8 w-48 text-xs">
|
||||
<SelectValue placeholder="Manufacturer" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>All manufacturers</SelectItem>
|
||||
{manufacturers.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={tagId} onValueChange={onTag}>
|
||||
<SelectTrigger className="h-8 w-36 text-xs">
|
||||
<SelectValue placeholder="Tag" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>All tags</SelectItem>
|
||||
{tags.map((t) => (
|
||||
<SelectItem key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@vector/ui';
|
||||
|
||||
interface PlaceholderProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
phase: string;
|
||||
}
|
||||
|
||||
export function Placeholder({ title, description, icon: Icon, phase }: PlaceholderProps) {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-muted">
|
||||
<Icon className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">Arriving in {phase}</CardTitle>
|
||||
<CardDescription>
|
||||
Screen will be built during the {phase} rewrite pass.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The API is already live for this resource. This placeholder exists so routing and
|
||||
navigation can be verified end-to-end from the shell.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { parseAsString } from 'nuqs';
|
||||
import { toast } from 'sonner';
|
||||
import { Edit, MoreHorizontal, Plus, Trash2, Wrench } from 'lucide-react';
|
||||
import type { RepairStatus } from '@vector/shared';
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@vector/ui';
|
||||
import { PageHeader } from '../components/layout/PageHeader.js';
|
||||
import { DataTable } from '../components/data-table/DataTable.js';
|
||||
import { RepairFormDialog } from '../components/repairs/RepairFormDialog.js';
|
||||
import {
|
||||
RepairStatusBadge,
|
||||
repairStatusOptions,
|
||||
} from '../components/repairs/RepairStatusBadge.js';
|
||||
import { ConfirmDialog } from '../components/ConfirmDialog.js';
|
||||
import { deleteRepair, listRepairs } from '../lib/api/repairs.js';
|
||||
import { ApiRequestError } from '../lib/api/client.js';
|
||||
import type { RepairJob } from '../lib/api/types.js';
|
||||
import { queryKeys } from '../lib/queryKeys.js';
|
||||
|
||||
type RepairFilters = {
|
||||
status: string | null;
|
||||
};
|
||||
|
||||
const filterParsers = {
|
||||
status: parseAsString,
|
||||
};
|
||||
|
||||
const ALL = '__all__';
|
||||
|
||||
export default function Repairs() {
|
||||
const queryClient = useQueryClient();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<RepairJob | null>(null);
|
||||
const [deleting, setDeleting] = useState<RepairJob | null>(null);
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteRepair(id),
|
||||
onSuccess: () => {
|
||||
toast.success('Repair removed');
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.repairs.all });
|
||||
setDeleting(null);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed'),
|
||||
});
|
||||
|
||||
const columns = useMemo<ColumnDef<RepairJob>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => <RepairStatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: 'part',
|
||||
header: 'Part',
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
to={`/parts/${row.original.partId}`}
|
||||
className="font-medium text-foreground hover:underline"
|
||||
>
|
||||
{row.original.part.serialNumber}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'mpn',
|
||||
header: 'MPN',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">{row.original.part.mpn}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'host',
|
||||
header: 'Host',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{row.original.host?.name ?? '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'openedAt',
|
||||
header: 'Opened',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(row.original.openedAt).toLocaleDateString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'closedAt',
|
||||
header: 'Closed',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{row.original.closedAt
|
||||
? new Date(row.original.closedAt).toLocaleDateString()
|
||||
: '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
size: 40,
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-36">
|
||||
<DropdownMenuItem onSelect={() => setEditing(row.original)}>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setDeleting(row.original)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title="Repairs"
|
||||
description="Open RMAs and host-attached repair jobs."
|
||||
actions={
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Open repair
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTable<RepairJob, RepairFilters>
|
||||
columns={columns}
|
||||
getRowId={(r) => r.id}
|
||||
filterParsers={filterParsers}
|
||||
queryKey={(params) =>
|
||||
queryKeys.repairs.list({
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
status: params.filters.status,
|
||||
})
|
||||
}
|
||||
queryFn={(params) =>
|
||||
listRepairs({
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
status: (params.filters.status ?? undefined) as RepairStatus | undefined,
|
||||
})
|
||||
}
|
||||
enableSearch={false}
|
||||
toolbar={({ filters, setFilter }) => (
|
||||
<Select
|
||||
value={filters.status ?? ALL}
|
||||
onValueChange={(v) => setFilter('status', v === ALL ? null : v)}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="Any status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>Any status</SelectItem>
|
||||
{repairStatusOptions.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
emptyState={
|
||||
<div className="flex flex-col items-center gap-1 py-8 text-muted-foreground">
|
||||
<Wrench className="h-6 w-6" />
|
||||
<span className="text-sm">No repair jobs yet.</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<RepairFormDialog open={createOpen} onOpenChange={setCreateOpen} />
|
||||
<RepairFormDialog
|
||||
open={Boolean(editing)}
|
||||
onOpenChange={(o) => !o && setEditing(null)}
|
||||
repair={editing}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleting)}
|
||||
onOpenChange={(o) => !o && setDeleting(null)}
|
||||
title="Delete repair?"
|
||||
description={
|
||||
deleting
|
||||
? `Remove repair for ${deleting.part.serialNumber}. This cannot be undone.`
|
||||
: undefined
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
destructive
|
||||
pending={deleteMutation.isPending}
|
||||
onConfirm={() => deleting && deleteMutation.mutate(deleting.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Edit, MoreHorizontal, Plus, Trash2, Users as UsersIcon } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@vector/ui';
|
||||
import { PageHeader } from '../../components/layout/PageHeader.js';
|
||||
import { DataTable } from '../../components/data-table/DataTable.js';
|
||||
import { UserFormDialog } from '../../components/users/UserFormDialog.js';
|
||||
import { ConfirmDialog } from '../../components/ConfirmDialog.js';
|
||||
import { deleteUser, listUsers } from '../../lib/api/users.js';
|
||||
import { ApiRequestError } from '../../lib/api/client.js';
|
||||
import type { User } from '../../lib/api/types.js';
|
||||
import { queryKeys } from '../../lib/queryKeys.js';
|
||||
import { useAuth } from '../../contexts/AuthContext.js';
|
||||
|
||||
export default function Users() {
|
||||
const queryClient = useQueryClient();
|
||||
const { user: currentUser } = useAuth();
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<User | null>(null);
|
||||
const [deleting, setDeleting] = useState<User | null>(null);
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteUser(id),
|
||||
onSuccess: () => {
|
||||
toast.success('User deleted');
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.users.all });
|
||||
setDeleting(null);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed'),
|
||||
});
|
||||
|
||||
const columns = useMemo<ColumnDef<User>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'username',
|
||||
header: 'Username',
|
||||
cell: ({ row }) => <span className="font-medium">{row.original.username}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'email',
|
||||
header: 'Email',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">{row.original.email}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'role',
|
||||
header: 'Role',
|
||||
cell: ({ row }) => (
|
||||
<Badge variant={row.original.role === 'ADMIN' ? 'brand' : 'secondary'}>
|
||||
{row.original.role}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
header: 'Joined',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(row.original.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
size: 40,
|
||||
cell: ({ row }) => {
|
||||
const isSelf = row.original.id === currentUser?.id;
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem onSelect={() => setEditing(row.original)}>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
disabled={isSelf}
|
||||
onSelect={() => !isSelf && setDeleting(row.original)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[currentUser?.id],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title="Users"
|
||||
description="Manage technicians and admins."
|
||||
actions={
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
New user
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTable<User, Record<string, never>>
|
||||
columns={columns}
|
||||
getRowId={(u) => u.id}
|
||||
queryKey={(params) =>
|
||||
queryKeys.users.list({ page: params.page, pageSize: params.pageSize })
|
||||
}
|
||||
queryFn={(params) => listUsers({ page: params.page, pageSize: params.pageSize })}
|
||||
enableSearch={false}
|
||||
emptyState={
|
||||
<div className="flex flex-col items-center gap-1 py-8 text-muted-foreground">
|
||||
<UsersIcon className="h-6 w-6" />
|
||||
<span className="text-sm">No users found.</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<UserFormDialog open={createOpen} onOpenChange={setCreateOpen} />
|
||||
<UserFormDialog
|
||||
open={Boolean(editing)}
|
||||
onOpenChange={(o) => !o && setEditing(null)}
|
||||
user={editing}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleting)}
|
||||
onOpenChange={(o) => !o && setDeleting(null)}
|
||||
title="Delete user?"
|
||||
description={
|
||||
deleting
|
||||
? `Remove ${deleting.username}. Their part-event history stays but is unattributed.`
|
||||
: undefined
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
destructive
|
||||
pending={deleteMutation.isPending}
|
||||
onConfirm={() => deleting && deleteMutation.mutate(deleting.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Edit, KeyRound, MoreHorizontal, Plus, Trash2, Webhook } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@vector/ui';
|
||||
import { PageHeader } from '../../components/layout/PageHeader.js';
|
||||
import { DataTable } from '../../components/data-table/DataTable.js';
|
||||
import { ConfirmDialog } from '../../components/ConfirmDialog.js';
|
||||
import { WebhookFormDialog } from '../../components/webhooks/WebhookFormDialog.js';
|
||||
import { WebhookSecretDialog } from '../../components/webhooks/WebhookSecretDialog.js';
|
||||
import {
|
||||
deleteWebhook,
|
||||
listWebhooks,
|
||||
rotateWebhookSecret,
|
||||
type WebhookSubscription,
|
||||
} from '../../lib/api/webhooks.js';
|
||||
import { ApiRequestError } from '../../lib/api/client.js';
|
||||
import { queryKeys } from '../../lib/queryKeys.js';
|
||||
|
||||
export default function Webhooks() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<WebhookSubscription | null>(null);
|
||||
const [deleting, setDeleting] = useState<WebhookSubscription | null>(null);
|
||||
const [rotating, setRotating] = useState<WebhookSubscription | null>(null);
|
||||
const [secret, setSecret] = useState<string | null>(null);
|
||||
const [secretTitle, setSecretTitle] = useState('Signing secret');
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteWebhook(id),
|
||||
onSuccess: () => {
|
||||
toast.success('Subscription deleted');
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.webhooks.all });
|
||||
setDeleting(null);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed'),
|
||||
});
|
||||
|
||||
const rotateMutation = useMutation({
|
||||
mutationFn: (id: string) => rotateWebhookSecret(id),
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.webhooks.all });
|
||||
setRotating(null);
|
||||
if (result.secret) {
|
||||
setSecretTitle('New signing secret');
|
||||
setSecret(result.secret);
|
||||
}
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Rotation failed'),
|
||||
});
|
||||
|
||||
const columns = useMemo<ColumnDef<WebhookSubscription>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'url',
|
||||
header: 'Endpoint',
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs break-all">{row.original.url}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'events',
|
||||
header: 'Events',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{row.original.events.map((e) => (
|
||||
<Badge key={e} variant="secondary" className="font-mono text-[10px]">
|
||||
{e}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'active',
|
||||
header: 'Status',
|
||||
cell: ({ row }) =>
|
||||
row.original.active ? (
|
||||
<Badge variant="success">Active</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Paused</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
header: 'Created',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(row.original.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
size: 40,
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem onSelect={() => setEditing(row.original)}>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setRotating(row.original)}>
|
||||
<KeyRound className="h-3.5 w-3.5" />
|
||||
Rotate secret
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setDeleting(row.original)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title="Webhooks"
|
||||
description="Subscribe external receivers to Vector events. Deliveries are signed with HMAC-SHA256."
|
||||
actions={
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
New subscription
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTable<WebhookSubscription, Record<string, never>>
|
||||
columns={columns}
|
||||
getRowId={(w) => w.id}
|
||||
queryKey={(params) =>
|
||||
queryKeys.webhooks.list({ page: params.page, pageSize: params.pageSize })
|
||||
}
|
||||
queryFn={(params) => listWebhooks({ page: params.page, pageSize: params.pageSize })}
|
||||
enableSearch={false}
|
||||
emptyState={
|
||||
<div className="flex flex-col items-center gap-1 py-8 text-muted-foreground">
|
||||
<Webhook className="h-6 w-6" />
|
||||
<span className="text-sm">No subscriptions yet.</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<WebhookFormDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
onCreated={(created) => {
|
||||
if (created.secret) {
|
||||
setSecretTitle('Signing secret');
|
||||
setSecret(created.secret);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<WebhookFormDialog
|
||||
open={Boolean(editing)}
|
||||
onOpenChange={(o) => !o && setEditing(null)}
|
||||
subscription={editing}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleting)}
|
||||
onOpenChange={(o) => !o && setDeleting(null)}
|
||||
title="Delete subscription?"
|
||||
description={
|
||||
deleting
|
||||
? `Stop delivering events to ${deleting.url}. This cannot be undone.`
|
||||
: undefined
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
destructive
|
||||
pending={deleteMutation.isPending}
|
||||
onConfirm={() => deleting && deleteMutation.mutate(deleting.id)}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={Boolean(rotating)}
|
||||
onOpenChange={(o) => !o && setRotating(null)}
|
||||
title="Rotate signing secret?"
|
||||
description={
|
||||
rotating
|
||||
? `Generate a new secret for ${rotating.url}. The old secret will stop working immediately.`
|
||||
: undefined
|
||||
}
|
||||
confirmLabel="Rotate"
|
||||
pending={rotateMutation.isPending}
|
||||
onConfirm={() => rotating && rotateMutation.mutate(rotating.id)}
|
||||
/>
|
||||
<WebhookSecretDialog
|
||||
open={Boolean(secret)}
|
||||
onOpenChange={(o) => !o && setSecret(null)}
|
||||
secret={secret}
|
||||
title={secretTitle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user