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
+1 -1
View File
@@ -1,6 +1,6 @@
# Vector # 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. 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.
+16
View File
@@ -31,3 +31,19 @@ export async function dropOff(
next(err); 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), validate('body', DropOffRequest),
ctrl.dropOff, ctrl.dropOff,
); );
router.post('/:partId/take-for-repair', requireAuth, ctrl.takeForRepair);
export default router; export default router;
+57 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import type { Tx, Actor } from './types.js'; 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 custodian: Actor = { id: 'user-1', username: 'tech', role: 'TECHNICIAN' };
const admin: Actor = { id: 'user-admin', username: 'boss', role: 'ADMIN' }; const admin: Actor = { id: 'user-admin', username: 'boss', role: 'ADMIN' };
@@ -166,4 +166,60 @@ describe('custody.dropOff', () => {
dropOff(tx, 'p-missing', { binId: null }, custodian), dropOff(tx, 'p-missing', { binId: null }, custodian),
).rejects.toMatchObject({ status: 404 }); ).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 }; 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( export async function dropOff(
tx: Tx, tx: Tx,
partId: string, partId: string,
@@ -36,23 +46,35 @@ export async function dropOff(
const part = await tx.part.findUnique({ where: { id: partId } }); const part = await tx.part.findUnique({ where: { id: partId } });
if (!part) throw errors.notFound('Part'); if (!part) throw errors.notFound('Part');
if ( const target = DROP_OFF_TARGET[part.state];
part.state !== 'PENDING_DROP_IN_CUSTODY' && if (!target) throw errors.badRequest('Part is not in custody');
part.state !== 'PENDING_DESTRUCTION_IN_CUSTODY'
) {
throw errors.badRequest('Part is not in custody');
}
if (part.custodianId !== actor.id && actor.role !== 'ADMIN') { if (part.custodianId !== actor.id && actor.role !== 'ADMIN') {
throw errors.forbidden('Only the current custodian can drop off this part'); throw errors.forbidden('Only the current custodian can drop off this part');
} }
if (target.binRequired && !input.binId) {
const nextState = throw errors.badRequest('A bin is required when returning a spare to inventory');
part.state === 'PENDING_DROP_IN_CUSTODY' ? 'BROKEN' : 'PENDING_DESTRUCTION'; }
return partsSvc.update( return partsSvc.update(
tx, tx,
partId, 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, 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');
});
});
+10 -5
View File
@@ -14,15 +14,16 @@ function mapUniqueViolation(target: unknown): string {
export async function list(tx: Tx, q: HostListQuery) { export async function list(tx: Tx, q: HostListQuery) {
const { page, pageSize, q: search } = q; const { page, pageSize, q: search } = q;
const where: Prisma.HostWhereInput = search const where: Prisma.HostWhereInput = {};
? { if (search) {
OR: [ where.OR = [
{ name: { contains: search } }, { name: { contains: search } },
{ assetId: { contains: search } }, { assetId: { contains: search } },
{ location: { 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([ const [data, total] = await Promise.all([
tx.host.findMany({ tx.host.findMany({
where, where,
@@ -55,6 +56,8 @@ export async function create(tx: Tx, input: CreateHostRequest) {
name: input.name, name: input.name,
location: input.location ?? null, location: input.location ?? null,
notes: input.notes ?? null, notes: input.notes ?? null,
state: input.state ?? 'DEPLOYED',
stack: input.stack ?? 'PRODUCTION',
}, },
}); });
} catch (err) { } 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.name !== undefined) data.name = input.name;
if (input.location !== undefined) data.location = input.location; if (input.location !== undefined) data.location = input.location;
if (input.notes !== undefined) data.notes = input.notes; 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 { try {
return await tx.host.update({ where: { id }, data }); return await tx.host.update({ where: { id }, data });
} catch (err) { } catch (err) {
+11 -2
View File
@@ -9,13 +9,15 @@ import type { Tx } from './types.js';
const partModelInclude = { const partModelInclude = {
manufacturer: true, manufacturer: true,
category: true,
_count: { select: { parts: true } }, _count: { select: { parts: true } },
} satisfies Prisma.PartModelInclude; } satisfies Prisma.PartModelInclude;
export async function list(tx: Tx, q: PartModelListQuery) { 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 = {}; const where: Prisma.PartModelWhereInput = {};
if (manufacturerId) where.manufacturerId = manufacturerId; if (manufacturerId) where.manufacturerId = manufacturerId;
if (categoryId) where.categoryId = categoryId;
if (search) where.mpn = { contains: search }; if (search) where.mpn = { contains: search };
if (eolBefore) where.eolDate = { lte: new Date(eolBefore) }; if (eolBefore) where.eolDate = { lte: new Date(eolBefore) };
@@ -42,6 +44,7 @@ export async function create(tx: Tx, input: CreatePartModelRequest) {
data: { data: {
manufacturerId: input.manufacturerId, manufacturerId: input.manufacturerId,
mpn: input.mpn, mpn: input.mpn,
categoryId: input.categoryId ?? null,
eolDate: input.eolDate ? new Date(input.eolDate) : null, eolDate: input.eolDate ? new Date(input.eolDate) : null,
destroyOnFail: input.destroyOnFail ?? false, destroyOnFail: input.destroyOnFail ?? false,
notes: input.notes ?? null, notes: input.notes ?? null,
@@ -51,7 +54,7 @@ export async function create(tx: Tx, input: CreatePartModelRequest) {
} catch (err) { } catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) { if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2002') throw errors.conflict('MPN already exists for this manufacturer'); 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; throw err;
} }
@@ -63,6 +66,11 @@ export async function update(tx: Tx, id: string, input: UpdatePartModelRequest)
data.manufacturer = { connect: { id: input.manufacturerId } }; data.manufacturer = { connect: { id: input.manufacturerId } };
} }
if (input.mpn !== undefined) data.mpn = input.mpn; 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) { if (input.eolDate !== undefined) {
data.eolDate = input.eolDate ? new Date(input.eolDate) : null; 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 instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2025') throw errors.notFound('Part model'); 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 === '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; throw err;
} }
+8 -21
View File
@@ -15,7 +15,7 @@ import type { Actor, Tx } from './types.js';
// The matrix is: // The matrix is:
// DEPLOYED — hostId required, binId forbidden, custodianId forbidden // DEPLOYED — hostId required, binId forbidden, custodianId forbidden
// SPARE / BROKEN / PENDING_DESTRUCTION — binId optional, hostId + custodian 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 // — custodianId required, host + bin forbidden
// Callers only need to pass what's changing; anything omitted is inherited from `current`. // Callers only need to pass what's changing; anything omitted is inherited from `current`.
function resolveLocation( function resolveLocation(
@@ -43,7 +43,11 @@ function resolveLocation(
return { binId: null, hostId, custodianId: null }; 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 = const custodianId =
input.custodianId !== undefined ? input.custodianId : current.custodianId; input.custodianId !== undefined ? input.custodianId : current.custodianId;
if (!custodianId) throw errors.badRequest('A part in custody must name a custodian'); if (!custodianId) throw errors.badRequest('A part in custody must name a custodian');
@@ -65,9 +69,8 @@ function resolveLocation(
const partInclude = { const partInclude = {
manufacturer: true, manufacturer: true,
partModel: true, partModel: { include: { category: true } },
bin: { include: { room: { include: { site: true } } } }, bin: { include: { room: { include: { site: true } } } },
category: true,
host: true, host: true,
custodian: { select: { id: true, username: true } }, custodian: { select: { id: true, username: true } },
tags: { include: { tag: true } }, tags: { include: { tag: true } },
@@ -127,7 +130,6 @@ function buildWhere(q: PartListQuery): Prisma.PartWhereInput {
if (q.custodianId) where.custodianId = q.custodianId; if (q.custodianId) where.custodianId = q.custodianId;
if (q.manufacturerId) where.manufacturerId = q.manufacturerId; if (q.manufacturerId) where.manufacturerId = q.manufacturerId;
if (q.partModelId) where.partModelId = q.partModelId; if (q.partModelId) where.partModelId = q.partModelId;
if (q.categoryId) where.categoryId = q.categoryId;
if (q.serialNumber) where.serialNumber = { contains: q.serialNumber }; if (q.serialNumber) where.serialNumber = { contains: q.serialNumber };
if (q.q) { if (q.q) {
where.OR = [ where.OR = [
@@ -141,6 +143,7 @@ function buildWhere(q: PartListQuery): Prisma.PartWhereInput {
const partModelFilter: Prisma.PartModelWhereInput = {}; const partModelFilter: Prisma.PartModelWhereInput = {};
if (q.mpn) partModelFilter.mpn = { contains: q.mpn }; if (q.mpn) partModelFilter.mpn = { contains: q.mpn };
if (q.eolOnly) partModelFilter.eolDate = { lt: new Date() }; if (q.eolOnly) partModelFilter.eolDate = { lt: new Date() };
if (q.categoryId) partModelFilter.categoryId = q.categoryId;
if (Object.keys(partModelFilter).length > 0) where.partModel = partModelFilter; if (Object.keys(partModelFilter).length > 0) where.partModel = partModelFilter;
return where; return where;
@@ -196,7 +199,6 @@ export async function create(
binId: location.binId, binId: location.binId,
hostId: location.hostId, hostId: location.hostId,
custodianId: location.custodianId, custodianId: location.custodianId,
categoryId: input.categoryId ?? null,
notes: input.notes ?? null, notes: input.notes ?? null,
}, },
include: partInclude, include: partInclude,
@@ -276,11 +278,6 @@ export async function update(
: { disconnect: true }; : { disconnect: true };
} }
if (input.categoryId !== undefined) {
data.category = input.categoryId
? { connect: { id: input.categoryId } }
: { disconnect: true };
}
if (input.notes !== undefined) data.notes = input.notes; if (input.notes !== undefined) data.notes = input.notes;
let part: PartWithRelations; let part: PartWithRelations;
@@ -357,16 +354,6 @@ export async function update(
newValue: input.serialNumber, 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) { if (input.price !== undefined && input.price !== current.price) {
events.push({ events.push({
partId: part.id, partId: part.id,
+76
View File
@@ -514,6 +514,82 @@ describe('repairs.log — validation failures', () => {
expect(events).toEqual(['repair.logged']); 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 () => { it('rejects when broken part is on a different host than the repair', async () => {
const broken = partRow({ const broken = partRow({
id: 'p-broken', id: 'p-broken',
+18 -4
View File
@@ -78,7 +78,7 @@ export async function log(
): Promise<RepairWithRelations> { ): Promise<RepairWithRelations> {
const host = await resolveHost(tx, input); 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({ const replacement = await tx.part.findUnique({
where: { serialNumber: input.replacementSerial }, where: { serialNumber: input.replacementSerial },
include: { partModel: true }, include: { partModel: true },
@@ -86,9 +86,11 @@ export async function log(
if (!replacement) { if (!replacement) {
throw errors.badRequest(`Replacement part ${input.replacementSerial} not found`); 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( 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 { } else {
const pm = await partModelsSvc.upsertByMpn(tx, { 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, manufacturerId: input.brokenManufacturerId,
mpn: input.brokenMpn, mpn: input.brokenMpn,
}); });
}
const created = await tx.part.create({ const created = await tx.part.create({
data: { data: {
serialNumber: input.brokenSerial, 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'; 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 ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm"> <DialogContent className="max-w-sm">
<DialogHeader> <DialogHeader>
<DialogTitle>Drop in bin</DialogTitle> <DialogTitle>{title}</DialogTitle>
<DialogDescription> <DialogDescription>{description}</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>
</DialogHeader> </DialogHeader>
<div className="space-y-2 text-sm"> <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)} onValueChange={(v) => setBinId(v === UNASSIGNED ? '' : v)}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Unassigned" /> <SelectValue placeholder={returningSpare ? 'Select a bin' : 'Unassigned'} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value={UNASSIGNED}>Unassigned</SelectItem> {!returningSpare && <SelectItem value={UNASSIGNED}>Unassigned</SelectItem>}
{bins.data?.data.map((b) => ( {bins.data?.data.map((b) => (
<SelectItem key={b.id} value={b.id}> <SelectItem key={b.id} value={b.id}>
{b.fullPath ?? b.name} {b.fullPath ?? b.name}
@@ -84,9 +90,9 @@ export function DropOffDialog({ part, onOpenChange, onConfirm, pending }: DropOf
> >
Cancel Cancel
</Button> </Button>
<Button onClick={() => onConfirm(binId || null)} disabled={pending}> <Button onClick={() => onConfirm(binId || null)} disabled={confirmDisabled}>
{pending && <Loader2 className="h-4 w-4 animate-spin" />} {pending && <Loader2 className="h-4 w-4 animate-spin" />}
Drop off {returningSpare ? 'Return' : 'Drop off'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -5,6 +5,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { z } from 'zod'; import { z } from 'zod';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { HostStack, HostState } from '@vector/shared';
import { import {
Button, Button,
Dialog, Dialog,
@@ -20,6 +21,11 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
Input, Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Textarea, Textarea,
} from '@vector/ui'; } from '@vector/ui';
import { createHost, updateHost } from '../../lib/api/hosts.js'; import { createHost, updateHost } from '../../lib/api/hosts.js';
@@ -32,9 +38,22 @@ const Schema = z.object({
name: z.string().min(1, 'Required').max(128), name: z.string().min(1, 'Required').max(128),
location: z.string().max(256).optional(), location: z.string().max(256).optional(),
notes: z.string().max(4096).optional(), notes: z.string().max(4096).optional(),
state: HostState,
stack: HostStack,
}); });
type Values = z.infer<typeof Schema>; 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 { interface HostFormDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
@@ -47,7 +66,14 @@ export function HostFormDialog({ open, onOpenChange, host }: HostFormDialogProps
const form = useForm<Values>({ const form = useForm<Values>({
resolver: zodResolver(Schema), resolver: zodResolver(Schema),
defaultValues: { assetId: '', name: '', location: '', notes: '' }, defaultValues: {
assetId: '',
name: '',
location: '',
notes: '',
state: 'DEPLOYED',
stack: 'PRODUCTION',
},
}); });
useEffect(() => { useEffect(() => {
@@ -57,6 +83,8 @@ export function HostFormDialog({ open, onOpenChange, host }: HostFormDialogProps
name: host?.name ?? '', name: host?.name ?? '',
location: host?.location ?? '', location: host?.location ?? '',
notes: host?.notes ?? '', notes: host?.notes ?? '',
state: host?.state ?? 'DEPLOYED',
stack: host?.stack ?? 'PRODUCTION',
}); });
}, [open, host, form]); }, [open, host, form]);
@@ -68,6 +96,8 @@ export function HostFormDialog({ open, onOpenChange, host }: HostFormDialogProps
name: values.name, name: values.name,
location: values.location ? values.location : null, location: values.location ? values.location : null,
notes: values.notes ? values.notes : null, notes: values.notes ? values.notes : null,
state: values.state,
stack: values.stack,
}); });
} }
return createHost({ return createHost({
@@ -75,6 +105,8 @@ export function HostFormDialog({ open, onOpenChange, host }: HostFormDialogProps
name: values.name, name: values.name,
location: values.location ? values.location : null, location: values.location ? values.location : null,
notes: values.notes ? values.notes : null, notes: values.notes ? values.notes : null,
state: values.state,
stack: values.stack,
}); });
}, },
onSuccess: () => { onSuccess: () => {
@@ -137,6 +169,56 @@ export function HostFormDialog({ open, onOpenChange, host }: HostFormDialogProps
</FormItem> </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 <FormField
control={form.control} control={form.control}
name="notes" 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'; } from '@vector/ui';
import { createPartModel, updatePartModel } from '../../lib/api/part-models.js'; import { createPartModel, updatePartModel } from '../../lib/api/part-models.js';
import { listManufacturers } from '../../lib/api/manufacturers.js'; import { listManufacturers } from '../../lib/api/manufacturers.js';
import { createCategory, listCategories } from '../../lib/api/categories.js';
import { ApiRequestError } from '../../lib/api/client.js'; import { ApiRequestError } from '../../lib/api/client.js';
import { queryKeys } from '../../lib/queryKeys.js'; import { queryKeys } from '../../lib/queryKeys.js';
import type { PartModel } from '../../lib/api/types.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({ const Schema = z.object({
manufacturerId: z.string().uuid('Pick a manufacturer'), manufacturerId: z.string().uuid('Pick a manufacturer'),
mpn: z.string().min(1, 'Required').max(128), mpn: z.string().min(1, 'Required').max(128),
categoryId: z.string().optional(), // '' = unassigned
eolDate: z eolDate: z
.string() .string()
.regex(/^\d{4}-\d{2}-\d{2}$/, 'YYYY-MM-DD') .regex(/^\d{4}-\d{2}-\d{2}$/, 'YYYY-MM-DD')
@@ -48,6 +50,8 @@ const Schema = z.object({
}); });
type Values = z.infer<typeof Schema>; type Values = z.infer<typeof Schema>;
const UNASSIGNED = '__none__';
function isoToDateInput(iso: string | null): string { function isoToDateInput(iso: string | null): string {
if (!iso) return ''; if (!iso) return '';
return new Date(iso).toISOString().slice(0, 10); return new Date(iso).toISOString().slice(0, 10);
@@ -72,6 +76,7 @@ export function PartModelFormDialog({
defaultValues: { defaultValues: {
manufacturerId: '', manufacturerId: '',
mpn: '', mpn: '',
categoryId: '',
eolDate: '', eolDate: '',
destroyOnFail: false, destroyOnFail: false,
notes: '', notes: '',
@@ -83,6 +88,7 @@ export function PartModelFormDialog({
form.reset({ form.reset({
manufacturerId: partModel?.manufacturerId ?? '', manufacturerId: partModel?.manufacturerId ?? '',
mpn: partModel?.mpn ?? '', mpn: partModel?.mpn ?? '',
categoryId: partModel?.categoryId ?? '',
eolDate: isoToDateInput(partModel?.eolDate ?? null), eolDate: isoToDateInput(partModel?.eolDate ?? null),
destroyOnFail: partModel?.destroyOnFail ?? false, destroyOnFail: partModel?.destroyOnFail ?? false,
notes: partModel?.notes ?? '', notes: partModel?.notes ?? '',
@@ -95,11 +101,28 @@ export function PartModelFormDialog({
enabled: open, 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({ const mutation = useMutation({
mutationFn: async (values: Values) => { mutationFn: async (values: Values) => {
const payload = { const payload = {
manufacturerId: values.manufacturerId, manufacturerId: values.manufacturerId,
mpn: values.mpn, mpn: values.mpn,
categoryId: values.categoryId ? values.categoryId : null,
eolDate: values.eolDate ? values.eolDate : null, eolDate: values.eolDate ? values.eolDate : null,
destroyOnFail: values.destroyOnFail, destroyOnFail: values.destroyOnFail,
notes: values.notes ? values.notes : null, notes: values.notes ? values.notes : null,
@@ -166,6 +189,47 @@ export function PartModelFormDialog({
</FormItem> </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 <FormField
control={form.control} control={form.control}
name="eolDate" name="eolDate"
@@ -1,4 +1,4 @@
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -6,6 +6,8 @@ import { z } from 'zod';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { PartState } from '@vector/shared'; import { PartState } from '@vector/shared';
import { PartModelCombobox } from '../common/PartModelCombobox.js';
import type { PartModel } from '../../lib/api/types.js';
import { import {
Button, Button,
Dialog, Dialog,
@@ -38,12 +40,13 @@ import { queryKeys } from '../../lib/queryKeys.js';
import { partStateOptions } from './PartStateBadge.js'; import { partStateOptions } from './PartStateBadge.js';
// Schema reflects the server's CreatePartRequest but keeps strings for the form, letting the // 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 const PartFormSchema = z
.object({ .object({
serialNumber: z.string().min(1, 'Required').max(128), serialNumber: z.string().min(1, 'Required').max(128),
mpn: z.string().min(1, 'Required').max(128), partModelId: z.string().optional(), // set when an existing model is picked
manufacturerId: z.string().uuid('Select a manufacturer'), mpn: z.string().max(128).optional(), // set when creating a new model
manufacturerId: z.string().optional(),
state: PartState, state: PartState,
binId: z.string().optional(), // '' = none binId: z.string().optional(), // '' = none
hostId: z.string().optional(), // '' = none hostId: z.string().optional(), // '' = none
@@ -51,6 +54,22 @@ const PartFormSchema = z
notes: z.string().max(4096).optional(), notes: z.string().max(4096).optional(),
}) })
.superRefine((v, ctx) => { .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) { if (v.state === 'DEPLOYED' && !v.hostId) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
@@ -73,10 +92,13 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
const editing = Boolean(part); const editing = Boolean(part);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [pickedModel, setPickedModel] = useState<PartModel | null>(null);
const form = useForm<PartFormValues>({ const form = useForm<PartFormValues>({
resolver: zodResolver(PartFormSchema), resolver: zodResolver(PartFormSchema),
defaultValues: { defaultValues: {
serialNumber: '', serialNumber: '',
partModelId: '',
mpn: '', mpn: '',
manufacturerId: '', manufacturerId: '',
state: 'SPARE', state: 'SPARE',
@@ -89,20 +111,24 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
form.reset( if (part) {
part setPickedModel(part.partModel ?? null);
? { form.reset({
serialNumber: part.serialNumber, serialNumber: part.serialNumber,
mpn: part.partModel.mpn, partModelId: part.partModelId,
manufacturerId: part.manufacturerId, mpn: '',
manufacturerId: '',
state: part.state, state: part.state,
binId: part.binId ?? '', binId: part.binId ?? '',
hostId: part.hostId ?? '', hostId: part.hostId ?? '',
price: part.price != null ? String(part.price) : '', price: part.price != null ? String(part.price) : '',
notes: part.notes ?? '', notes: part.notes ?? '',
} });
: { } else {
setPickedModel(null);
form.reset({
serialNumber: '', serialNumber: '',
partModelId: '',
mpn: '', mpn: '',
manufacturerId: '', manufacturerId: '',
state: 'SPARE', state: 'SPARE',
@@ -110,8 +136,8 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
hostId: '', hostId: '',
price: '', price: '',
notes: '', notes: '',
}, });
); }
}, [open, part, form]); }, [open, part, form]);
const watchedState = form.watch('state'); const watchedState = form.watch('state');
@@ -137,16 +163,18 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async (values: PartFormValues) => { mutationFn: async (values: PartFormValues) => {
const deployed = values.state === 'DEPLOYED'; const deployed = values.state === 'DEPLOYED';
const payload = { const base = {
serialNumber: values.serialNumber, serialNumber: values.serialNumber,
mpn: values.mpn,
manufacturerId: values.manufacturerId,
state: values.state, state: values.state,
binId: deployed ? null : values.binId ? values.binId : null, binId: deployed ? null : values.binId ? values.binId : null,
hostId: deployed ? (values.hostId ? values.hostId : null) : null, hostId: deployed ? (values.hostId ? values.hostId : null) : null,
price: values.price === '' ? null : Number(values.price), price: values.price === '' ? null : Number(values.price),
notes: values.notes ? values.notes : null, 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 return editing && part
? updatePart(part.id, payload) ? updatePart(part.id, payload)
: createPart(payload); : createPart(payload);
@@ -191,7 +219,6 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-3"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<FormField <FormField
control={form.control} control={form.control}
name="serialNumber" name="serialNumber"
@@ -205,28 +232,47 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="mpn" name="partModelId"
render={({ field }) => ( render={() => (
<FormItem> <FormItem>
<FormLabel>MPN</FormLabel> <FormLabel>Part model</FormLabel>
<FormControl> <PartModelCombobox
<Input {...field} /> value={pickedModel}
</FormControl> 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 /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
</div>
{!pickedModel && form.watch('mpn') && (
<FormField <FormField
control={form.control} control={form.control}
name="manufacturerId" name="manufacturerId"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Manufacturer</FormLabel> <FormLabel>Manufacturer (for new model)</FormLabel>
<Select value={field.value} onValueChange={field.onChange}> <Select value={field.value ?? ''} onValueChange={field.onChange}>
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select manufacturer" /> <SelectValue placeholder="Select manufacturer" />
@@ -244,6 +290,7 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
</FormItem> </FormItem>
)} )}
/> />
)}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<FormField <FormField
@@ -8,6 +8,7 @@ const STATE_LABEL: Record<PartState, string> = {
PENDING_DESTRUCTION: 'Pending destruction', PENDING_DESTRUCTION: 'Pending destruction',
PENDING_DROP_IN_CUSTODY: 'In custody', PENDING_DROP_IN_CUSTODY: 'In custody',
PENDING_DESTRUCTION_IN_CUSTODY: 'In custody (destroy)', PENDING_DESTRUCTION_IN_CUSTODY: 'In custody (destroy)',
PENDING_REPAIR: 'Held for repair',
}; };
const STATE_VARIANT: Record<PartState, BadgeProps['variant']> = { const STATE_VARIANT: Record<PartState, BadgeProps['variant']> = {
@@ -17,6 +18,7 @@ const STATE_VARIANT: Record<PartState, BadgeProps['variant']> = {
PENDING_DESTRUCTION: 'destructive', PENDING_DESTRUCTION: 'destructive',
PENDING_DROP_IN_CUSTODY: 'outline', PENDING_DROP_IN_CUSTODY: 'outline',
PENDING_DESTRUCTION_IN_CUSTODY: 'outline', PENDING_DESTRUCTION_IN_CUSTODY: 'outline',
PENDING_REPAIR: 'outline',
}; };
export function PartStateBadge({ state }: { state: PartState }) { 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 { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; 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 { listParts } from '../../lib/api/parts.js';
import { ApiRequestError } from '../../lib/api/client.js'; import { ApiRequestError } from '../../lib/api/client.js';
import { queryKeys } from '../../lib/queryKeys.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({ // 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'), hostId: z.string().uuid('Pick a host'),
brokenSerial: z.string().trim().min(1, 'Required').max(128), brokenSerial: z.string().trim().min(1, 'Required').max(128),
brokenMpn: z.string().trim().min(1, 'Required').max(128), brokenPartModelId: z.string().uuid().optional(),
brokenManufacturerId: z.string().uuid('Select a manufacturer'), brokenMpn: z.string().trim().max(128).optional(),
brokenManufacturerId: z.string().uuid().optional(),
replacementSerial: z.string().trim().min(1, 'Required').max(128), replacementSerial: z.string().trim().min(1, 'Required').max(128),
fmId: z.string().optional(), 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>; type Values = z.infer<typeof Schema>;
const NO_FM = '__none__'; const NO_FM = '__none__';
@@ -57,27 +83,34 @@ interface LogRepairDialogProps {
export function LogRepairDialog({ open, onOpenChange, onLogged }: LogRepairDialogProps) { export function LogRepairDialog({ open, onOpenChange, onLogged }: LogRepairDialogProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [pickedModel, setPickedModel] = useState<PartModel | null>(null);
const form = useForm<Values>({ const form = useForm<Values>({
resolver: zodResolver(Schema), resolver: zodResolver(Schema),
defaultValues: { defaultValues: {
hostId: '', hostId: '',
brokenSerial: '', brokenSerial: '',
brokenPartModelId: '',
brokenMpn: '', brokenMpn: '',
brokenManufacturerId: '', brokenManufacturerId: '',
replacementSerial: '', replacementSerial: '',
fmId: '', fmId: '',
brokenExists: false,
}, },
}); });
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
setPickedModel(null);
form.reset({ form.reset({
hostId: '', hostId: '',
brokenSerial: '', brokenSerial: '',
brokenPartModelId: '',
brokenMpn: '', brokenMpn: '',
brokenManufacturerId: '', brokenManufacturerId: '',
replacementSerial: '', replacementSerial: '',
fmId: '', fmId: '',
brokenExists: false,
}); });
}, [open, form]); }, [open, form]);
@@ -115,16 +148,30 @@ export function LogRepairDialog({ open, onOpenChange, onLogged }: LogRepairDialo
(p) => p.serialNumber === brokenSerial, (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({ const mutation = useMutation({
mutationFn: (v: Values) => mutationFn: (v: Values) => {
logRepair({ const base = {
hostId: v.hostId, hostId: v.hostId,
brokenSerial: v.brokenSerial.trim(), brokenSerial: v.brokenSerial.trim(),
brokenMpn: v.brokenMpn.trim(),
brokenManufacturerId: v.brokenManufacturerId,
replacementSerial: v.replacementSerial.trim(), replacementSerial: v.replacementSerial.trim(),
fmId: v.fmId ? v.fmId : undefined, 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) => { onSuccess: (repair) => {
toast.success('Repair logged'); toast.success('Repair logged');
queryClient.invalidateQueries({ queryKey: queryKeys.repairs.all }); queryClient.invalidateQueries({ queryKey: queryKeys.repairs.all });
@@ -217,27 +264,48 @@ export function LogRepairDialog({ open, onOpenChange, onLogged }: LogRepairDialo
/> />
</div> </div>
<div className="grid grid-cols-2 gap-3"> {!existingBroken && (
<>
<FormField <FormField
control={form.control} control={form.control}
name="brokenMpn" name="brokenPartModelId"
render={({ field }) => ( render={() => (
<FormItem> <FormItem>
<FormLabel>Broken MPN</FormLabel> <FormLabel>Broken part model</FormLabel>
<FormControl> <PartModelCombobox
<Input {...field} /> value={pickedModel}
</FormControl> 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 /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
{!pickedModel && form.watch('brokenMpn') && (
<FormField <FormField
control={form.control} control={form.control}
name="brokenManufacturerId" name="brokenManufacturerId"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Broken manufacturer</FormLabel> <FormLabel>Manufacturer (for new model)</FormLabel>
<Select value={field.value} onValueChange={field.onChange}> <Select value={field.value ?? ''} onValueChange={field.onChange}>
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select" /> <SelectValue placeholder="Select" />
@@ -255,7 +323,9 @@ export function LogRepairDialog({ open, onOpenChange, onLogged }: LogRepairDialo
</FormItem> </FormItem>
)} )}
/> />
</div> )}
</>
)}
<FormField <FormField
control={form.control} 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); const res = await api.post<Part>(`/custody/${partId}/drop-off`, input);
return res.data; 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; page?: number;
pageSize?: number; pageSize?: number;
q?: string; q?: string;
state?: string;
stack?: string;
}; };
export function listHosts(filters: HostListFilters = {}) { export function listHosts(filters: HostListFilters = {}) {
+1
View File
@@ -10,6 +10,7 @@ export type PartModelListFilters = {
page?: number; page?: number;
pageSize?: number; pageSize?: number;
manufacturerId?: string; manufacturerId?: string;
categoryId?: string;
q?: string; q?: string;
eolBefore?: 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). // Shapes mirror Prisma rows the API returns (dates serialized as ISO strings).
// Keep these in sync with apps/api/src/services responses. // Keep these in sync with apps/api/src/services responses.
@@ -14,12 +21,14 @@ export interface PartModel {
id: string; id: string;
manufacturerId: string; manufacturerId: string;
mpn: string; mpn: string;
categoryId: string | null;
eolDate: string | null; eolDate: string | null;
destroyOnFail: boolean; destroyOnFail: boolean;
notes: string | null; notes: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
manufacturer?: Manufacturer; manufacturer?: Manufacturer;
category?: Category | null;
_count?: { parts: number }; _count?: { parts: number };
} }
@@ -59,7 +68,6 @@ export interface Part {
price: number | null; price: number | null;
state: PartState; state: PartState;
binId: string | null; binId: string | null;
categoryId: string | null;
hostId: string | null; hostId: string | null;
custodianId: string | null; custodianId: string | null;
notes: string | null; notes: string | null;
@@ -99,6 +107,8 @@ export interface Host {
name: string; name: string;
location: string | null; location: string | null;
notes: string | null; notes: string | null;
state: HostState;
stack: HostStack;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
+5 -3
View File
@@ -35,6 +35,7 @@ const STATE_LABELS: Record<PartState, string> = {
PENDING_DESTRUCTION: 'Pending destruction', PENDING_DESTRUCTION: 'Pending destruction',
PENDING_DROP_IN_CUSTODY: 'In custody', PENDING_DROP_IN_CUSTODY: 'In custody',
PENDING_DESTRUCTION_IN_CUSTODY: 'In custody (destroy)', PENDING_DESTRUCTION_IN_CUSTODY: 'In custody (destroy)',
PENDING_REPAIR: 'Held for repair',
}; };
const STATE_COLORS: Record<PartState, string> = { const STATE_COLORS: Record<PartState, string> = {
@@ -44,10 +45,11 @@ const STATE_COLORS: Record<PartState, string> = {
PENDING_DESTRUCTION: 'hsl(38 92% 50%)', PENDING_DESTRUCTION: 'hsl(38 92% 50%)',
PENDING_DROP_IN_CUSTODY: 'hsl(262 83% 58%)', PENDING_DROP_IN_CUSTODY: 'hsl(262 83% 58%)',
PENDING_DESTRUCTION_IN_CUSTODY: 'hsl(340 82% 52%)', PENDING_DESTRUCTION_IN_CUSTODY: 'hsl(340 82% 52%)',
PENDING_REPAIR: 'hsl(197 80% 50%)',
}; };
function currency(cents: number): string { function currency(dollars: number): string {
return (cents / 100).toLocaleString(undefined, { style: 'currency', currency: 'USD' }); return dollars.toLocaleString(undefined, { style: 'currency', currency: 'USD' });
} }
export default function Dashboard() { export default function Dashboard() {
@@ -159,7 +161,7 @@ export default function Dashboard() {
.map((s) => ({ .map((s) => ({
name: STATE_LABELS[s.state], name: STATE_LABELS[s.state],
state: s.state, state: s.state,
value: s.totalPrice / 100, value: s.totalPrice,
}))} }))}
dataKey="value" dataKey="value"
nameKey="name" 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 { Edit, MoreHorizontal, Plus, Server, Trash2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
Badge,
Button, Button,
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -55,6 +56,25 @@ export default function Hosts() {
header: 'Name', header: 'Name',
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>, 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', accessorKey: 'location',
header: 'Location', header: 'Location',
+85 -9
View File
@@ -1,8 +1,13 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { parseAsString, useQueryState } from 'nuqs'; import { parseAsString, useQueryState } from 'nuqs';
import { ChevronRight, MapPin } from 'lucide-react';
import { PageHeader } from '../components/layout/PageHeader.js'; import { PageHeader } from '../components/layout/PageHeader.js';
import { SiteList } from '../components/locations/SiteList.js'; import { SiteRoomTree } from '../components/locations/SiteRoomTree.js';
import { RoomDrawer } from '../components/locations/RoomDrawer.js';
import { BinGrid } from '../components/locations/BinGrid.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'; import { useAuth } from '../contexts/AuthContext.js';
export default function Locations() { export default function Locations() {
@@ -20,23 +25,94 @@ export default function Locations() {
void setRoomId(id || null); 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 ( return (
<div className="flex h-[calc(100vh-var(--spacing-topbar,3.25rem)-3rem)] flex-col gap-4"> <div className="flex h-[calc(100vh-var(--spacing-topbar,3.25rem)-3rem)] flex-col gap-4">
<PageHeader <PageHeader
title="Locations" 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"> <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>
<div className="border-r border-border"> <div className="flex min-h-0 flex-col">
<RoomDrawer siteId={siteId} selectedId={roomId} onSelect={handleRoom} canEdit={canEdit} /> <Breadcrumb siteName={siteName} roomName={roomName} />
</div> <div className="min-h-0 flex-1 overflow-y-auto">
<div> {roomId ? (
<BinGrid roomId={roomId} canEdit={canEdit} /> <BinGrid roomId={roomId} canEdit={canEdit} />
) : (
<EmptyPane siteSelected={Boolean(siteId)} />
)}
</div>
</div> </div>
</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>
);
}
+17 -6
View File
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { useMutation, useQueryClient } from '@tanstack/react-query'; 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 { toast } from 'sonner';
import { Button } from '@vector/ui'; import { Button } from '@vector/ui';
import { PageHeader } from '../components/layout/PageHeader.js'; import { PageHeader } from '../components/layout/PageHeader.js';
@@ -74,13 +74,24 @@ export default function MyCustody() {
{ {
id: 'actions', id: 'actions',
header: () => <span className="sr-only">Actions</span>, header: () => <span className="sr-only">Actions</span>,
size: 140, size: 160,
cell: ({ row }) => ( cell: ({ row }) => {
<Button size="sm" variant="outline" onClick={() => setDropping(row.original)}> 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" /> <PackageCheck className="h-3.5 w-3.5" />
Drop in bin )}
{pending ? 'Return to bin' : 'Drop in bin'}
</Button> </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> <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', accessorKey: 'eolDate',
header: 'EOL', header: 'EOL',
@@ -87,7 +97,7 @@ export default function PartModels() {
row.original.destroyOnFail ? ( row.original.destroyOnFail ? (
<Check className="h-4 w-4 text-foreground" /> <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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { parseAsString } from 'nuqs'; import { parseAsString } from 'nuqs';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Edit, MoreHorizontal, Package, Plus, Trash2 } from 'lucide-react'; import { Edit, HandHelping, MoreHorizontal, Package, Plus, Trash2 } from 'lucide-react';
import { import {
Button, Button,
DropdownMenu, DropdownMenu,
@@ -26,7 +26,9 @@ import { PartBulkStateDialog } from '../components/parts/PartBulkStateDialog.js'
import { ConfirmDialog } from '../components/ConfirmDialog.js'; import { ConfirmDialog } from '../components/ConfirmDialog.js';
import { listParts, deletePart } from '../lib/api/parts.js'; import { listParts, deletePart } from '../lib/api/parts.js';
import { listManufacturers } from '../lib/api/manufacturers.js'; import { listManufacturers } from '../lib/api/manufacturers.js';
import { listCategories } from '../lib/api/categories.js';
import { listTags } from '../lib/api/tags.js'; import { listTags } from '../lib/api/tags.js';
import { takeForRepair } from '../lib/api/custody.js';
import { ApiRequestError } from '../lib/api/client.js'; import { ApiRequestError } from '../lib/api/client.js';
import type { Part } from '../lib/api/types.js'; import type { Part } from '../lib/api/types.js';
import { queryKeys } from '../lib/queryKeys.js'; import { queryKeys } from '../lib/queryKeys.js';
@@ -35,12 +37,14 @@ import { useAuth } from '../contexts/AuthContext.js';
type PartsFilters = { type PartsFilters = {
state: string | null; state: string | null;
manufacturerId: string | null; manufacturerId: string | null;
categoryId: string | null;
tagId: string | null; tagId: string | null;
}; };
const filterParsers = { const filterParsers = {
state: parseAsString, state: parseAsString,
manufacturerId: parseAsString, manufacturerId: parseAsString,
categoryId: parseAsString,
tagId: parseAsString, tagId: parseAsString,
}; };
@@ -62,6 +66,10 @@ export default function Parts() {
queryKey: queryKeys.manufacturers.list({ pageSize: 100 }), queryKey: queryKeys.manufacturers.list({ pageSize: 100 }),
queryFn: () => listManufacturers({ pageSize: 100 }), queryFn: () => listManufacturers({ pageSize: 100 }),
}); });
const categoriesQuery = useQuery({
queryKey: queryKeys.categories.list({ pageSize: 100 }),
queryFn: () => listCategories({ pageSize: 100 }),
});
const tagsQuery = useQuery({ const tagsQuery = useQuery({
queryKey: queryKeys.tags.list({ pageSize: 100 }), queryKey: queryKeys.tags.list({ pageSize: 100 }),
queryFn: () => listTags({ 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>[]>( 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> <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', accessorKey: 'state',
header: 'State', header: 'State',
@@ -159,7 +190,7 @@ export default function Parts() {
<MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40"> <DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem onSelect={() => navigate(`/parts/${row.original.id}`)}> <DropdownMenuItem onSelect={() => navigate(`/parts/${row.original.id}`)}>
View View
</DropdownMenuItem> </DropdownMenuItem>
@@ -167,6 +198,15 @@ export default function Parts() {
<Edit className="h-3.5 w-3.5" /> <Edit className="h-3.5 w-3.5" />
Edit Edit
</DropdownMenuItem> </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 && ( {isAdmin && (
<> <>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@@ -184,7 +224,7 @@ export default function Parts() {
), ),
}, },
], ],
[navigate, isAdmin], [navigate, isAdmin, takeForRepairMutation],
); );
return ( return (
@@ -213,6 +253,7 @@ export default function Parts() {
sort: params.sort, sort: params.sort,
state: params.filters.state, state: params.filters.state,
manufacturerId: params.filters.manufacturerId, manufacturerId: params.filters.manufacturerId,
categoryId: params.filters.categoryId,
tagId: params.filters.tagId, tagId: params.filters.tagId,
}) })
} }
@@ -224,6 +265,7 @@ export default function Parts() {
sort: params.sort, sort: params.sort,
state: params.filters.state ?? undefined, state: params.filters.state ?? undefined,
manufacturerId: params.filters.manufacturerId ?? undefined, manufacturerId: params.filters.manufacturerId ?? undefined,
categoryId: params.filters.categoryId ?? undefined,
tagId: params.filters.tagId ?? undefined, tagId: params.filters.tagId ?? undefined,
}) })
} }
@@ -239,12 +281,15 @@ export default function Parts() {
toolbar={({ filters, setFilter }) => ( toolbar={({ filters, setFilter }) => (
<PartsFilters <PartsFilters
manufacturers={manufacturers.data?.data ?? []} manufacturers={manufacturers.data?.data ?? []}
categories={categoriesQuery.data?.data ?? []}
tags={tagsQuery.data?.data ?? []} tags={tagsQuery.data?.data ?? []}
state={filters.state ?? ALL} state={filters.state ?? ALL}
manufacturerId={filters.manufacturerId ?? ALL} manufacturerId={filters.manufacturerId ?? ALL}
categoryId={filters.categoryId ?? ALL}
tagId={filters.tagId ?? ALL} tagId={filters.tagId ?? ALL}
onState={(v) => setFilter('state', v === ALL ? null : v)} onState={(v) => setFilter('state', v === ALL ? null : v)}
onManufacturer={(v) => setFilter('manufacturerId', 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)} onTag={(v) => setFilter('tagId', v === ALL ? null : v)}
/> />
)} )}
@@ -296,23 +341,29 @@ export default function Parts() {
interface PartsFiltersProps { interface PartsFiltersProps {
manufacturers: { id: string; name: string }[]; manufacturers: { id: string; name: string }[];
categories: { id: string; name: string }[];
tags: { id: string; name: string }[]; tags: { id: string; name: string }[];
state: string; state: string;
manufacturerId: string; manufacturerId: string;
categoryId: string;
tagId: string; tagId: string;
onState: (v: string) => void; onState: (v: string) => void;
onManufacturer: (v: string) => void; onManufacturer: (v: string) => void;
onCategory: (v: string) => void;
onTag: (v: string) => void; onTag: (v: string) => void;
} }
function PartsFilters({ function PartsFilters({
manufacturers, manufacturers,
categories,
tags, tags,
state, state,
manufacturerId, manufacturerId,
categoryId,
tagId, tagId,
onState, onState,
onManufacturer, onManufacturer,
onCategory,
onTag, onTag,
}: PartsFiltersProps) { }: PartsFiltersProps) {
return ( return (
@@ -343,6 +394,19 @@ function PartsFilters({
))} ))}
</SelectContent> </SelectContent>
</Select> </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}> <Select value={tagId} onValueChange={onTag}>
<SelectTrigger className="h-8 w-36 text-xs"> <SelectTrigger className="h-8 w-36 text-xs">
<SelectValue placeholder="Tag" /> <SelectValue placeholder="Tag" />
@@ -0,0 +1,78 @@
/*
Warnings:
- You are about to drop the column `categoryId` on the `Part` table. All the data in the column will be lost.
*/
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Host" (
"id" TEXT NOT NULL PRIMARY KEY,
"assetId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"location" TEXT,
"notes" TEXT,
"state" TEXT NOT NULL DEFAULT 'DEPLOYED',
"stack" TEXT NOT NULL DEFAULT 'PRODUCTION',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Host" ("assetId", "createdAt", "id", "location", "name", "notes", "updatedAt") SELECT "assetId", "createdAt", "id", "location", "name", "notes", "updatedAt" FROM "Host";
DROP TABLE "Host";
ALTER TABLE "new_Host" RENAME TO "Host";
CREATE UNIQUE INDEX "Host_assetId_key" ON "Host"("assetId");
CREATE UNIQUE INDEX "Host_name_key" ON "Host"("name");
CREATE INDEX "Host_state_idx" ON "Host"("state");
CREATE INDEX "Host_stack_idx" ON "Host"("stack");
CREATE TABLE "new_Part" (
"id" TEXT NOT NULL PRIMARY KEY,
"serialNumber" TEXT NOT NULL,
"partModelId" TEXT NOT NULL,
"manufacturerId" TEXT NOT NULL,
"price" REAL,
"state" TEXT NOT NULL DEFAULT 'SPARE',
"binId" TEXT,
"hostId" TEXT,
"custodianId" TEXT,
"notes" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Part_partModelId_fkey" FOREIGN KEY ("partModelId") REFERENCES "PartModel" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Part_manufacturerId_fkey" FOREIGN KEY ("manufacturerId") REFERENCES "Manufacturer" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Part_binId_fkey" FOREIGN KEY ("binId") REFERENCES "Bin" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Part_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Part_custodianId_fkey" FOREIGN KEY ("custodianId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_Part" ("binId", "createdAt", "custodianId", "hostId", "id", "manufacturerId", "notes", "partModelId", "price", "serialNumber", "state", "updatedAt") SELECT "binId", "createdAt", "custodianId", "hostId", "id", "manufacturerId", "notes", "partModelId", "price", "serialNumber", "state", "updatedAt" FROM "Part";
DROP TABLE "Part";
ALTER TABLE "new_Part" RENAME TO "Part";
CREATE UNIQUE INDEX "Part_serialNumber_key" ON "Part"("serialNumber");
CREATE INDEX "Part_state_idx" ON "Part"("state");
CREATE INDEX "Part_binId_idx" ON "Part"("binId");
CREATE INDEX "Part_manufacturerId_idx" ON "Part"("manufacturerId");
CREATE INDEX "Part_partModelId_idx" ON "Part"("partModelId");
CREATE INDEX "Part_hostId_idx" ON "Part"("hostId");
CREATE INDEX "Part_custodianId_idx" ON "Part"("custodianId");
CREATE TABLE "new_PartModel" (
"id" TEXT NOT NULL PRIMARY KEY,
"manufacturerId" TEXT NOT NULL,
"mpn" TEXT NOT NULL,
"eolDate" DATETIME,
"destroyOnFail" BOOLEAN NOT NULL DEFAULT false,
"notes" TEXT,
"categoryId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "PartModel_manufacturerId_fkey" FOREIGN KEY ("manufacturerId") REFERENCES "Manufacturer" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "PartModel_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_PartModel" ("createdAt", "destroyOnFail", "eolDate", "id", "manufacturerId", "mpn", "notes", "updatedAt") SELECT "createdAt", "destroyOnFail", "eolDate", "id", "manufacturerId", "mpn", "notes", "updatedAt" FROM "PartModel";
DROP TABLE "PartModel";
ALTER TABLE "new_PartModel" RENAME TO "PartModel";
CREATE INDEX "PartModel_manufacturerId_idx" ON "PartModel"("manufacturerId");
CREATE INDEX "PartModel_eolDate_idx" ON "PartModel"("eolDate");
CREATE INDEX "PartModel_categoryId_idx" ON "PartModel"("categoryId");
CREATE UNIQUE INDEX "PartModel_manufacturerId_mpn_key" ON "PartModel"("manufacturerId", "mpn");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
+9 -4
View File
@@ -62,6 +62,8 @@ model PartModel {
eolDate DateTime? eolDate DateTime?
destroyOnFail Boolean @default(false) destroyOnFail Boolean @default(false)
notes String? notes String?
categoryId String?
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
parts Part[] parts Part[]
@@ -69,6 +71,7 @@ model PartModel {
@@unique([manufacturerId, mpn]) @@unique([manufacturerId, mpn])
@@index([manufacturerId]) @@index([manufacturerId])
@@index([eolDate]) @@index([eolDate])
@@index([categoryId])
} }
model Site { model Site {
@@ -111,7 +114,7 @@ model Category {
description String? description String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
parts Part[] partModels PartModel[]
} }
model Part { model Part {
@@ -125,8 +128,6 @@ model Part {
state String @default("SPARE") state String @default("SPARE")
binId String? binId String?
bin Bin? @relation(fields: [binId], references: [id], onDelete: SetNull) bin Bin? @relation(fields: [binId], references: [id], onDelete: SetNull)
categoryId String?
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
hostId String? hostId String?
host Host? @relation(fields: [hostId], references: [id], onDelete: SetNull) host Host? @relation(fields: [hostId], references: [id], onDelete: SetNull)
custodianId String? custodianId String?
@@ -144,7 +145,6 @@ model Part {
@@index([binId]) @@index([binId])
@@index([manufacturerId]) @@index([manufacturerId])
@@index([partModelId]) @@index([partModelId])
@@index([categoryId])
@@index([hostId]) @@index([hostId])
@@index([custodianId]) @@index([custodianId])
} }
@@ -191,11 +191,16 @@ model Host {
name String @unique name String @unique
location String? location String?
notes String? notes String?
state String @default("DEPLOYED")
stack String @default("PRODUCTION")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
parts Part[] parts Part[]
fms Fm[] fms Fm[]
repairs Repair[] repairs Repair[]
@@index([state])
@@index([stack])
} }
model Fm { model Fm {
+10
View File
@@ -17,6 +17,16 @@ async function main() {
console.log(`Seeded admin user: ${admin.username} (${admin.email})`); console.log(`Seeded admin user: ${admin.username} (${admin.email})`);
console.log('Default password: admin — change this immediately!'); console.log('Default password: admin — change this immediately!');
const categoryNames = ['GPU', 'RAM', 'SSD', 'HDD', 'NIC', 'CPU', 'PSU', 'MOBO'];
for (const name of categoryNames) {
await prisma.category.upsert({
where: { name },
update: {},
create: { name },
});
}
console.log(`Seeded ${categoryNames.length} part categories.`);
} }
main() main()
+7
View File
@@ -7,9 +7,16 @@ export const PartState = z.enum([
'PENDING_DESTRUCTION', 'PENDING_DESTRUCTION',
'PENDING_DROP_IN_CUSTODY', 'PENDING_DROP_IN_CUSTODY',
'PENDING_DESTRUCTION_IN_CUSTODY', 'PENDING_DESTRUCTION_IN_CUSTODY',
'PENDING_REPAIR',
]); ]);
export type PartState = z.infer<typeof PartState>; export type PartState = z.infer<typeof PartState>;
export const HostState = z.enum(['DEPLOYED', 'DEGRADED', 'TESTING']);
export type HostState = z.infer<typeof HostState>;
export const HostStack = z.enum(['PRODUCTION', 'VETTING']);
export type HostStack = z.infer<typeof HostStack>;
export const Role = z.enum(['ADMIN', 'TECHNICIAN']); export const Role = z.enum(['ADMIN', 'TECHNICIAN']);
export type Role = z.infer<typeof Role>; export type Role = z.infer<typeof Role>;
+7
View File
@@ -1,4 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
import { HostStack, HostState } from './enums.js';
import { PaginationQuery } from './pagination.js'; import { PaginationQuery } from './pagination.js';
const AssetId = z const AssetId = z
@@ -12,6 +13,8 @@ export const CreateHostRequest = z.object({
name: z.string().min(1).max(128), name: z.string().min(1).max(128),
location: z.string().max(256).optional().nullable(), location: z.string().max(256).optional().nullable(),
notes: z.string().max(4096).optional().nullable(), notes: z.string().max(4096).optional().nullable(),
state: HostState.optional(),
stack: HostStack.optional(),
}); });
export type CreateHostRequest = z.infer<typeof CreateHostRequest>; export type CreateHostRequest = z.infer<typeof CreateHostRequest>;
@@ -21,11 +24,15 @@ export const UpdateHostRequest = z
name: z.string().min(1).max(128).optional(), name: z.string().min(1).max(128).optional(),
location: z.string().max(256).nullable().optional(), location: z.string().max(256).nullable().optional(),
notes: z.string().max(4096).nullable().optional(), notes: z.string().max(4096).nullable().optional(),
state: HostState.optional(),
stack: HostStack.optional(),
}) })
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' }); .refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
export type UpdateHostRequest = z.infer<typeof UpdateHostRequest>; export type UpdateHostRequest = z.infer<typeof UpdateHostRequest>;
export const HostListQuery = PaginationQuery.extend({ export const HostListQuery = PaginationQuery.extend({
q: z.string().max(128).optional(), q: z.string().max(128).optional(),
state: HostState.optional(),
stack: HostStack.optional(),
}); });
export type HostListQuery = z.infer<typeof HostListQuery>; export type HostListQuery = z.infer<typeof HostListQuery>;
+3
View File
@@ -9,6 +9,7 @@ export const CreatePartModelRequest = z.object({
eolDate: IsoDate.nullable().optional(), eolDate: IsoDate.nullable().optional(),
destroyOnFail: z.boolean().optional(), destroyOnFail: z.boolean().optional(),
notes: z.string().max(4096).nullable().optional(), notes: z.string().max(4096).nullable().optional(),
categoryId: z.string().uuid().nullable().optional(),
}); });
export type CreatePartModelRequest = z.infer<typeof CreatePartModelRequest>; export type CreatePartModelRequest = z.infer<typeof CreatePartModelRequest>;
@@ -19,12 +20,14 @@ export const UpdatePartModelRequest = z
eolDate: IsoDate.nullable().optional(), eolDate: IsoDate.nullable().optional(),
destroyOnFail: z.boolean().optional(), destroyOnFail: z.boolean().optional(),
notes: z.string().max(4096).nullable().optional(), notes: z.string().max(4096).nullable().optional(),
categoryId: z.string().uuid().nullable().optional(),
}) })
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' }); .refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
export type UpdatePartModelRequest = z.infer<typeof UpdatePartModelRequest>; export type UpdatePartModelRequest = z.infer<typeof UpdatePartModelRequest>;
export const PartModelListQuery = PaginationQuery.extend({ export const PartModelListQuery = PaginationQuery.extend({
manufacturerId: z.string().uuid().optional(), manufacturerId: z.string().uuid().optional(),
categoryId: z.string().uuid().optional(),
q: z.string().max(128).optional(), q: z.string().max(128).optional(),
eolBefore: IsoDate.optional(), eolBefore: IsoDate.optional(),
}); });
+1 -2
View File
@@ -28,6 +28,7 @@ export function allowedLocationFieldsForState(state: PartState): {
return { hostId: 'required', binId: 'forbidden', custodianId: 'forbidden' }; return { hostId: 'required', binId: 'forbidden', custodianId: 'forbidden' };
case 'PENDING_DROP_IN_CUSTODY': case 'PENDING_DROP_IN_CUSTODY':
case 'PENDING_DESTRUCTION_IN_CUSTODY': case 'PENDING_DESTRUCTION_IN_CUSTODY':
case 'PENDING_REPAIR':
return { hostId: 'forbidden', binId: 'forbidden', custodianId: 'required' }; return { hostId: 'forbidden', binId: 'forbidden', custodianId: 'required' };
case 'SPARE': case 'SPARE':
case 'BROKEN': case 'BROKEN':
@@ -49,7 +50,6 @@ export const CreatePartRequest = z
hostId: z.string().uuid().optional().nullable(), hostId: z.string().uuid().optional().nullable(),
custodianId: z.string().uuid().optional().nullable(), custodianId: z.string().uuid().optional().nullable(),
notes: z.string().max(4096).optional().nullable(), notes: z.string().max(4096).optional().nullable(),
categoryId: z.string().uuid().optional().nullable(),
tagIds: z.array(z.string().uuid()).max(32).optional(), tagIds: z.array(z.string().uuid()).max(32).optional(),
}) })
.superRefine((v, ctx) => { .superRefine((v, ctx) => {
@@ -115,7 +115,6 @@ export const UpdatePartRequest = z
hostId: z.string().uuid().nullable().optional(), hostId: z.string().uuid().nullable().optional(),
custodianId: z.string().uuid().nullable().optional(), custodianId: z.string().uuid().nullable().optional(),
notes: z.string().max(4096).nullable().optional(), notes: z.string().max(4096).nullable().optional(),
categoryId: z.string().uuid().nullable().optional(),
tagIds: z.array(z.string().uuid()).max(32).optional(), tagIds: z.array(z.string().uuid()).max(32).optional(),
}) })
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' }) .refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' })
+17 -4
View File
@@ -8,20 +8,33 @@ export const LogRepairRequest = z
hostId: z.string().uuid().optional(), hostId: z.string().uuid().optional(),
assetId: z.string().trim().min(1).max(128).optional(), assetId: z.string().trim().min(1).max(128).optional(),
brokenSerial: z.string().trim().min(1).max(128), brokenSerial: z.string().trim().min(1).max(128),
brokenMpn: z.string().trim().min(1).max(128), // When the broken serial isn't in Vector yet we ingest it. Provide either a known PartModel
brokenManufacturerId: z.string().uuid(), // (brokenPartModelId) or the manufacturer + mpn pair to auto-create it.
brokenPartModelId: z.string().uuid().optional(),
brokenMpn: z.string().trim().min(1).max(128).optional(),
brokenManufacturerId: z.string().uuid().optional(),
replacementSerial: z.string().trim().min(1).max(128), replacementSerial: z.string().trim().min(1).max(128),
fmId: z.string().uuid().optional(), fmId: z.string().uuid().optional(),
}) })
.superRefine((v, ctx) => { .superRefine((v, ctx) => {
const has = [v.hostId, v.assetId].filter((x) => x !== undefined && x !== '').length; const hostHas = [v.hostId, v.assetId].filter((x) => x !== undefined && x !== '').length;
if (has !== 1) { if (hostHas !== 1) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
message: 'Provide exactly one of hostId or assetId', message: 'Provide exactly one of hostId or assetId',
path: ['hostId'], path: ['hostId'],
}); });
} }
const hasModel =
v.brokenPartModelId !== undefined ||
(v.brokenMpn !== undefined && v.brokenManufacturerId !== undefined);
if (!hasModel) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Provide brokenPartModelId or both brokenMpn and brokenManufacturerId',
path: ['brokenPartModelId'],
});
}
}); });
export type LogRepairRequest = z.infer<typeof LogRepairRequest>; export type LogRepairRequest = z.infer<typeof LogRepairRequest>;