feat: laundry-list polish pass
CI / Lint · Typecheck · Test · Build (push) Successful in 44s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 59s

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:
2026-04-17 13:36:11 -04:00
parent 3d77f2846d
commit 60255f20bb
39 changed files with 1731 additions and 630 deletions
+16
View File
@@ -31,3 +31,19 @@ export async function dropOff(
next(err);
}
}
export async function takeForRepair(
req: Request<{ partId: string }>,
res: Response,
next: NextFunction,
) {
try {
if (!req.user) throw errors.unauthorized();
const part = await prisma.$transaction((tx) =>
svc.takeForRepair(tx, req.params.partId, req.user!),
);
res.json(part);
} catch (err) {
next(err);
}
}
+1
View File
@@ -13,5 +13,6 @@ router.post(
validate('body', DropOffRequest),
ctrl.dropOff,
);
router.post('/:partId/take-for-repair', requireAuth, ctrl.takeForRepair);
export default router;
+57 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import type { Tx, Actor } from './types.js';
import { dropOff } from './custody.js';
import { dropOff, takeForRepair } from './custody.js';
const custodian: Actor = { id: 'user-1', username: 'tech', role: 'TECHNICIAN' };
const admin: Actor = { id: 'user-admin', username: 'boss', role: 'ADMIN' };
@@ -166,4 +166,60 @@ describe('custody.dropOff', () => {
dropOff(tx, 'p-missing', { binId: null }, custodian),
).rejects.toMatchObject({ status: 404 });
});
it('PENDING_REPAIR → SPARE when returned with a bin; custodian cleared', async () => {
const { tx, current } = buildTx(
custodyPart({ state: 'PENDING_REPAIR', binId: null }),
);
await dropOff(tx, 'p-1', { binId: 'bin-7' }, custodian);
expect(current.state).toBe('SPARE');
expect(current.binId).toBe('bin-7');
expect(current.custodianId).toBeNull();
});
it('rejects PENDING_REPAIR drop-off without a bin with 400', async () => {
const { tx, partUpdate } = buildTx(
custodyPart({ state: 'PENDING_REPAIR', binId: null }),
);
await expect(
dropOff(tx, 'p-1', { binId: null }, custodian),
).rejects.toMatchObject({ status: 400 });
expect(partUpdate).not.toHaveBeenCalled();
});
});
describe('custody.takeForRepair', () => {
it('SPARE → PENDING_REPAIR with the actor as custodian', async () => {
const { tx, current } = buildTx(
custodyPart({ state: 'SPARE', custodianId: null, custodian: null, binId: 'bin-1' }),
);
await takeForRepair(tx, 'p-1', custodian);
expect(current.state).toBe('PENDING_REPAIR');
expect(current.custodianId).toBe('user-1');
});
it('rejects take-for-repair on a non-SPARE part with 400', async () => {
const { tx, partUpdate } = buildTx(custodyPart({ state: 'DEPLOYED' }));
await expect(takeForRepair(tx, 'p-1', custodian)).rejects.toMatchObject({
status: 400,
});
expect(partUpdate).not.toHaveBeenCalled();
});
it('rejects take-for-repair on a missing part with 404', async () => {
const tx = {
part: { findUnique: async () => null, update: vi.fn() },
partEvent: { createMany: vi.fn() },
} as unknown as Tx;
await expect(takeForRepair(tx, 'p-missing', custodian)).rejects.toMatchObject({
status: 404,
});
});
});
+32 -10
View File
@@ -27,6 +27,16 @@ export async function listMine(tx: Tx, userId: string, q: CustodyListQuery) {
return { data, page, pageSize, total };
}
// Map of custody-state → state the part returns to when dropped off.
// PENDING_REPAIR is a spare the tech picked up for a future swap; on return it goes back to
// SPARE and must land in a bin (no useful "in-limbo" state for spares). The broken-part
// paths continue to allow binId=null since techs sometimes hold broken parts without a bin.
const DROP_OFF_TARGET: Record<string, { next: 'BROKEN' | 'PENDING_DESTRUCTION' | 'SPARE'; binRequired: boolean }> = {
PENDING_DROP_IN_CUSTODY: { next: 'BROKEN', binRequired: false },
PENDING_DESTRUCTION_IN_CUSTODY: { next: 'PENDING_DESTRUCTION', binRequired: false },
PENDING_REPAIR: { next: 'SPARE', binRequired: true },
};
export async function dropOff(
tx: Tx,
partId: string,
@@ -36,23 +46,35 @@ export async function dropOff(
const part = await tx.part.findUnique({ where: { id: partId } });
if (!part) throw errors.notFound('Part');
if (
part.state !== 'PENDING_DROP_IN_CUSTODY' &&
part.state !== 'PENDING_DESTRUCTION_IN_CUSTODY'
) {
throw errors.badRequest('Part is not in custody');
}
const target = DROP_OFF_TARGET[part.state];
if (!target) throw errors.badRequest('Part is not in custody');
if (part.custodianId !== actor.id && actor.role !== 'ADMIN') {
throw errors.forbidden('Only the current custodian can drop off this part');
}
const nextState =
part.state === 'PENDING_DROP_IN_CUSTODY' ? 'BROKEN' : 'PENDING_DESTRUCTION';
if (target.binRequired && !input.binId) {
throw errors.badRequest('A bin is required when returning a spare to inventory');
}
return partsSvc.update(
tx,
partId,
{ state: nextState, binId: input.binId ?? null, custodianId: null },
{ state: target.next, binId: input.binId ?? null, custodianId: null },
actor,
);
}
// A tech takes a SPARE into their custody for a future repair. The part waits in
// PENDING_REPAIR until it's used in a Repair (→ DEPLOYED) or dropped back into a bin (→ SPARE).
export async function takeForRepair(tx: Tx, partId: string, actor: Actor) {
const part = await tx.part.findUnique({ where: { id: partId } });
if (!part) throw errors.notFound('Part');
if (part.state !== 'SPARE') {
throw errors.badRequest('Only SPARE parts can be taken for a repair');
}
return partsSvc.update(
tx,
partId,
{ state: 'PENDING_REPAIR', custodianId: actor.id },
actor,
);
}
+117
View File
@@ -0,0 +1,117 @@
import { describe, expect, it, vi } from 'vitest';
import type { Tx } from './types.js';
import { create, update } from './hosts.js';
interface HostRow {
id: string;
assetId: string;
name: string;
location: string | null;
notes: string | null;
state: string;
stack: string;
}
function buildTx(seed: HostRow[] = []) {
const registry = new Map(seed.map((h) => [h.id, h]));
const tx = {
host: {
create: vi.fn(async (args: { data: Record<string, unknown> }) => {
const row: HostRow = {
id: `host-${registry.size + 1}`,
assetId: String(args.data.assetId),
name: String(args.data.name),
location: (args.data.location as string | null) ?? null,
notes: (args.data.notes as string | null) ?? null,
state: String(args.data.state ?? 'DEPLOYED'),
stack: String(args.data.stack ?? 'PRODUCTION'),
};
registry.set(row.id, row);
return row;
}),
update: vi.fn(async (args: { where: { id: string }; data: Record<string, unknown> }) => {
const current = registry.get(args.where.id);
if (!current) throw new Error(`No host ${args.where.id}`);
const d = args.data;
if (d.assetId !== undefined) current.assetId = String(d.assetId);
if (d.name !== undefined) current.name = String(d.name);
if (d.location !== undefined) current.location = (d.location as string | null) ?? null;
if (d.notes !== undefined) current.notes = (d.notes as string | null) ?? null;
if (d.state !== undefined) current.state = String(d.state);
if (d.stack !== undefined) current.stack = String(d.stack);
return current;
}),
},
} as unknown as Tx;
return { tx, registry };
}
describe('hosts.create', () => {
it('defaults state=DEPLOYED and stack=PRODUCTION when not supplied', async () => {
const { tx } = buildTx();
const host = await create(tx, { assetId: 'A-1', name: 'rack-1' });
expect(host.state).toBe('DEPLOYED');
expect(host.stack).toBe('PRODUCTION');
});
it('persists explicit state and stack', async () => {
const { tx } = buildTx();
const host = await create(tx, {
assetId: 'A-2',
name: 'rack-2',
state: 'TESTING',
stack: 'VETTING',
});
expect(host.state).toBe('TESTING');
expect(host.stack).toBe('VETTING');
});
});
describe('hosts.update', () => {
it('updates state and stack when provided', async () => {
const { tx, registry } = buildTx([
{
id: 'host-1',
assetId: 'A-1',
name: 'rack-1',
location: null,
notes: null,
state: 'DEPLOYED',
stack: 'PRODUCTION',
},
]);
await update(tx, 'host-1', { state: 'DEGRADED', stack: 'VETTING' });
const row = registry.get('host-1')!;
expect(row.state).toBe('DEGRADED');
expect(row.stack).toBe('VETTING');
});
it('leaves state/stack untouched when not provided', async () => {
const { tx, registry } = buildTx([
{
id: 'host-1',
assetId: 'A-1',
name: 'rack-1',
location: null,
notes: null,
state: 'TESTING',
stack: 'VETTING',
},
]);
await update(tx, 'host-1', { name: 'rack-1-renamed' });
const row = registry.get('host-1')!;
expect(row.state).toBe('TESTING');
expect(row.stack).toBe('VETTING');
expect(row.name).toBe('rack-1-renamed');
});
});
+14 -9
View File
@@ -14,15 +14,16 @@ function mapUniqueViolation(target: unknown): string {
export async function list(tx: Tx, q: HostListQuery) {
const { page, pageSize, q: search } = q;
const where: Prisma.HostWhereInput = search
? {
OR: [
{ name: { contains: search } },
{ assetId: { contains: search } },
{ location: { contains: search } },
],
}
: {};
const where: Prisma.HostWhereInput = {};
if (search) {
where.OR = [
{ name: { contains: search } },
{ assetId: { contains: search } },
{ location: { contains: search } },
];
}
if (q.state) where.state = q.state;
if (q.stack) where.stack = q.stack;
const [data, total] = await Promise.all([
tx.host.findMany({
where,
@@ -55,6 +56,8 @@ export async function create(tx: Tx, input: CreateHostRequest) {
name: input.name,
location: input.location ?? null,
notes: input.notes ?? null,
state: input.state ?? 'DEPLOYED',
stack: input.stack ?? 'PRODUCTION',
},
});
} catch (err) {
@@ -71,6 +74,8 @@ export async function update(tx: Tx, id: string, input: UpdateHostRequest) {
if (input.name !== undefined) data.name = input.name;
if (input.location !== undefined) data.location = input.location;
if (input.notes !== undefined) data.notes = input.notes;
if (input.state !== undefined) data.state = input.state;
if (input.stack !== undefined) data.stack = input.stack;
try {
return await tx.host.update({ where: { id }, data });
} catch (err) {
+11 -2
View File
@@ -9,13 +9,15 @@ import type { Tx } from './types.js';
const partModelInclude = {
manufacturer: true,
category: true,
_count: { select: { parts: true } },
} satisfies Prisma.PartModelInclude;
export async function list(tx: Tx, q: PartModelListQuery) {
const { page, pageSize, manufacturerId, q: search, eolBefore } = q;
const { page, pageSize, manufacturerId, categoryId, q: search, eolBefore } = q;
const where: Prisma.PartModelWhereInput = {};
if (manufacturerId) where.manufacturerId = manufacturerId;
if (categoryId) where.categoryId = categoryId;
if (search) where.mpn = { contains: search };
if (eolBefore) where.eolDate = { lte: new Date(eolBefore) };
@@ -42,6 +44,7 @@ export async function create(tx: Tx, input: CreatePartModelRequest) {
data: {
manufacturerId: input.manufacturerId,
mpn: input.mpn,
categoryId: input.categoryId ?? null,
eolDate: input.eolDate ? new Date(input.eolDate) : null,
destroyOnFail: input.destroyOnFail ?? false,
notes: input.notes ?? null,
@@ -51,7 +54,7 @@ export async function create(tx: Tx, input: CreatePartModelRequest) {
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2002') throw errors.conflict('MPN already exists for this manufacturer');
if (err.code === 'P2003') throw errors.badRequest('Manufacturer does not exist');
if (err.code === 'P2003') throw errors.badRequest('Manufacturer or category does not exist');
}
throw err;
}
@@ -63,6 +66,11 @@ export async function update(tx: Tx, id: string, input: UpdatePartModelRequest)
data.manufacturer = { connect: { id: input.manufacturerId } };
}
if (input.mpn !== undefined) data.mpn = input.mpn;
if (input.categoryId !== undefined) {
data.category = input.categoryId
? { connect: { id: input.categoryId } }
: { disconnect: true };
}
if (input.eolDate !== undefined) {
data.eolDate = input.eolDate ? new Date(input.eolDate) : null;
}
@@ -74,6 +82,7 @@ export async function update(tx: Tx, id: string, input: UpdatePartModelRequest)
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2025') throw errors.notFound('Part model');
if (err.code === 'P2002') throw errors.conflict('MPN already exists for this manufacturer');
if (err.code === 'P2003') throw errors.badRequest('Manufacturer or category does not exist');
}
throw err;
}
+8 -21
View File
@@ -15,7 +15,7 @@ import type { Actor, Tx } from './types.js';
// The matrix is:
// DEPLOYED — hostId required, binId forbidden, custodianId forbidden
// SPARE / BROKEN / PENDING_DESTRUCTION — binId optional, hostId + custodian forbidden
// PENDING_DROP_IN_CUSTODY / PENDING_DESTRUCTION_IN_CUSTODY
// PENDING_DROP_IN_CUSTODY / PENDING_DESTRUCTION_IN_CUSTODY / PENDING_REPAIR
// — custodianId required, host + bin forbidden
// Callers only need to pass what's changing; anything omitted is inherited from `current`.
function resolveLocation(
@@ -43,7 +43,11 @@ function resolveLocation(
return { binId: null, hostId, custodianId: null };
}
if (state === 'PENDING_DROP_IN_CUSTODY' || state === 'PENDING_DESTRUCTION_IN_CUSTODY') {
if (
state === 'PENDING_DROP_IN_CUSTODY' ||
state === 'PENDING_DESTRUCTION_IN_CUSTODY' ||
state === 'PENDING_REPAIR'
) {
const custodianId =
input.custodianId !== undefined ? input.custodianId : current.custodianId;
if (!custodianId) throw errors.badRequest('A part in custody must name a custodian');
@@ -65,9 +69,8 @@ function resolveLocation(
const partInclude = {
manufacturer: true,
partModel: true,
partModel: { include: { category: true } },
bin: { include: { room: { include: { site: true } } } },
category: true,
host: true,
custodian: { select: { id: true, username: true } },
tags: { include: { tag: true } },
@@ -127,7 +130,6 @@ function buildWhere(q: PartListQuery): Prisma.PartWhereInput {
if (q.custodianId) where.custodianId = q.custodianId;
if (q.manufacturerId) where.manufacturerId = q.manufacturerId;
if (q.partModelId) where.partModelId = q.partModelId;
if (q.categoryId) where.categoryId = q.categoryId;
if (q.serialNumber) where.serialNumber = { contains: q.serialNumber };
if (q.q) {
where.OR = [
@@ -141,6 +143,7 @@ function buildWhere(q: PartListQuery): Prisma.PartWhereInput {
const partModelFilter: Prisma.PartModelWhereInput = {};
if (q.mpn) partModelFilter.mpn = { contains: q.mpn };
if (q.eolOnly) partModelFilter.eolDate = { lt: new Date() };
if (q.categoryId) partModelFilter.categoryId = q.categoryId;
if (Object.keys(partModelFilter).length > 0) where.partModel = partModelFilter;
return where;
@@ -196,7 +199,6 @@ export async function create(
binId: location.binId,
hostId: location.hostId,
custodianId: location.custodianId,
categoryId: input.categoryId ?? null,
notes: input.notes ?? null,
},
include: partInclude,
@@ -276,11 +278,6 @@ export async function update(
: { disconnect: true };
}
if (input.categoryId !== undefined) {
data.category = input.categoryId
? { connect: { id: input.categoryId } }
: { disconnect: true };
}
if (input.notes !== undefined) data.notes = input.notes;
let part: PartWithRelations;
@@ -357,16 +354,6 @@ export async function update(
newValue: input.serialNumber,
});
}
if (input.categoryId !== undefined && input.categoryId !== current.categoryId) {
events.push({
partId: part.id,
userId,
type: 'FIELD_UPDATED',
field: 'category',
oldValue: current.category?.name ?? null,
newValue: part.category?.name ?? null,
});
}
if (input.price !== undefined && input.price !== current.price) {
events.push({
partId: part.id,
+76
View File
@@ -514,6 +514,82 @@ describe('repairs.log — validation failures', () => {
expect(events).toEqual(['repair.logged']);
});
it('accepts a PENDING_REPAIR replacement held by the actor', async () => {
const broken = partRow({
id: 'p-broken',
serialNumber: 'SN-BROKEN',
partModelId: brokenModel.id,
state: 'DEPLOYED',
hostId: 'host-1',
host: host1,
partModel: brokenModel,
});
const replacement = partRow({
id: 'p-replacement',
serialNumber: 'SN-REPLACE',
partModelId: replacementModel.id,
state: 'PENDING_REPAIR',
custodianId: actor.id,
custodian: { id: actor.id, username: actor.username },
partModel: replacementModel,
});
const { tx, registry } = buildTx({ parts: [broken, replacement], hosts: [host1] });
const r = await log(
tx,
{
hostId: 'host-1',
brokenSerial: 'SN-BROKEN',
brokenMpn: 'WD-BROKEN',
brokenManufacturerId: 'mfr-1',
replacementSerial: 'SN-REPLACE',
},
actor,
);
expect(r.id).toBe('repair-1');
const updatedReplacement = registry.get('p-replacement')!;
expect(updatedReplacement.state).toBe('DEPLOYED');
expect(updatedReplacement.hostId).toBe('host-1');
expect(updatedReplacement.custodianId).toBeNull();
});
it('rejects a PENDING_REPAIR replacement held by someone else with 400', async () => {
const broken = partRow({
id: 'p-broken',
serialNumber: 'SN-BROKEN',
partModelId: brokenModel.id,
state: 'DEPLOYED',
hostId: 'host-1',
host: host1,
partModel: brokenModel,
});
const replacement = partRow({
id: 'p-replacement',
serialNumber: 'SN-REPLACE',
partModelId: replacementModel.id,
state: 'PENDING_REPAIR',
custodianId: 'user-other',
custodian: { id: 'user-other', username: 'someone' },
partModel: replacementModel,
});
const { tx } = buildTx({ parts: [broken, replacement], hosts: [host1] });
await expect(
log(
tx,
{
hostId: 'host-1',
brokenSerial: 'SN-BROKEN',
brokenMpn: 'WD-BROKEN',
brokenManufacturerId: 'mfr-1',
replacementSerial: 'SN-REPLACE',
},
actor,
),
).rejects.toMatchObject({ status: 400 });
});
it('rejects when broken part is on a different host than the repair', async () => {
const broken = partRow({
id: 'p-broken',
+21 -7
View File
@@ -78,7 +78,7 @@ export async function log(
): Promise<RepairWithRelations> {
const host = await resolveHost(tx, input);
// 1. Resolve replacement — must exist, must be SPARE.
// 1. Resolve replacement — must exist; must be SPARE, or a PENDING_REPAIR held by the actor.
const replacement = await tx.part.findUnique({
where: { serialNumber: input.replacementSerial },
include: { partModel: true },
@@ -86,9 +86,11 @@ export async function log(
if (!replacement) {
throw errors.badRequest(`Replacement part ${input.replacementSerial} not found`);
}
if (replacement.state !== 'SPARE') {
const heldForRepairByActor =
replacement.state === 'PENDING_REPAIR' && replacement.custodianId === actor.id;
if (replacement.state !== 'SPARE' && !heldForRepairByActor) {
throw errors.badRequest(
`Replacement part ${input.replacementSerial} is ${replacement.state}, must be SPARE`,
`Replacement part ${input.replacementSerial} is ${replacement.state}, must be SPARE or PENDING_REPAIR held by you`,
);
}
@@ -104,10 +106,22 @@ export async function log(
);
}
} else {
const pm = await partModelsSvc.upsertByMpn(tx, {
manufacturerId: input.brokenManufacturerId,
mpn: input.brokenMpn,
});
let pm: { id: string; manufacturerId: string };
if (input.brokenPartModelId) {
const existing = await tx.partModel.findUnique({ where: { id: input.brokenPartModelId } });
if (!existing) throw errors.badRequest('Broken part model does not exist');
pm = { id: existing.id, manufacturerId: existing.manufacturerId };
} else {
if (!input.brokenMpn || !input.brokenManufacturerId) {
throw errors.badRequest(
'Provide brokenPartModelId or both brokenMpn and brokenManufacturerId',
);
}
pm = await partModelsSvc.upsertByMpn(tx, {
manufacturerId: input.brokenManufacturerId,
mpn: input.brokenMpn,
});
}
const created = await tx.part.create({
data: {
serialNumber: input.brokenSerial,
@@ -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"
+122 -75
View File
@@ -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}
+5
View File
@@ -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;
}
+2
View File
@@ -7,6 +7,8 @@ export type HostListFilters = {
page?: number;
pageSize?: number;
q?: string;
state?: string;
stack?: string;
};
export function listHosts(filters: HostListFilters = {}) {
+1
View File
@@ -10,6 +10,7 @@ export type PartModelListFilters = {
page?: number;
pageSize?: number;
manufacturerId?: string;
categoryId?: string;
q?: string;
eolBefore?: string;
};
+12 -2
View File
@@ -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;
}
+5 -3
View File
@@ -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"
+20
View File
@@ -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',
+86 -10
View File
@@ -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>
);
}
+19 -8
View File
@@ -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>
);
},
},
],
[],
+11 -1
View File
@@ -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>
),
},
{
+67 -3
View File
@@ -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" />