diff --git a/README.md b/README.md index 8d60b4c..4cd4bc4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Vector -Hardware parts inventory system. Tracks serialized parts across sites → rooms → bins, with a full audit trail, repair/RMA workflow, tag-based organization, manufacturer EOL tracking, and signed webhook delivery for external integrations. +Hardware parts inventory system. Tracks serialized parts across sites → rooms → bins and hosts (with externally-driven state/stack lifecycle), with a full audit trail, repair/RMA workflow, per-tech custody for broken-part holds and pre-staged spares, tag-based organization, category-per-model taxonomy, manufacturer EOL tracking, and signed webhook delivery for external integrations. Vector 2.0 is a ground-up TypeScript rewrite of the original JavaScript codebase, delivered as a pnpm + Turbo monorepo with shadcn/ui on the frontend and a service-layered Express API on the backend. diff --git a/apps/api/src/controllers/custody.ts b/apps/api/src/controllers/custody.ts index b13ecd9..a8c5cf1 100644 --- a/apps/api/src/controllers/custody.ts +++ b/apps/api/src/controllers/custody.ts @@ -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); + } +} diff --git a/apps/api/src/routes/custody.ts b/apps/api/src/routes/custody.ts index 6ff68b4..4ac81bf 100644 --- a/apps/api/src/routes/custody.ts +++ b/apps/api/src/routes/custody.ts @@ -13,5 +13,6 @@ router.post( validate('body', DropOffRequest), ctrl.dropOff, ); +router.post('/:partId/take-for-repair', requireAuth, ctrl.takeForRepair); export default router; diff --git a/apps/api/src/services/custody.test.ts b/apps/api/src/services/custody.test.ts index f0b2c35..4e83387 100644 --- a/apps/api/src/services/custody.test.ts +++ b/apps/api/src/services/custody.test.ts @@ -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, + }); + }); }); diff --git a/apps/api/src/services/custody.ts b/apps/api/src/services/custody.ts index e419e00..8cbe1ec 100644 --- a/apps/api/src/services/custody.ts +++ b/apps/api/src/services/custody.ts @@ -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 = { + 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, ); } diff --git a/apps/api/src/services/hosts.test.ts b/apps/api/src/services/hosts.test.ts new file mode 100644 index 0000000..94fb7ee --- /dev/null +++ b/apps/api/src/services/hosts.test.ts @@ -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 }) => { + 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 }) => { + 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'); + }); +}); diff --git a/apps/api/src/services/hosts.ts b/apps/api/src/services/hosts.ts index cb6ebde..f34b094 100644 --- a/apps/api/src/services/hosts.ts +++ b/apps/api/src/services/hosts.ts @@ -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) { diff --git a/apps/api/src/services/part-models.ts b/apps/api/src/services/part-models.ts index 48619aa..52a08fa 100644 --- a/apps/api/src/services/part-models.ts +++ b/apps/api/src/services/part-models.ts @@ -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; } diff --git a/apps/api/src/services/parts.ts b/apps/api/src/services/parts.ts index 8700c9c..28a102f 100644 --- a/apps/api/src/services/parts.ts +++ b/apps/api/src/services/parts.ts @@ -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, diff --git a/apps/api/src/services/repairs.test.ts b/apps/api/src/services/repairs.test.ts index 0d805c6..f02575e 100644 --- a/apps/api/src/services/repairs.test.ts +++ b/apps/api/src/services/repairs.test.ts @@ -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', diff --git a/apps/api/src/services/repairs.ts b/apps/api/src/services/repairs.ts index d21db67..93f2e78 100644 --- a/apps/api/src/services/repairs.ts +++ b/apps/api/src/services/repairs.ts @@ -78,7 +78,7 @@ export async function log( ): Promise { 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, diff --git a/apps/web/src/components/common/PartModelCombobox.tsx b/apps/web/src/components/common/PartModelCombobox.tsx new file mode 100644 index 0000000..2f88136 --- /dev/null +++ b/apps/web/src/components/common/PartModelCombobox.tsx @@ -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(null); + + const label = value + ? `${value.manufacturer?.name ?? ''} — ${value.mpn}` + : newMpn + ? `New model: ${newMpn}` + : ''; + + return ( +
+ !disabled && setOpen(o)}> + + + + + + + + + {query.isLoading ? 'Searching…' : 'No models found.'} + + + {results.map((m) => ( + { + onPick(m); + setSearch(''); + setOpen(false); + }} + > + + + {m.manufacturer?.name ?? '—'} —{' '} + + {m.mpn} + + + + ))} + {canCreate && ( + { + onCreateNew(typed); + setSearch(''); + setOpen(false); + }} + > + + Create new model: {typed} + + )} + + + + + + {(value || newMpn) && !disabled && ( + + )} +
+ ); +} diff --git a/apps/web/src/components/custody/DropOffDialog.tsx b/apps/web/src/components/custody/DropOffDialog.tsx index 70246f2..7b0a7bc 100644 --- a/apps/web/src/components/custody/DropOffDialog.tsx +++ b/apps/web/src/components/custody/DropOffDialog.tsx @@ -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 ( - Drop in bin - - {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.`} - + {title} + {description}
@@ -62,10 +68,10 @@ export function DropOffDialog({ part, onOpenChange, onConfirm, pending }: DropOf onValueChange={(v) => setBinId(v === UNASSIGNED ? '' : v)} > - + - Unassigned + {!returningSpare && Unassigned} {bins.data?.data.map((b) => ( {b.fullPath ?? b.name} @@ -84,9 +90,9 @@ export function DropOffDialog({ part, onOpenChange, onConfirm, pending }: DropOf > Cancel - diff --git a/apps/web/src/components/hosts/HostFormDialog.tsx b/apps/web/src/components/hosts/HostFormDialog.tsx index 69155d8..1c201e7 100644 --- a/apps/web/src/components/hosts/HostFormDialog.tsx +++ b/apps/web/src/components/hosts/HostFormDialog.tsx @@ -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; +const STATE_LABELS: Record, string> = { + DEPLOYED: 'Deployed', + DEGRADED: 'Degraded', + TESTING: 'Testing', +}; + +const STACK_LABELS: Record, 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({ 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 )} /> +
+ ( + + State + + + + )} + /> + ( + + Stack + + + + )} + /> +
void; - canEdit: boolean; -} - -export function RoomDrawer({ siteId, selectedId, onSelect, canEdit }: RoomDrawerProps) { - const queryClient = useQueryClient(); - const [creating, setCreating] = useState(false); - const [renaming, setRenaming] = useState(null); - const [deleting, setDeleting] = useState(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 ( -
- Select a site to see its rooms. -
- ); - } - - return ( -
-
-

- Rooms -

- {canEdit && ( - - )} -
- -
- {rooms.isPending ? ( -
- {Array.from({ length: 3 }).map((_, i) => ( - - ))} -
- ) : rooms.isError ? ( -

Failed to load rooms.

- ) : rooms.data && rooms.data.data.length === 0 ? ( -
- - No rooms in this site -
- ) : ( -
    - {rooms.data!.data.map((r) => { - const active = r.id === selectedId; - return ( -
  • -
    - - {canEdit && ( - - - - - - setRenaming(r)}> - Rename - - setDeleting(r)} - className="text-destructive focus:text-destructive" - > - - Delete - - - - )} -
    -
  • - ); - })} -
- )} -
- - createMutation.mutate(name)} - /> - !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 })} - /> - !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)} - /> -
- ); -} diff --git a/apps/web/src/components/locations/SiteList.tsx b/apps/web/src/components/locations/SiteList.tsx deleted file mode 100644 index 1032ee4..0000000 --- a/apps/web/src/components/locations/SiteList.tsx +++ /dev/null @@ -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(null); - const [deleting, setDeleting] = useState(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 ( -
-
-

- Sites -

- {canEdit && ( - - )} -
- -
- {sites.isPending ? ( -
- {Array.from({ length: 4 }).map((_, i) => ( - - ))} -
- ) : sites.isError ? ( -

Failed to load sites.

- ) : sites.data && sites.data.data.length === 0 ? ( -
- - No sites yet -
- ) : ( -
    - {sites.data!.data.map((s) => { - const active = s.id === selectedId; - return ( -
  • -
    - - {canEdit && ( - - - - - - setRenaming(s)}> - Rename - - setDeleting(s)} - className="text-destructive focus:text-destructive" - > - - Delete - - - - )} -
    -
  • - ); - })} -
- )} -
- - createMutation.mutate(name)} - /> - !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 })} - /> - !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)} - /> -
- ); -} diff --git a/apps/web/src/components/locations/SiteRoomTree.tsx b/apps/web/src/components/locations/SiteRoomTree.tsx new file mode 100644 index 0000000..e2a9279 --- /dev/null +++ b/apps/web/src/components/locations/SiteRoomTree.tsx @@ -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(null); + const [renaming, setRenaming] = useState(null); + const [deleting, setDeleting] = useState(null); + const [expanded, setExpanded] = useState>(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(); + 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 ( +
+
+

+ Sites +

+ {canEdit && hasSites && ( + + )} +
+ +
+ {sites.isPending ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ) : sites.isError ? ( +

Failed to load sites.

+ ) : !hasSites ? ( +
+ + No sites yet + {canEdit && ( + + )} +
+ ) : ( +
    + {sites.data!.data.map((s) => { + const isOpen = expanded.has(s.id); + const siteActive = s.id === siteId; + const rooms = roomsBySite.get(s.id) ?? []; + return ( +
  • +
    + + + {canEdit && ( + + + + + + setCreatingRoomInSite(s.id)} + > + + Add room + + setRenaming({ kind: 'site', value: s })} + > + Rename + + setDeleting({ kind: 'site', value: s })} + className="text-destructive focus:text-destructive" + > + + Delete + + + + )} +
    + + {isOpen && ( +
      + {rooms.length === 0 ? ( +
    • + No rooms yet + {canEdit && ( + + )} +
    • + ) : ( + rooms.map((r) => { + const roomActive = r.id === roomId; + return ( +
    • +
      + + {canEdit && ( + + + + + + + setRenaming({ kind: 'room', value: r }) + } + > + Rename + + + setDeleting({ kind: 'room', value: r }) + } + className="text-destructive focus:text-destructive" + > + + Delete + + + + )} +
      +
    • + ); + }) + )} +
    + )} +
  • + ); + })} +
+ )} +
+ + createSiteMutation.mutate(name)} + /> + !o && setCreatingRoomInSite(null)} + title="New room" + label="Room name" + confirmLabel="Create" + pending={createRoomMutation.isPending} + onSubmit={(name) => + creatingRoomInSite && + createRoomMutation.mutate({ siteId: creatingRoomInSite, name }) + } + /> + !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 }); + } + }} + /> + !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); + }} + /> +
+ ); +} diff --git a/apps/web/src/components/part-models/PartModelFormDialog.tsx b/apps/web/src/components/part-models/PartModelFormDialog.tsx index 52772c1..22cb022 100644 --- a/apps/web/src/components/part-models/PartModelFormDialog.tsx +++ b/apps/web/src/components/part-models/PartModelFormDialog.tsx @@ -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; +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({ )} /> + ( + + Category +
+ +
+ + Groups like GPU / RAM / SSD describe this model family. + + +
+ )} + /> { + 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(null); + const form = useForm({ 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
-
- ( - - Serial - - - - - - )} - /> - ( - - MPN - - - - - - )} - /> -
- ( - Manufacturer - + Serial + + + )} /> + ( + + Part model + { + 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', ''); + }} + /> + + + )} + /> + + {!pickedModel && form.watch('mpn') && ( + ( + + Manufacturer (for new model) + + + + )} + /> + )} +
= { 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 = { @@ -17,6 +18,7 @@ const STATE_VARIANT: Record = { PENDING_DESTRUCTION: 'destructive', PENDING_DROP_IN_CUSTODY: 'outline', PENDING_DESTRUCTION_IN_CUSTODY: 'outline', + PENDING_REPAIR: 'outline', }; export function PartStateBadge({ state }: { state: PartState }) { diff --git a/apps/web/src/components/repairs/LogRepairDialog.tsx b/apps/web/src/components/repairs/LogRepairDialog.tsx index ad03cce..e7a008f 100644 --- a/apps/web/src/components/repairs/LogRepairDialog.tsx +++ b/apps/web/src/components/repairs/LogRepairDialog.tsx @@ -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; const NO_FM = '__none__'; @@ -57,27 +83,34 @@ interface LogRepairDialogProps { export function LogRepairDialog({ open, onOpenChange, onLogged }: LogRepairDialogProps) { const queryClient = useQueryClient(); + const [pickedModel, setPickedModel] = useState(null); + const form = useForm({ 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 />
-
- ( - - Broken MPN - - - - - + {!existingBroken && ( + <> + ( + + Broken part model + { + 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', ''); + }} + /> + + + )} + /> + + {!pickedModel && form.watch('brokenMpn') && ( + ( + + Manufacturer (for new model) + + + + )} + /> )} - /> - ( - - Broken manufacturer - - - - )} - /> -
+ + )} (`/custody/${partId}/drop-off`, input); return res.data; } + +export async function takeForRepair(partId: string): Promise { + const res = await api.post(`/custody/${partId}/take-for-repair`); + return res.data; +} diff --git a/apps/web/src/lib/api/hosts.ts b/apps/web/src/lib/api/hosts.ts index 3e8e71a..7949311 100644 --- a/apps/web/src/lib/api/hosts.ts +++ b/apps/web/src/lib/api/hosts.ts @@ -7,6 +7,8 @@ export type HostListFilters = { page?: number; pageSize?: number; q?: string; + state?: string; + stack?: string; }; export function listHosts(filters: HostListFilters = {}) { diff --git a/apps/web/src/lib/api/part-models.ts b/apps/web/src/lib/api/part-models.ts index b97617a..033d349 100644 --- a/apps/web/src/lib/api/part-models.ts +++ b/apps/web/src/lib/api/part-models.ts @@ -10,6 +10,7 @@ export type PartModelListFilters = { page?: number; pageSize?: number; manufacturerId?: string; + categoryId?: string; q?: string; eolBefore?: string; }; diff --git a/apps/web/src/lib/api/types.ts b/apps/web/src/lib/api/types.ts index 8899ac9..2d00adc 100644 --- a/apps/web/src/lib/api/types.ts +++ b/apps/web/src/lib/api/types.ts @@ -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; } diff --git a/apps/web/src/pages/Dashboard.tsx b/apps/web/src/pages/Dashboard.tsx index e7478f0..da7819c 100644 --- a/apps/web/src/pages/Dashboard.tsx +++ b/apps/web/src/pages/Dashboard.tsx @@ -35,6 +35,7 @@ const STATE_LABELS: Record = { 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 = { @@ -44,10 +45,11 @@ const STATE_COLORS: Record = { 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" diff --git a/apps/web/src/pages/Hosts.tsx b/apps/web/src/pages/Hosts.tsx index 5da5f00..a2cf965 100644 --- a/apps/web/src/pages/Hosts.tsx +++ b/apps/web/src/pages/Hosts.tsx @@ -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 }) => {row.original.name}, }, + { + accessorKey: 'state', + header: 'State', + cell: ({ row }) => { + const s = row.original.state; + const variant = + s === 'DEPLOYED' ? 'secondary' : s === 'DEGRADED' ? 'destructive' : 'outline'; + return {s}; + }, + }, + { + accessorKey: 'stack', + header: 'Stack', + cell: ({ row }) => ( + + {row.original.stack} + + ), + }, { accessorKey: 'location', header: 'Location', diff --git a/apps/web/src/pages/Locations.tsx b/apps/web/src/pages/Locations.tsx index fb57aaf..cea33be 100644 --- a/apps/web/src/pages/Locations.tsx +++ b/apps/web/src/pages/Locations.tsx @@ -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 (
-
+
- +
-
- -
-
- +
+ +
+ {roomId ? ( + + ) : ( + + )} +
); } + +function Breadcrumb({ + siteName, + roomName, +}: { + siteName: string | undefined; + roomName: string | undefined; +}) { + return ( +
+ {siteName ? ( + <> + {siteName} + {roomName && ( + <> + + {roomName} + + )} + + ) : ( + Select a site to begin. + )} +
+ ); +} + +function EmptyPane({ siteSelected }: { siteSelected: boolean }) { + return ( +
+
+ +

+ {siteSelected ? 'Pick a room' : 'Pick a site and room'} +

+

+ Bins live inside rooms. Expand a site in the tree and choose a room to manage its bins. +

+
+
+ ); +} diff --git a/apps/web/src/pages/MyCustody.tsx b/apps/web/src/pages/MyCustody.tsx index 15203b7..ec67096 100644 --- a/apps/web/src/pages/MyCustody.tsx +++ b/apps/web/src/pages/MyCustody.tsx @@ -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: () => Actions, - size: 140, - cell: ({ row }) => ( - - ), + size: 160, + cell: ({ row }) => { + const pending = row.original.state === 'PENDING_REPAIR'; + return ( + + ); + }, }, ], [], diff --git a/apps/web/src/pages/PartModels.tsx b/apps/web/src/pages/PartModels.tsx index e48ebd1..cd52fad 100644 --- a/apps/web/src/pages/PartModels.tsx +++ b/apps/web/src/pages/PartModels.tsx @@ -58,6 +58,16 @@ export default function PartModels() { {row.original.mpn} ), }, + { + id: 'category', + header: 'Category', + cell: ({ row }) => + row.original.category ? ( + {row.original.category.name} + ) : ( + + ), + }, { accessorKey: 'eolDate', header: 'EOL', @@ -87,7 +97,7 @@ export default function PartModels() { row.original.destroyOnFail ? ( ) : ( - + No ), }, { diff --git a/apps/web/src/pages/Parts.tsx b/apps/web/src/pages/Parts.tsx index 40b3331..fcb7886 100644 --- a/apps/web/src/pages/Parts.tsx +++ b/apps/web/src/pages/Parts.tsx @@ -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[]>( () => [ { @@ -107,6 +126,18 @@ export default function Parts() { {row.original.manufacturer.name} ), }, + { + id: 'category', + header: 'Category', + cell: ({ row }) => + row.original.partModel.category ? ( + + {row.original.partModel.category.name} + + ) : ( + + ), + }, { accessorKey: 'state', header: 'State', @@ -159,7 +190,7 @@ export default function Parts() { - + navigate(`/parts/${row.original.id}`)}> View @@ -167,6 +198,15 @@ export default function Parts() { Edit + {row.original.state === 'SPARE' && ( + takeForRepairMutation.mutate(row.original.id)} + disabled={takeForRepairMutation.isPending} + > + + Take into custody + + )} {isAdmin && ( <> @@ -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 }) => ( 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({ ))} +