feat: laundry-list polish pass
Seven bundled improvements: - PartModel combobox on Add Part + Log Repair (known MPN auto-fills; unknown reveals manufacturer picker for catalog upsert). - Host lifecycle: state (DEPLOYED/DEGRADED/TESTING) and stack (PRODUCTION/VETTING) fields, driven by external clients via the API. - Locations page redesigned as a 2-pane tree + bin grid with breadcrumb. - PENDING_REPAIR custody state: tech takes a SPARE into custody for a future swap; resolves to DEPLOYED via Repair or back to SPARE via a bin-required drop-off. - Move Category from Part to PartModel; seed common categories (GPU/RAM/SSD/HDD/NIC/CPU/PSU/MOBO). Parts table gets a Category column and filter sourced from the model. - Fix Deployed Value 100x bug on the Dashboard (price is stored as dollars, not cents). - PartModels table shows "No" instead of "--" when destroyOnFail=false. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Check, ChevronsUpDown, Plus, X } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
cn,
|
||||
} from '@vector/ui';
|
||||
import { listPartModels } from '../../lib/api/part-models.js';
|
||||
import { queryKeys } from '../../lib/queryKeys.js';
|
||||
import type { PartModel } from '../../lib/api/types.js';
|
||||
|
||||
// Async combobox over the PartModel catalog. Two outputs:
|
||||
// - onPick(model): user chose an existing PartModel — the form should hide the manufacturer
|
||||
// field and send { partModelId } at submit time.
|
||||
// - onCreateNew(mpn): user typed an MPN not in the catalog and picked the "Create new" row —
|
||||
// the form should reveal the manufacturer picker and send { mpn, manufacturerId } at submit
|
||||
// time so partModels.upsertByMpn provisions the row.
|
||||
interface PartModelComboboxProps {
|
||||
value: PartModel | null;
|
||||
newMpn: string | null;
|
||||
onPick: (model: PartModel) => void;
|
||||
onCreateNew: (mpn: string) => void;
|
||||
onClear: () => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function PartModelCombobox({
|
||||
value,
|
||||
newMpn,
|
||||
onPick,
|
||||
onCreateNew,
|
||||
onClear,
|
||||
disabled,
|
||||
placeholder = 'Search MPN…',
|
||||
}: PartModelComboboxProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [debounced, setDebounced] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebounced(search.trim()), 200);
|
||||
return () => clearTimeout(t);
|
||||
}, [search]);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.partModels.list({ q: debounced, pageSize: 20 }),
|
||||
queryFn: () => listPartModels({ q: debounced || undefined, pageSize: 20 }),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const results = useMemo(() => query.data?.data ?? [], [query.data]);
|
||||
|
||||
const typed = search.trim();
|
||||
const hasExactMatch = results.some(
|
||||
(m) => m.mpn.toLowerCase() === typed.toLowerCase(),
|
||||
);
|
||||
const canCreate = typed.length > 0 && !hasExactMatch;
|
||||
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const label = value
|
||||
? `${value.manufacturer?.name ?? ''} — ${value.mpn}`
|
||||
: newMpn
|
||||
? `New model: ${newMpn}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Popover open={open} onOpenChange={(o) => !disabled && setOpen(o)}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex-1 justify-between font-normal',
|
||||
!value && !newMpn && 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{label || placeholder}</span>
|
||||
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
align="start"
|
||||
style={{ width: triggerRef.current?.offsetWidth }}
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder="Type MPN…"
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{query.isLoading ? 'Searching…' : 'No models found.'}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{results.map((m) => (
|
||||
<CommandItem
|
||||
key={m.id}
|
||||
value={m.id}
|
||||
onSelect={() => {
|
||||
onPick(m);
|
||||
setSearch('');
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="flex-1 truncate">
|
||||
<span className="text-muted-foreground">
|
||||
{m.manufacturer?.name ?? '—'} —{' '}
|
||||
</span>
|
||||
<span className="font-mono">{m.mpn}</span>
|
||||
</span>
|
||||
<Check
|
||||
className={cn(
|
||||
'ml-auto h-4 w-4 opacity-0',
|
||||
value?.id === m.id && 'opacity-100',
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
{canCreate && (
|
||||
<CommandItem
|
||||
value={`__create__${typed}`}
|
||||
onSelect={() => {
|
||||
onCreateNew(typed);
|
||||
setSearch('');
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Create new model: <span className="font-mono">{typed}</span>
|
||||
</CommandItem>
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{(value || newMpn) && !disabled && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 shrink-0"
|
||||
onClick={onClear}
|
||||
aria-label="Clear selection"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -43,17 +43,23 @@ export function DropOffDialog({ part, onOpenChange, onConfirm, pending }: DropOf
|
||||
});
|
||||
|
||||
const destruction = part?.state === 'PENDING_DESTRUCTION_IN_CUSTODY';
|
||||
// Spares returned from custody must land in a bin — we don't have a useful "in limbo"
|
||||
// SPARE state. Destruction / broken drop-offs still allow an unassigned bin.
|
||||
const returningSpare = part?.state === 'PENDING_REPAIR';
|
||||
const title = returningSpare ? 'Return spare to bin' : 'Drop in bin';
|
||||
const description = returningSpare
|
||||
? `Return ${part?.serialNumber ?? ''} to inventory. Choose a bin — required when returning a spare.`
|
||||
: destruction
|
||||
? 'This part is flagged for destruction. It will move to pending-destruction — optionally place it in a destruction bin.'
|
||||
: `Dropping ${part?.serialNumber ?? ''} into a bin marks it broken.`;
|
||||
const confirmDisabled = pending || (returningSpare && !binId);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Drop in bin</DialogTitle>
|
||||
<DialogDescription>
|
||||
{destruction
|
||||
? 'This part is flagged for destruction. It will move to pending-destruction — optionally place it in a destruction bin.'
|
||||
: `Dropping ${part?.serialNumber ?? ''} into a bin marks it broken.`}
|
||||
</DialogDescription>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
@@ -62,10 +68,10 @@ export function DropOffDialog({ part, onOpenChange, onConfirm, pending }: DropOf
|
||||
onValueChange={(v) => setBinId(v === UNASSIGNED ? '' : v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Unassigned" />
|
||||
<SelectValue placeholder={returningSpare ? 'Select a bin' : 'Unassigned'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={UNASSIGNED}>Unassigned</SelectItem>
|
||||
{!returningSpare && <SelectItem value={UNASSIGNED}>Unassigned</SelectItem>}
|
||||
{bins.data?.data.map((b) => (
|
||||
<SelectItem key={b.id} value={b.id}>
|
||||
{b.fullPath ?? b.name}
|
||||
@@ -84,9 +90,9 @@ export function DropOffDialog({ part, onOpenChange, onConfirm, pending }: DropOf
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => onConfirm(binId || null)} disabled={pending}>
|
||||
<Button onClick={() => onConfirm(binId || null)} disabled={confirmDisabled}>
|
||||
{pending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Drop off
|
||||
{returningSpare ? 'Return' : 'Drop off'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { z } from 'zod';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { HostStack, HostState } from '@vector/shared';
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
@@ -20,6 +21,11 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Textarea,
|
||||
} from '@vector/ui';
|
||||
import { createHost, updateHost } from '../../lib/api/hosts.js';
|
||||
@@ -32,9 +38,22 @@ const Schema = z.object({
|
||||
name: z.string().min(1, 'Required').max(128),
|
||||
location: z.string().max(256).optional(),
|
||||
notes: z.string().max(4096).optional(),
|
||||
state: HostState,
|
||||
stack: HostStack,
|
||||
});
|
||||
type Values = z.infer<typeof Schema>;
|
||||
|
||||
const STATE_LABELS: Record<z.infer<typeof HostState>, string> = {
|
||||
DEPLOYED: 'Deployed',
|
||||
DEGRADED: 'Degraded',
|
||||
TESTING: 'Testing',
|
||||
};
|
||||
|
||||
const STACK_LABELS: Record<z.infer<typeof HostStack>, string> = {
|
||||
PRODUCTION: 'Production',
|
||||
VETTING: 'Vetting',
|
||||
};
|
||||
|
||||
interface HostFormDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -47,7 +66,14 @@ export function HostFormDialog({ open, onOpenChange, host }: HostFormDialogProps
|
||||
|
||||
const form = useForm<Values>({
|
||||
resolver: zodResolver(Schema),
|
||||
defaultValues: { assetId: '', name: '', location: '', notes: '' },
|
||||
defaultValues: {
|
||||
assetId: '',
|
||||
name: '',
|
||||
location: '',
|
||||
notes: '',
|
||||
state: 'DEPLOYED',
|
||||
stack: 'PRODUCTION',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -57,6 +83,8 @@ export function HostFormDialog({ open, onOpenChange, host }: HostFormDialogProps
|
||||
name: host?.name ?? '',
|
||||
location: host?.location ?? '',
|
||||
notes: host?.notes ?? '',
|
||||
state: host?.state ?? 'DEPLOYED',
|
||||
stack: host?.stack ?? 'PRODUCTION',
|
||||
});
|
||||
}, [open, host, form]);
|
||||
|
||||
@@ -68,6 +96,8 @@ export function HostFormDialog({ open, onOpenChange, host }: HostFormDialogProps
|
||||
name: values.name,
|
||||
location: values.location ? values.location : null,
|
||||
notes: values.notes ? values.notes : null,
|
||||
state: values.state,
|
||||
stack: values.stack,
|
||||
});
|
||||
}
|
||||
return createHost({
|
||||
@@ -75,6 +105,8 @@ export function HostFormDialog({ open, onOpenChange, host }: HostFormDialogProps
|
||||
name: values.name,
|
||||
location: values.location ? values.location : null,
|
||||
notes: values.notes ? values.notes : null,
|
||||
state: values.state,
|
||||
stack: values.stack,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
@@ -137,6 +169,56 @@ export function HostFormDialog({ open, onOpenChange, host }: HostFormDialogProps
|
||||
</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>
|
||||
{HostState.options.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{STATE_LABELS[s]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="stack"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Stack</FormLabel>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{HostStack.options.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{STACK_LABELS[s]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notes"
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
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,434 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Building2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
DoorOpen,
|
||||
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 { 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, Site } from '../../lib/api/types.js';
|
||||
|
||||
// A single tree view combining the former SiteList and RoomDrawer. Sites expand to show their
|
||||
// rooms inline; the whole thing shares the same URL state (?site=&room=) so deep links still
|
||||
// resolve. Each row keeps its inline rename/delete action; creation happens per level.
|
||||
interface SiteRoomTreeProps {
|
||||
siteId: string | null;
|
||||
roomId: string | null;
|
||||
onSelectSite: (id: string) => void;
|
||||
onSelectRoom: (id: string) => void;
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
type RenameTarget = { kind: 'site'; value: Site } | { kind: 'room'; value: Room };
|
||||
type DeleteTarget = { kind: 'site'; value: Site } | { kind: 'room'; value: Room };
|
||||
|
||||
export function SiteRoomTree({
|
||||
siteId,
|
||||
roomId,
|
||||
onSelectSite,
|
||||
onSelectRoom,
|
||||
canEdit,
|
||||
}: SiteRoomTreeProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [creatingSite, setCreatingSite] = useState(false);
|
||||
const [creatingRoomInSite, setCreatingRoomInSite] = useState<string | null>(null);
|
||||
const [renaming, setRenaming] = useState<RenameTarget | null>(null);
|
||||
const [deleting, setDeleting] = useState<DeleteTarget | null>(null);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
|
||||
const sites = useQuery({
|
||||
queryKey: queryKeys.sites.list({ pageSize: 100 }),
|
||||
queryFn: () => listSites({ pageSize: 100 }),
|
||||
});
|
||||
|
||||
// Ensure the selected site is expanded on load / deep link.
|
||||
useEffect(() => {
|
||||
if (siteId) setExpanded((prev) => (prev.has(siteId) ? prev : new Set(prev).add(siteId)));
|
||||
}, [siteId]);
|
||||
|
||||
const siteIds = useMemo(() => {
|
||||
const list = sites.data?.data ?? [];
|
||||
return list.filter((s) => expanded.has(s.id)).map((s) => s.id);
|
||||
}, [sites.data, expanded]);
|
||||
|
||||
const roomQueries = useQueries({
|
||||
queries: siteIds.map((id) => ({
|
||||
queryKey: queryKeys.rooms.list({ siteId: id, pageSize: 100 }),
|
||||
queryFn: () => listRooms({ siteId: id, pageSize: 100 }),
|
||||
})),
|
||||
});
|
||||
const roomsBySite = useMemo(() => {
|
||||
const m = new Map<string, Room[]>();
|
||||
siteIds.forEach((id, i) => {
|
||||
m.set(id, roomQueries[i]?.data?.data ?? []);
|
||||
});
|
||||
return m;
|
||||
}, [siteIds, roomQueries]);
|
||||
|
||||
const invalidateSites = () =>
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sites.all });
|
||||
const invalidateRooms = () =>
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.rooms.all });
|
||||
|
||||
const createSiteMutation = useMutation({
|
||||
mutationFn: (name: string) => createSite({ name }),
|
||||
onSuccess: (s) => {
|
||||
toast.success('Site created');
|
||||
invalidateSites();
|
||||
setCreatingSite(false);
|
||||
setExpanded((prev) => new Set(prev).add(s.id));
|
||||
onSelectSite(s.id);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Create failed'),
|
||||
});
|
||||
|
||||
const createRoomMutation = useMutation({
|
||||
mutationFn: (vars: { siteId: string; name: string }) =>
|
||||
createRoom({ name: vars.name, siteId: vars.siteId }),
|
||||
onSuccess: (r) => {
|
||||
toast.success('Room created');
|
||||
invalidateRooms();
|
||||
setCreatingRoomInSite(null);
|
||||
onSelectRoom(r.id);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Create failed'),
|
||||
});
|
||||
|
||||
const renameSiteMutation = useMutation({
|
||||
mutationFn: (vars: { id: string; name: string }) => updateSite(vars.id, { name: vars.name }),
|
||||
onSuccess: () => {
|
||||
toast.success('Site renamed');
|
||||
invalidateSites();
|
||||
setRenaming(null);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Rename failed'),
|
||||
});
|
||||
|
||||
const renameRoomMutation = useMutation({
|
||||
mutationFn: (vars: { id: string; name: string }) => updateRoom(vars.id, { name: vars.name }),
|
||||
onSuccess: () => {
|
||||
toast.success('Room renamed');
|
||||
invalidateRooms();
|
||||
setRenaming(null);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Rename failed'),
|
||||
});
|
||||
|
||||
const deleteSiteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteSite(id),
|
||||
onSuccess: (_, id) => {
|
||||
toast.success('Site deleted');
|
||||
invalidateSites();
|
||||
invalidateRooms();
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.bins.all });
|
||||
setDeleting(null);
|
||||
if (siteId === id) onSelectSite('');
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed'),
|
||||
});
|
||||
|
||||
const deleteRoomMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteRoom(id),
|
||||
onSuccess: (_, id) => {
|
||||
toast.success('Room deleted');
|
||||
invalidateRooms();
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.bins.all });
|
||||
setDeleting(null);
|
||||
if (roomId === id) onSelectRoom('');
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed'),
|
||||
});
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const hasSites = Boolean(sites.data && sites.data.data.length > 0);
|
||||
|
||||
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 && hasSites && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => setCreatingSite(true)}
|
||||
aria-label="Add site"
|
||||
>
|
||||
<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>
|
||||
) : !hasSites ? (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-muted-foreground">
|
||||
<Building2 className="h-5 w-5" />
|
||||
<span className="text-xs">No sites yet</span>
|
||||
{canEdit && (
|
||||
<Button size="sm" variant="outline" onClick={() => setCreatingSite(true)}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add site
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{sites.data!.data.map((s) => {
|
||||
const isOpen = expanded.has(s.id);
|
||||
const siteActive = s.id === siteId;
|
||||
const rooms = roomsBySite.get(s.id) ?? [];
|
||||
return (
|
||||
<li key={s.id}>
|
||||
<div
|
||||
className={cn(
|
||||
'group flex items-center rounded-md text-sm',
|
||||
siteActive
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-foreground hover:bg-accent/60',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(s.id)}
|
||||
className="flex h-8 w-7 items-center justify-center text-muted-foreground"
|
||||
aria-label={isOpen ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
{isOpen ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelectSite(s.id);
|
||||
setExpanded((prev) => new Set(prev).add(s.id));
|
||||
}}
|
||||
className="flex flex-1 items-center gap-2 py-1.5 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="mr-1 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-40">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setCreatingRoomInSite(s.id)}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add room
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setRenaming({ kind: 'site', value: s })}
|
||||
>
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setDeleting({ kind: 'site', value: s })}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<ul className="ml-6 mt-0.5 space-y-0.5 border-l border-border pl-1">
|
||||
{rooms.length === 0 ? (
|
||||
<li className="px-2 py-1 text-xs text-muted-foreground">
|
||||
No rooms yet
|
||||
{canEdit && (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="h-auto px-1.5 py-0 text-xs"
|
||||
onClick={() => setCreatingRoomInSite(s.id)}
|
||||
>
|
||||
+ add
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
) : (
|
||||
rooms.map((r) => {
|
||||
const roomActive = r.id === roomId;
|
||||
return (
|
||||
<li key={r.id}>
|
||||
<div
|
||||
className={cn(
|
||||
'group flex items-center rounded-md text-sm',
|
||||
roomActive
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-foreground hover:bg-accent/60',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectRoom(r.id)}
|
||||
className="flex flex-1 items-center gap-2 py-1.5 pl-2 text-left"
|
||||
>
|
||||
<DoorOpen className="h-3.5 w-3.5 opacity-70" />
|
||||
<span className="truncate">{r.name}</span>
|
||||
</button>
|
||||
{canEdit && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mr-1 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({ kind: 'room', value: r })
|
||||
}
|
||||
>
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
setDeleting({ kind: 'room', value: r })
|
||||
}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<NamePromptDialog
|
||||
open={creatingSite}
|
||||
onOpenChange={setCreatingSite}
|
||||
title="New site"
|
||||
label="Site name"
|
||||
confirmLabel="Create"
|
||||
pending={createSiteMutation.isPending}
|
||||
onSubmit={(name) => createSiteMutation.mutate(name)}
|
||||
/>
|
||||
<NamePromptDialog
|
||||
open={Boolean(creatingRoomInSite)}
|
||||
onOpenChange={(o) => !o && setCreatingRoomInSite(null)}
|
||||
title="New room"
|
||||
label="Room name"
|
||||
confirmLabel="Create"
|
||||
pending={createRoomMutation.isPending}
|
||||
onSubmit={(name) =>
|
||||
creatingRoomInSite &&
|
||||
createRoomMutation.mutate({ siteId: creatingRoomInSite, name })
|
||||
}
|
||||
/>
|
||||
<NamePromptDialog
|
||||
open={Boolean(renaming)}
|
||||
onOpenChange={(o) => !o && setRenaming(null)}
|
||||
title={renaming?.kind === 'site' ? 'Rename site' : 'Rename room'}
|
||||
label={renaming?.kind === 'site' ? 'Site name' : 'Room name'}
|
||||
confirmLabel="Rename"
|
||||
initialValue={renaming?.value.name ?? ''}
|
||||
pending={renameSiteMutation.isPending || renameRoomMutation.isPending}
|
||||
onSubmit={(name) => {
|
||||
if (!renaming) return;
|
||||
if (renaming.kind === 'site') {
|
||||
renameSiteMutation.mutate({ id: renaming.value.id, name });
|
||||
} else {
|
||||
renameRoomMutation.mutate({ id: renaming.value.id, name });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleting)}
|
||||
onOpenChange={(o) => !o && setDeleting(null)}
|
||||
title={deleting?.kind === 'site' ? 'Delete site?' : 'Delete room?'}
|
||||
description={
|
||||
deleting
|
||||
? deleting.kind === 'site'
|
||||
? `Remove ${deleting.value.name}. All rooms and bins inside will be deleted too. Parts in those bins become unassigned.`
|
||||
: `Remove ${deleting.value.name}. All bins inside will be deleted too. Parts in those bins become unassigned.`
|
||||
: undefined
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
destructive
|
||||
pending={deleteSiteMutation.isPending || deleteRoomMutation.isPending}
|
||||
onConfirm={() => {
|
||||
if (!deleting) return;
|
||||
if (deleting.kind === 'site') deleteSiteMutation.mutate(deleting.value.id);
|
||||
else deleteRoomMutation.mutate(deleting.value.id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from '@vector/ui';
|
||||
import { createPartModel, updatePartModel } from '../../lib/api/part-models.js';
|
||||
import { listManufacturers } from '../../lib/api/manufacturers.js';
|
||||
import { createCategory, listCategories } from '../../lib/api/categories.js';
|
||||
import { ApiRequestError } from '../../lib/api/client.js';
|
||||
import { queryKeys } from '../../lib/queryKeys.js';
|
||||
import type { PartModel } from '../../lib/api/types.js';
|
||||
@@ -38,6 +39,7 @@ import type { PartModel } from '../../lib/api/types.js';
|
||||
const Schema = z.object({
|
||||
manufacturerId: z.string().uuid('Pick a manufacturer'),
|
||||
mpn: z.string().min(1, 'Required').max(128),
|
||||
categoryId: z.string().optional(), // '' = unassigned
|
||||
eolDate: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, 'YYYY-MM-DD')
|
||||
@@ -48,6 +50,8 @@ const Schema = z.object({
|
||||
});
|
||||
type Values = z.infer<typeof Schema>;
|
||||
|
||||
const UNASSIGNED = '__none__';
|
||||
|
||||
function isoToDateInput(iso: string | null): string {
|
||||
if (!iso) return '';
|
||||
return new Date(iso).toISOString().slice(0, 10);
|
||||
@@ -72,6 +76,7 @@ export function PartModelFormDialog({
|
||||
defaultValues: {
|
||||
manufacturerId: '',
|
||||
mpn: '',
|
||||
categoryId: '',
|
||||
eolDate: '',
|
||||
destroyOnFail: false,
|
||||
notes: '',
|
||||
@@ -83,6 +88,7 @@ export function PartModelFormDialog({
|
||||
form.reset({
|
||||
manufacturerId: partModel?.manufacturerId ?? '',
|
||||
mpn: partModel?.mpn ?? '',
|
||||
categoryId: partModel?.categoryId ?? '',
|
||||
eolDate: isoToDateInput(partModel?.eolDate ?? null),
|
||||
destroyOnFail: partModel?.destroyOnFail ?? false,
|
||||
notes: partModel?.notes ?? '',
|
||||
@@ -95,11 +101,28 @@ export function PartModelFormDialog({
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const categories = useQuery({
|
||||
queryKey: queryKeys.categories.list({ pageSize: 100 }),
|
||||
queryFn: () => listCategories({ pageSize: 100 }),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const createCategoryMutation = useMutation({
|
||||
mutationFn: (name: string) => createCategory({ name }),
|
||||
onSuccess: (cat) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.categories.all });
|
||||
form.setValue('categoryId', cat.id);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Could not add category'),
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: Values) => {
|
||||
const payload = {
|
||||
manufacturerId: values.manufacturerId,
|
||||
mpn: values.mpn,
|
||||
categoryId: values.categoryId ? values.categoryId : null,
|
||||
eolDate: values.eolDate ? values.eolDate : null,
|
||||
destroyOnFail: values.destroyOnFail,
|
||||
notes: values.notes ? values.notes : null,
|
||||
@@ -166,6 +189,47 @@ export function PartModelFormDialog({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="categoryId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Category</FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={field.value ? field.value : UNASSIGNED}
|
||||
onValueChange={(v) => {
|
||||
if (v === '__new__') {
|
||||
const name = window.prompt('New category name')?.trim();
|
||||
if (name) createCategoryMutation.mutate(name);
|
||||
return;
|
||||
}
|
||||
field.onChange(v === UNASSIGNED ? '' : v);
|
||||
}}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Unassigned" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={UNASSIGNED}>Unassigned</SelectItem>
|
||||
{categories.data?.data.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="__new__">+ Add category…</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<FormDescription>
|
||||
Groups like GPU / RAM / SSD describe this model family.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="eolDate"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
@@ -6,6 +6,8 @@ import { z } from 'zod';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { PartState } from '@vector/shared';
|
||||
import { PartModelCombobox } from '../common/PartModelCombobox.js';
|
||||
import type { PartModel } from '../../lib/api/types.js';
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
@@ -38,12 +40,13 @@ 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.
|
||||
// submit handler coerce to the network shape. The combobox drives partModelId xor (mpn+mfr).
|
||||
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'),
|
||||
partModelId: z.string().optional(), // set when an existing model is picked
|
||||
mpn: z.string().max(128).optional(), // set when creating a new model
|
||||
manufacturerId: z.string().optional(),
|
||||
state: PartState,
|
||||
binId: z.string().optional(), // '' = none
|
||||
hostId: z.string().optional(), // '' = none
|
||||
@@ -51,6 +54,22 @@ const PartFormSchema = z
|
||||
notes: z.string().max(4096).optional(),
|
||||
})
|
||||
.superRefine((v, ctx) => {
|
||||
const hasModel = Boolean(v.partModelId);
|
||||
const hasNew = Boolean(v.mpn && v.mpn.length > 0);
|
||||
if (!hasModel && !hasNew) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Pick a part model or enter a new MPN',
|
||||
path: ['partModelId'],
|
||||
});
|
||||
}
|
||||
if (hasNew && !v.manufacturerId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Select a manufacturer for the new model',
|
||||
path: ['manufacturerId'],
|
||||
});
|
||||
}
|
||||
if (v.state === 'DEPLOYED' && !v.hostId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
@@ -73,10 +92,13 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
|
||||
const editing = Boolean(part);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [pickedModel, setPickedModel] = useState<PartModel | null>(null);
|
||||
|
||||
const form = useForm<PartFormValues>({
|
||||
resolver: zodResolver(PartFormSchema),
|
||||
defaultValues: {
|
||||
serialNumber: '',
|
||||
partModelId: '',
|
||||
mpn: '',
|
||||
manufacturerId: '',
|
||||
state: 'SPARE',
|
||||
@@ -89,29 +111,33 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
form.reset(
|
||||
part
|
||||
? {
|
||||
serialNumber: part.serialNumber,
|
||||
mpn: part.partModel.mpn,
|
||||
manufacturerId: part.manufacturerId,
|
||||
state: part.state,
|
||||
binId: part.binId ?? '',
|
||||
hostId: part.hostId ?? '',
|
||||
price: part.price != null ? String(part.price) : '',
|
||||
notes: part.notes ?? '',
|
||||
}
|
||||
: {
|
||||
serialNumber: '',
|
||||
mpn: '',
|
||||
manufacturerId: '',
|
||||
state: 'SPARE',
|
||||
binId: '',
|
||||
hostId: '',
|
||||
price: '',
|
||||
notes: '',
|
||||
},
|
||||
);
|
||||
if (part) {
|
||||
setPickedModel(part.partModel ?? null);
|
||||
form.reset({
|
||||
serialNumber: part.serialNumber,
|
||||
partModelId: part.partModelId,
|
||||
mpn: '',
|
||||
manufacturerId: '',
|
||||
state: part.state,
|
||||
binId: part.binId ?? '',
|
||||
hostId: part.hostId ?? '',
|
||||
price: part.price != null ? String(part.price) : '',
|
||||
notes: part.notes ?? '',
|
||||
});
|
||||
} else {
|
||||
setPickedModel(null);
|
||||
form.reset({
|
||||
serialNumber: '',
|
||||
partModelId: '',
|
||||
mpn: '',
|
||||
manufacturerId: '',
|
||||
state: 'SPARE',
|
||||
binId: '',
|
||||
hostId: '',
|
||||
price: '',
|
||||
notes: '',
|
||||
});
|
||||
}
|
||||
}, [open, part, form]);
|
||||
|
||||
const watchedState = form.watch('state');
|
||||
@@ -137,16 +163,18 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: PartFormValues) => {
|
||||
const deployed = values.state === 'DEPLOYED';
|
||||
const payload = {
|
||||
const base = {
|
||||
serialNumber: values.serialNumber,
|
||||
mpn: values.mpn,
|
||||
manufacturerId: values.manufacturerId,
|
||||
state: values.state,
|
||||
binId: deployed ? null : values.binId ? values.binId : null,
|
||||
hostId: deployed ? (values.hostId ? values.hostId : null) : null,
|
||||
price: values.price === '' ? null : Number(values.price),
|
||||
notes: values.notes ? values.notes : null,
|
||||
};
|
||||
const modelPayload = values.partModelId
|
||||
? { partModelId: values.partModelId }
|
||||
: { mpn: values.mpn!, manufacturerId: values.manufacturerId! };
|
||||
const payload = { ...base, ...modelPayload };
|
||||
return editing && part
|
||||
? updatePart(part.id, payload)
|
||||
: createPart(payload);
|
||||
@@ -191,60 +219,79 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
|
||||
|
||||
<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"
|
||||
name="serialNumber"
|
||||
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>
|
||||
<FormLabel>Serial</FormLabel>
|
||||
<FormControl>
|
||||
<Input autoFocus {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="partModelId"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>Part model</FormLabel>
|
||||
<PartModelCombobox
|
||||
value={pickedModel}
|
||||
newMpn={form.watch('mpn') || null}
|
||||
onPick={(m) => {
|
||||
setPickedModel(m);
|
||||
form.setValue('partModelId', m.id, { shouldValidate: true });
|
||||
form.setValue('mpn', '');
|
||||
form.setValue('manufacturerId', '');
|
||||
}}
|
||||
onCreateNew={(mpn) => {
|
||||
setPickedModel(null);
|
||||
form.setValue('partModelId', '');
|
||||
form.setValue('mpn', mpn, { shouldValidate: true });
|
||||
}}
|
||||
onClear={() => {
|
||||
setPickedModel(null);
|
||||
form.setValue('partModelId', '');
|
||||
form.setValue('mpn', '');
|
||||
form.setValue('manufacturerId', '');
|
||||
}}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!pickedModel && form.watch('mpn') && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="manufacturerId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Manufacturer (for new model)</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}
|
||||
|
||||
@@ -8,6 +8,7 @@ const STATE_LABEL: Record<PartState, string> = {
|
||||
PENDING_DESTRUCTION: 'Pending destruction',
|
||||
PENDING_DROP_IN_CUSTODY: 'In custody',
|
||||
PENDING_DESTRUCTION_IN_CUSTODY: 'In custody (destroy)',
|
||||
PENDING_REPAIR: 'Held for repair',
|
||||
};
|
||||
|
||||
const STATE_VARIANT: Record<PartState, BadgeProps['variant']> = {
|
||||
@@ -17,6 +18,7 @@ const STATE_VARIANT: Record<PartState, BadgeProps['variant']> = {
|
||||
PENDING_DESTRUCTION: 'destructive',
|
||||
PENDING_DROP_IN_CUSTODY: 'outline',
|
||||
PENDING_DESTRUCTION_IN_CUSTODY: 'outline',
|
||||
PENDING_REPAIR: 'outline',
|
||||
};
|
||||
|
||||
export function PartStateBadge({ state }: { state: PartState }) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
@@ -34,16 +34,42 @@ import { listFms } from '../../lib/api/fms.js';
|
||||
import { listParts } from '../../lib/api/parts.js';
|
||||
import { ApiRequestError } from '../../lib/api/client.js';
|
||||
import { queryKeys } from '../../lib/queryKeys.js';
|
||||
import type { Repair } from '../../lib/api/types.js';
|
||||
import type { PartModel, Repair } from '../../lib/api/types.js';
|
||||
import { PartModelCombobox } from '../common/PartModelCombobox.js';
|
||||
|
||||
const Schema = z.object({
|
||||
hostId: z.string().uuid('Pick a host'),
|
||||
brokenSerial: z.string().trim().min(1, 'Required').max(128),
|
||||
brokenMpn: z.string().trim().min(1, 'Required').max(128),
|
||||
brokenManufacturerId: z.string().uuid('Select a manufacturer'),
|
||||
replacementSerial: z.string().trim().min(1, 'Required').max(128),
|
||||
fmId: z.string().optional(),
|
||||
});
|
||||
// When the broken serial matches an existing Part the model fields are skipped entirely;
|
||||
// otherwise the tech either picks an existing PartModel (partModelId) or types a new MPN
|
||||
// and a manufacturer. The refine mirrors LogRepairRequest.superRefine on the server.
|
||||
const Schema = z
|
||||
.object({
|
||||
hostId: z.string().uuid('Pick a host'),
|
||||
brokenSerial: z.string().trim().min(1, 'Required').max(128),
|
||||
brokenPartModelId: z.string().uuid().optional(),
|
||||
brokenMpn: z.string().trim().max(128).optional(),
|
||||
brokenManufacturerId: z.string().uuid().optional(),
|
||||
replacementSerial: z.string().trim().min(1, 'Required').max(128),
|
||||
fmId: z.string().optional(),
|
||||
brokenExists: z.boolean().optional(),
|
||||
})
|
||||
.superRefine((v, ctx) => {
|
||||
if (v.brokenExists) return;
|
||||
const hasModel = Boolean(v.brokenPartModelId);
|
||||
const hasNew = Boolean(v.brokenMpn && v.brokenMpn.length > 0);
|
||||
if (!hasModel && !hasNew) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Pick a part model or enter a new MPN',
|
||||
path: ['brokenPartModelId'],
|
||||
});
|
||||
}
|
||||
if (hasNew && !v.brokenManufacturerId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Select a manufacturer for the new model',
|
||||
path: ['brokenManufacturerId'],
|
||||
});
|
||||
}
|
||||
});
|
||||
type Values = z.infer<typeof Schema>;
|
||||
|
||||
const NO_FM = '__none__';
|
||||
@@ -57,27 +83,34 @@ interface LogRepairDialogProps {
|
||||
export function LogRepairDialog({ open, onOpenChange, onLogged }: LogRepairDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [pickedModel, setPickedModel] = useState<PartModel | null>(null);
|
||||
|
||||
const form = useForm<Values>({
|
||||
resolver: zodResolver(Schema),
|
||||
defaultValues: {
|
||||
hostId: '',
|
||||
brokenSerial: '',
|
||||
brokenPartModelId: '',
|
||||
brokenMpn: '',
|
||||
brokenManufacturerId: '',
|
||||
replacementSerial: '',
|
||||
fmId: '',
|
||||
brokenExists: false,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setPickedModel(null);
|
||||
form.reset({
|
||||
hostId: '',
|
||||
brokenSerial: '',
|
||||
brokenPartModelId: '',
|
||||
brokenMpn: '',
|
||||
brokenManufacturerId: '',
|
||||
replacementSerial: '',
|
||||
fmId: '',
|
||||
brokenExists: false,
|
||||
});
|
||||
}, [open, form]);
|
||||
|
||||
@@ -115,16 +148,30 @@ export function LogRepairDialog({ open, onOpenChange, onLogged }: LogRepairDialo
|
||||
(p) => p.serialNumber === brokenSerial,
|
||||
);
|
||||
|
||||
// Keep a form-level flag so the zod refine can skip model validation when the broken part
|
||||
// is already in the catalog (server just reuses the existing PartModel).
|
||||
useEffect(() => {
|
||||
form.setValue('brokenExists', Boolean(existingBroken), { shouldValidate: true });
|
||||
}, [existingBroken, form]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (v: Values) =>
|
||||
logRepair({
|
||||
mutationFn: (v: Values) => {
|
||||
const base = {
|
||||
hostId: v.hostId,
|
||||
brokenSerial: v.brokenSerial.trim(),
|
||||
brokenMpn: v.brokenMpn.trim(),
|
||||
brokenManufacturerId: v.brokenManufacturerId,
|
||||
replacementSerial: v.replacementSerial.trim(),
|
||||
fmId: v.fmId ? v.fmId : undefined,
|
||||
}),
|
||||
};
|
||||
// If the broken part is already catalogued, the server ignores model fields entirely.
|
||||
if (existingBroken) return logRepair(base);
|
||||
const modelPayload = v.brokenPartModelId
|
||||
? { brokenPartModelId: v.brokenPartModelId }
|
||||
: {
|
||||
brokenMpn: v.brokenMpn?.trim(),
|
||||
brokenManufacturerId: v.brokenManufacturerId,
|
||||
};
|
||||
return logRepair({ ...base, ...modelPayload });
|
||||
},
|
||||
onSuccess: (repair) => {
|
||||
toast.success('Repair logged');
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.repairs.all });
|
||||
@@ -217,45 +264,68 @@ export function LogRepairDialog({ open, onOpenChange, onLogged }: LogRepairDialo
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brokenMpn"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Broken MPN</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
{!existingBroken && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brokenPartModelId"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>Broken part model</FormLabel>
|
||||
<PartModelCombobox
|
||||
value={pickedModel}
|
||||
newMpn={form.watch('brokenMpn') || null}
|
||||
onPick={(m) => {
|
||||
setPickedModel(m);
|
||||
form.setValue('brokenPartModelId', m.id, { shouldValidate: true });
|
||||
form.setValue('brokenMpn', '');
|
||||
form.setValue('brokenManufacturerId', '');
|
||||
}}
|
||||
onCreateNew={(mpn) => {
|
||||
setPickedModel(null);
|
||||
form.setValue('brokenPartModelId', '');
|
||||
form.setValue('brokenMpn', mpn, { shouldValidate: true });
|
||||
}}
|
||||
onClear={() => {
|
||||
setPickedModel(null);
|
||||
form.setValue('brokenPartModelId', '');
|
||||
form.setValue('brokenMpn', '');
|
||||
form.setValue('brokenManufacturerId', '');
|
||||
}}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!pickedModel && form.watch('brokenMpn') && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brokenManufacturerId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Manufacturer (for new model)</FormLabel>
|
||||
<Select value={field.value ?? ''} onValueChange={field.onChange}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{manufacturers.data?.data.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brokenManufacturerId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Broken manufacturer</FormLabel>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{manufacturers.data?.data.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -11,3 +11,8 @@ export async function dropOff(partId: string, input: DropOffRequest): Promise<Pa
|
||||
const res = await api.post<Part>(`/custody/${partId}/drop-off`, input);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function takeForRepair(partId: string): Promise<Part> {
|
||||
const res = await api.post<Part>(`/custody/${partId}/take-for-repair`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ export type HostListFilters = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
q?: string;
|
||||
state?: string;
|
||||
stack?: string;
|
||||
};
|
||||
|
||||
export function listHosts(filters: HostListFilters = {}) {
|
||||
|
||||
@@ -10,6 +10,7 @@ export type PartModelListFilters = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
manufacturerId?: string;
|
||||
categoryId?: string;
|
||||
q?: string;
|
||||
eolBefore?: string;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import type { FmStatus, PartEventType, PartState, Role } from '@vector/shared';
|
||||
import type {
|
||||
FmStatus,
|
||||
HostState,
|
||||
HostStack,
|
||||
PartEventType,
|
||||
PartState,
|
||||
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.
|
||||
@@ -14,12 +21,14 @@ export interface PartModel {
|
||||
id: string;
|
||||
manufacturerId: string;
|
||||
mpn: string;
|
||||
categoryId: string | null;
|
||||
eolDate: string | null;
|
||||
destroyOnFail: boolean;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
manufacturer?: Manufacturer;
|
||||
category?: Category | null;
|
||||
_count?: { parts: number };
|
||||
}
|
||||
|
||||
@@ -59,7 +68,6 @@ export interface Part {
|
||||
price: number | null;
|
||||
state: PartState;
|
||||
binId: string | null;
|
||||
categoryId: string | null;
|
||||
hostId: string | null;
|
||||
custodianId: string | null;
|
||||
notes: string | null;
|
||||
@@ -99,6 +107,8 @@ export interface Host {
|
||||
name: string;
|
||||
location: string | null;
|
||||
notes: string | null;
|
||||
state: HostState;
|
||||
stack: HostStack;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ const STATE_LABELS: Record<PartState, string> = {
|
||||
PENDING_DESTRUCTION: 'Pending destruction',
|
||||
PENDING_DROP_IN_CUSTODY: 'In custody',
|
||||
PENDING_DESTRUCTION_IN_CUSTODY: 'In custody (destroy)',
|
||||
PENDING_REPAIR: 'Held for repair',
|
||||
};
|
||||
|
||||
const STATE_COLORS: Record<PartState, string> = {
|
||||
@@ -44,10 +45,11 @@ const STATE_COLORS: Record<PartState, string> = {
|
||||
PENDING_DESTRUCTION: 'hsl(38 92% 50%)',
|
||||
PENDING_DROP_IN_CUSTODY: 'hsl(262 83% 58%)',
|
||||
PENDING_DESTRUCTION_IN_CUSTODY: 'hsl(340 82% 52%)',
|
||||
PENDING_REPAIR: 'hsl(197 80% 50%)',
|
||||
};
|
||||
|
||||
function currency(cents: number): string {
|
||||
return (cents / 100).toLocaleString(undefined, { style: 'currency', currency: 'USD' });
|
||||
function currency(dollars: number): string {
|
||||
return dollars.toLocaleString(undefined, { style: 'currency', currency: 'USD' });
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
@@ -159,7 +161,7 @@ export default function Dashboard() {
|
||||
.map((s) => ({
|
||||
name: STATE_LABELS[s.state],
|
||||
state: s.state,
|
||||
value: s.totalPrice / 100,
|
||||
value: s.totalPrice,
|
||||
}))}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Edit, MoreHorizontal, Plus, Server, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -55,6 +56,25 @@ export default function Hosts() {
|
||||
header: 'Name',
|
||||
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'state',
|
||||
header: 'State',
|
||||
cell: ({ row }) => {
|
||||
const s = row.original.state;
|
||||
const variant =
|
||||
s === 'DEPLOYED' ? 'secondary' : s === 'DEGRADED' ? 'destructive' : 'outline';
|
||||
return <Badge variant={variant}>{s}</Badge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'stack',
|
||||
header: 'Stack',
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{row.original.stack}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'location',
|
||||
header: 'Location',
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import { ChevronRight, MapPin } from 'lucide-react';
|
||||
import { PageHeader } from '../components/layout/PageHeader.js';
|
||||
import { SiteList } from '../components/locations/SiteList.js';
|
||||
import { RoomDrawer } from '../components/locations/RoomDrawer.js';
|
||||
import { SiteRoomTree } from '../components/locations/SiteRoomTree.js';
|
||||
import { BinGrid } from '../components/locations/BinGrid.js';
|
||||
import { listSites } from '../lib/api/sites.js';
|
||||
import { listRooms } from '../lib/api/rooms.js';
|
||||
import { queryKeys } from '../lib/queryKeys.js';
|
||||
import { useAuth } from '../contexts/AuthContext.js';
|
||||
|
||||
export default function Locations() {
|
||||
@@ -20,23 +25,94 @@ export default function Locations() {
|
||||
void setRoomId(id || null);
|
||||
};
|
||||
|
||||
const sites = useQuery({
|
||||
queryKey: queryKeys.sites.list({ pageSize: 100 }),
|
||||
queryFn: () => listSites({ pageSize: 100 }),
|
||||
});
|
||||
const rooms = useQuery({
|
||||
queryKey: queryKeys.rooms.list({ siteId, pageSize: 100 }),
|
||||
queryFn: () => listRooms({ siteId: siteId!, pageSize: 100 }),
|
||||
enabled: Boolean(siteId),
|
||||
});
|
||||
|
||||
const siteName = useMemo(
|
||||
() => sites.data?.data.find((s) => s.id === siteId)?.name,
|
||||
[sites.data, siteId],
|
||||
);
|
||||
const roomName = useMemo(
|
||||
() => rooms.data?.data.find((r) => r.id === roomId)?.name,
|
||||
[rooms.data, roomId],
|
||||
);
|
||||
|
||||
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."
|
||||
description="Sites → Rooms → Bins. Pick a room to see its bins."
|
||||
/>
|
||||
<div className="grid min-h-0 flex-1 grid-cols-[16rem_16rem_1fr] overflow-hidden rounded-lg border border-border bg-card">
|
||||
<div className="grid min-h-0 flex-1 grid-cols-[18rem_1fr] overflow-hidden rounded-lg border border-border bg-card">
|
||||
<div className="border-r border-border">
|
||||
<SiteList selectedId={siteId} onSelect={handleSite} canEdit={canEdit} />
|
||||
<SiteRoomTree
|
||||
siteId={siteId}
|
||||
roomId={roomId}
|
||||
onSelectSite={handleSite}
|
||||
onSelectRoom={handleRoom}
|
||||
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 className="flex min-h-0 flex-col">
|
||||
<Breadcrumb siteName={siteName} roomName={roomName} />
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{roomId ? (
|
||||
<BinGrid roomId={roomId} canEdit={canEdit} />
|
||||
) : (
|
||||
<EmptyPane siteSelected={Boolean(siteId)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Breadcrumb({
|
||||
siteName,
|
||||
roomName,
|
||||
}: {
|
||||
siteName: string | undefined;
|
||||
roomName: string | undefined;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 border-b border-border px-4 py-2 text-sm text-muted-foreground">
|
||||
{siteName ? (
|
||||
<>
|
||||
<span className="text-foreground">{siteName}</span>
|
||||
{roomName && (
|
||||
<>
|
||||
<ChevronRight className="h-3.5 w-3.5 opacity-60" />
|
||||
<span className="text-foreground">{roomName}</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span>Select a site to begin.</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyPane({ siteSelected }: { siteSelected: boolean }) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-8">
|
||||
<div className="flex max-w-sm flex-col items-center gap-2 rounded-lg border border-dashed border-border bg-muted/30 px-8 py-10 text-center">
|
||||
<MapPin className="h-6 w-6 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">
|
||||
{siteSelected ? 'Pick a room' : 'Pick a site and room'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Bins live inside rooms. Expand a site in the tree and choose a room to manage its bins.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 { Hand, PackageCheck } from 'lucide-react';
|
||||
import { Hand, PackageCheck, Undo2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@vector/ui';
|
||||
import { PageHeader } from '../components/layout/PageHeader.js';
|
||||
@@ -74,13 +74,24 @@ export default function MyCustody() {
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
size: 140,
|
||||
cell: ({ row }) => (
|
||||
<Button size="sm" variant="outline" onClick={() => setDropping(row.original)}>
|
||||
<PackageCheck className="h-3.5 w-3.5" />
|
||||
Drop in bin
|
||||
</Button>
|
||||
),
|
||||
size: 160,
|
||||
cell: ({ row }) => {
|
||||
const pending = row.original.state === 'PENDING_REPAIR';
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setDropping(row.original)}
|
||||
>
|
||||
{pending ? (
|
||||
<Undo2 className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<PackageCheck className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{pending ? 'Return to bin' : 'Drop in bin'}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
|
||||
@@ -58,6 +58,16 @@ export default function PartModels() {
|
||||
<span className="font-mono text-xs font-medium">{row.original.mpn}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'category',
|
||||
header: 'Category',
|
||||
cell: ({ row }) =>
|
||||
row.original.category ? (
|
||||
<Badge variant="outline">{row.original.category.name}</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'eolDate',
|
||||
header: 'EOL',
|
||||
@@ -87,7 +97,7 @@ export default function PartModels() {
|
||||
row.original.destroyOnFail ? (
|
||||
<Check className="h-4 w-4 text-foreground" />
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
<span className="text-xs text-muted-foreground">No</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 { Edit, HandHelping, MoreHorizontal, Package, Plus, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
@@ -26,7 +26,9 @@ 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 { listCategories } from '../lib/api/categories.js';
|
||||
import { listTags } from '../lib/api/tags.js';
|
||||
import { takeForRepair } from '../lib/api/custody.js';
|
||||
import { ApiRequestError } from '../lib/api/client.js';
|
||||
import type { Part } from '../lib/api/types.js';
|
||||
import { queryKeys } from '../lib/queryKeys.js';
|
||||
@@ -35,12 +37,14 @@ import { useAuth } from '../contexts/AuthContext.js';
|
||||
type PartsFilters = {
|
||||
state: string | null;
|
||||
manufacturerId: string | null;
|
||||
categoryId: string | null;
|
||||
tagId: string | null;
|
||||
};
|
||||
|
||||
const filterParsers = {
|
||||
state: parseAsString,
|
||||
manufacturerId: parseAsString,
|
||||
categoryId: parseAsString,
|
||||
tagId: parseAsString,
|
||||
};
|
||||
|
||||
@@ -62,6 +66,10 @@ export default function Parts() {
|
||||
queryKey: queryKeys.manufacturers.list({ pageSize: 100 }),
|
||||
queryFn: () => listManufacturers({ pageSize: 100 }),
|
||||
});
|
||||
const categoriesQuery = useQuery({
|
||||
queryKey: queryKeys.categories.list({ pageSize: 100 }),
|
||||
queryFn: () => listCategories({ pageSize: 100 }),
|
||||
});
|
||||
const tagsQuery = useQuery({
|
||||
queryKey: queryKeys.tags.list({ pageSize: 100 }),
|
||||
queryFn: () => listTags({ pageSize: 100 }),
|
||||
@@ -79,6 +87,17 @@ export default function Parts() {
|
||||
},
|
||||
});
|
||||
|
||||
const takeForRepairMutation = useMutation({
|
||||
mutationFn: (id: string) => takeForRepair(id),
|
||||
onSuccess: () => {
|
||||
toast.success('Part moved into your custody');
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.custody.all });
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Could not take part'),
|
||||
});
|
||||
|
||||
const columns = useMemo<ColumnDef<Part>[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -107,6 +126,18 @@ export default function Parts() {
|
||||
<span className="text-sm text-muted-foreground">{row.original.manufacturer.name}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'category',
|
||||
header: 'Category',
|
||||
cell: ({ row }) =>
|
||||
row.original.partModel.category ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{row.original.partModel.category.name}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'state',
|
||||
header: 'State',
|
||||
@@ -159,7 +190,7 @@ export default function Parts() {
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem onSelect={() => navigate(`/parts/${row.original.id}`)}>
|
||||
View
|
||||
</DropdownMenuItem>
|
||||
@@ -167,6 +198,15 @@ export default function Parts() {
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
{row.original.state === 'SPARE' && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => takeForRepairMutation.mutate(row.original.id)}
|
||||
disabled={takeForRepairMutation.isPending}
|
||||
>
|
||||
<HandHelping className="h-3.5 w-3.5" />
|
||||
Take into custody
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
@@ -184,7 +224,7 @@ export default function Parts() {
|
||||
),
|
||||
},
|
||||
],
|
||||
[navigate, isAdmin],
|
||||
[navigate, isAdmin, takeForRepairMutation],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -213,6 +253,7 @@ export default function Parts() {
|
||||
sort: params.sort,
|
||||
state: params.filters.state,
|
||||
manufacturerId: params.filters.manufacturerId,
|
||||
categoryId: params.filters.categoryId,
|
||||
tagId: params.filters.tagId,
|
||||
})
|
||||
}
|
||||
@@ -224,6 +265,7 @@ export default function Parts() {
|
||||
sort: params.sort,
|
||||
state: params.filters.state ?? undefined,
|
||||
manufacturerId: params.filters.manufacturerId ?? undefined,
|
||||
categoryId: params.filters.categoryId ?? undefined,
|
||||
tagId: params.filters.tagId ?? undefined,
|
||||
})
|
||||
}
|
||||
@@ -239,12 +281,15 @@ export default function Parts() {
|
||||
toolbar={({ filters, setFilter }) => (
|
||||
<PartsFilters
|
||||
manufacturers={manufacturers.data?.data ?? []}
|
||||
categories={categoriesQuery.data?.data ?? []}
|
||||
tags={tagsQuery.data?.data ?? []}
|
||||
state={filters.state ?? ALL}
|
||||
manufacturerId={filters.manufacturerId ?? ALL}
|
||||
categoryId={filters.categoryId ?? ALL}
|
||||
tagId={filters.tagId ?? ALL}
|
||||
onState={(v) => setFilter('state', v === ALL ? null : v)}
|
||||
onManufacturer={(v) => setFilter('manufacturerId', v === ALL ? null : v)}
|
||||
onCategory={(v) => setFilter('categoryId', v === ALL ? null : v)}
|
||||
onTag={(v) => setFilter('tagId', v === ALL ? null : v)}
|
||||
/>
|
||||
)}
|
||||
@@ -296,23 +341,29 @@ export default function Parts() {
|
||||
|
||||
interface PartsFiltersProps {
|
||||
manufacturers: { id: string; name: string }[];
|
||||
categories: { id: string; name: string }[];
|
||||
tags: { id: string; name: string }[];
|
||||
state: string;
|
||||
manufacturerId: string;
|
||||
categoryId: string;
|
||||
tagId: string;
|
||||
onState: (v: string) => void;
|
||||
onManufacturer: (v: string) => void;
|
||||
onCategory: (v: string) => void;
|
||||
onTag: (v: string) => void;
|
||||
}
|
||||
|
||||
function PartsFilters({
|
||||
manufacturers,
|
||||
categories,
|
||||
tags,
|
||||
state,
|
||||
manufacturerId,
|
||||
categoryId,
|
||||
tagId,
|
||||
onState,
|
||||
onManufacturer,
|
||||
onCategory,
|
||||
onTag,
|
||||
}: PartsFiltersProps) {
|
||||
return (
|
||||
@@ -343,6 +394,19 @@ function PartsFilters({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={categoryId} onValueChange={onCategory}>
|
||||
<SelectTrigger className="h-8 w-40 text-xs">
|
||||
<SelectValue placeholder="Category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>All categories</SelectItem>
|
||||
{categories.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={tagId} onValueChange={onTag}>
|
||||
<SelectTrigger className="h-8 w-36 text-xs">
|
||||
<SelectValue placeholder="Tag" />
|
||||
|
||||
Reference in New Issue
Block a user