feat: laundry-list polish pass
Seven bundled improvements: - PartModel combobox on Add Part + Log Repair (known MPN auto-fills; unknown reveals manufacturer picker for catalog upsert). - Host lifecycle: state (DEPLOYED/DEGRADED/TESTING) and stack (PRODUCTION/VETTING) fields, driven by external clients via the API. - Locations page redesigned as a 2-pane tree + bin grid with breadcrumb. - PENDING_REPAIR custody state: tech takes a SPARE into custody for a future swap; resolves to DEPLOYED via Repair or back to SPARE via a bin-required drop-off. - Move Category from Part to PartModel; seed common categories (GPU/RAM/SSD/HDD/NIC/CPU/PSU/MOBO). Parts table gets a Category column and filter sourced from the model. - Fix Deployed Value 100x bug on the Dashboard (price is stored as dollars, not cents). - PartModels table shows "No" instead of "--" when destroyOnFail=false. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Vector
|
||||
|
||||
Hardware parts inventory system. Tracks serialized parts across sites → rooms → bins, with a full audit trail, repair/RMA workflow, tag-based organization, manufacturer EOL tracking, and signed webhook delivery for external integrations.
|
||||
Hardware parts inventory system. Tracks serialized parts across sites → rooms → bins and hosts (with externally-driven state/stack lifecycle), with a full audit trail, repair/RMA workflow, per-tech custody for broken-part holds and pre-staged spares, tag-based organization, category-per-model taxonomy, manufacturer EOL tracking, and signed webhook delivery for external integrations.
|
||||
|
||||
Vector 2.0 is a ground-up TypeScript rewrite of the original JavaScript codebase, delivered as a pnpm + Turbo monorepo with shadcn/ui on the frontend and a service-layered Express API on the backend.
|
||||
|
||||
|
||||
@@ -31,3 +31,19 @@ export async function dropOff(
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function takeForRepair(
|
||||
req: Request<{ partId: string }>,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
try {
|
||||
if (!req.user) throw errors.unauthorized();
|
||||
const part = await prisma.$transaction((tx) =>
|
||||
svc.takeForRepair(tx, req.params.partId, req.user!),
|
||||
);
|
||||
res.json(part);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,5 +13,6 @@ router.post(
|
||||
validate('body', DropOffRequest),
|
||||
ctrl.dropOff,
|
||||
);
|
||||
router.post('/:partId/take-for-repair', requireAuth, ctrl.takeForRepair);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { Tx, Actor } from './types.js';
|
||||
import { dropOff } from './custody.js';
|
||||
import { dropOff, takeForRepair } from './custody.js';
|
||||
|
||||
const custodian: Actor = { id: 'user-1', username: 'tech', role: 'TECHNICIAN' };
|
||||
const admin: Actor = { id: 'user-admin', username: 'boss', role: 'ADMIN' };
|
||||
@@ -166,4 +166,60 @@ describe('custody.dropOff', () => {
|
||||
dropOff(tx, 'p-missing', { binId: null }, custodian),
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
|
||||
it('PENDING_REPAIR → SPARE when returned with a bin; custodian cleared', async () => {
|
||||
const { tx, current } = buildTx(
|
||||
custodyPart({ state: 'PENDING_REPAIR', binId: null }),
|
||||
);
|
||||
|
||||
await dropOff(tx, 'p-1', { binId: 'bin-7' }, custodian);
|
||||
|
||||
expect(current.state).toBe('SPARE');
|
||||
expect(current.binId).toBe('bin-7');
|
||||
expect(current.custodianId).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects PENDING_REPAIR drop-off without a bin with 400', async () => {
|
||||
const { tx, partUpdate } = buildTx(
|
||||
custodyPart({ state: 'PENDING_REPAIR', binId: null }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
dropOff(tx, 'p-1', { binId: null }, custodian),
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
expect(partUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('custody.takeForRepair', () => {
|
||||
it('SPARE → PENDING_REPAIR with the actor as custodian', async () => {
|
||||
const { tx, current } = buildTx(
|
||||
custodyPart({ state: 'SPARE', custodianId: null, custodian: null, binId: 'bin-1' }),
|
||||
);
|
||||
|
||||
await takeForRepair(tx, 'p-1', custodian);
|
||||
|
||||
expect(current.state).toBe('PENDING_REPAIR');
|
||||
expect(current.custodianId).toBe('user-1');
|
||||
});
|
||||
|
||||
it('rejects take-for-repair on a non-SPARE part with 400', async () => {
|
||||
const { tx, partUpdate } = buildTx(custodyPart({ state: 'DEPLOYED' }));
|
||||
|
||||
await expect(takeForRepair(tx, 'p-1', custodian)).rejects.toMatchObject({
|
||||
status: 400,
|
||||
});
|
||||
expect(partUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects take-for-repair on a missing part with 404', async () => {
|
||||
const tx = {
|
||||
part: { findUnique: async () => null, update: vi.fn() },
|
||||
partEvent: { createMany: vi.fn() },
|
||||
} as unknown as Tx;
|
||||
|
||||
await expect(takeForRepair(tx, 'p-missing', custodian)).rejects.toMatchObject({
|
||||
status: 404,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,16 @@ export async function listMine(tx: Tx, userId: string, q: CustodyListQuery) {
|
||||
return { data, page, pageSize, total };
|
||||
}
|
||||
|
||||
// Map of custody-state → state the part returns to when dropped off.
|
||||
// PENDING_REPAIR is a spare the tech picked up for a future swap; on return it goes back to
|
||||
// SPARE and must land in a bin (no useful "in-limbo" state for spares). The broken-part
|
||||
// paths continue to allow binId=null since techs sometimes hold broken parts without a bin.
|
||||
const DROP_OFF_TARGET: Record<string, { next: 'BROKEN' | 'PENDING_DESTRUCTION' | 'SPARE'; binRequired: boolean }> = {
|
||||
PENDING_DROP_IN_CUSTODY: { next: 'BROKEN', binRequired: false },
|
||||
PENDING_DESTRUCTION_IN_CUSTODY: { next: 'PENDING_DESTRUCTION', binRequired: false },
|
||||
PENDING_REPAIR: { next: 'SPARE', binRequired: true },
|
||||
};
|
||||
|
||||
export async function dropOff(
|
||||
tx: Tx,
|
||||
partId: string,
|
||||
@@ -36,23 +46,35 @@ export async function dropOff(
|
||||
const part = await tx.part.findUnique({ where: { id: partId } });
|
||||
if (!part) throw errors.notFound('Part');
|
||||
|
||||
if (
|
||||
part.state !== 'PENDING_DROP_IN_CUSTODY' &&
|
||||
part.state !== 'PENDING_DESTRUCTION_IN_CUSTODY'
|
||||
) {
|
||||
throw errors.badRequest('Part is not in custody');
|
||||
}
|
||||
const target = DROP_OFF_TARGET[part.state];
|
||||
if (!target) throw errors.badRequest('Part is not in custody');
|
||||
if (part.custodianId !== actor.id && actor.role !== 'ADMIN') {
|
||||
throw errors.forbidden('Only the current custodian can drop off this part');
|
||||
}
|
||||
|
||||
const nextState =
|
||||
part.state === 'PENDING_DROP_IN_CUSTODY' ? 'BROKEN' : 'PENDING_DESTRUCTION';
|
||||
if (target.binRequired && !input.binId) {
|
||||
throw errors.badRequest('A bin is required when returning a spare to inventory');
|
||||
}
|
||||
|
||||
return partsSvc.update(
|
||||
tx,
|
||||
partId,
|
||||
{ state: nextState, binId: input.binId ?? null, custodianId: null },
|
||||
{ state: target.next, binId: input.binId ?? null, custodianId: null },
|
||||
actor,
|
||||
);
|
||||
}
|
||||
|
||||
// A tech takes a SPARE into their custody for a future repair. The part waits in
|
||||
// PENDING_REPAIR until it's used in a Repair (→ DEPLOYED) or dropped back into a bin (→ SPARE).
|
||||
export async function takeForRepair(tx: Tx, partId: string, actor: Actor) {
|
||||
const part = await tx.part.findUnique({ where: { id: partId } });
|
||||
if (!part) throw errors.notFound('Part');
|
||||
if (part.state !== 'SPARE') {
|
||||
throw errors.badRequest('Only SPARE parts can be taken for a repair');
|
||||
}
|
||||
return partsSvc.update(
|
||||
tx,
|
||||
partId,
|
||||
{ state: 'PENDING_REPAIR', custodianId: actor.id },
|
||||
actor,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { Tx } from './types.js';
|
||||
import { create, update } from './hosts.js';
|
||||
|
||||
interface HostRow {
|
||||
id: string;
|
||||
assetId: string;
|
||||
name: string;
|
||||
location: string | null;
|
||||
notes: string | null;
|
||||
state: string;
|
||||
stack: string;
|
||||
}
|
||||
|
||||
function buildTx(seed: HostRow[] = []) {
|
||||
const registry = new Map(seed.map((h) => [h.id, h]));
|
||||
|
||||
const tx = {
|
||||
host: {
|
||||
create: vi.fn(async (args: { data: Record<string, unknown> }) => {
|
||||
const row: HostRow = {
|
||||
id: `host-${registry.size + 1}`,
|
||||
assetId: String(args.data.assetId),
|
||||
name: String(args.data.name),
|
||||
location: (args.data.location as string | null) ?? null,
|
||||
notes: (args.data.notes as string | null) ?? null,
|
||||
state: String(args.data.state ?? 'DEPLOYED'),
|
||||
stack: String(args.data.stack ?? 'PRODUCTION'),
|
||||
};
|
||||
registry.set(row.id, row);
|
||||
return row;
|
||||
}),
|
||||
update: vi.fn(async (args: { where: { id: string }; data: Record<string, unknown> }) => {
|
||||
const current = registry.get(args.where.id);
|
||||
if (!current) throw new Error(`No host ${args.where.id}`);
|
||||
const d = args.data;
|
||||
if (d.assetId !== undefined) current.assetId = String(d.assetId);
|
||||
if (d.name !== undefined) current.name = String(d.name);
|
||||
if (d.location !== undefined) current.location = (d.location as string | null) ?? null;
|
||||
if (d.notes !== undefined) current.notes = (d.notes as string | null) ?? null;
|
||||
if (d.state !== undefined) current.state = String(d.state);
|
||||
if (d.stack !== undefined) current.stack = String(d.stack);
|
||||
return current;
|
||||
}),
|
||||
},
|
||||
} as unknown as Tx;
|
||||
|
||||
return { tx, registry };
|
||||
}
|
||||
|
||||
describe('hosts.create', () => {
|
||||
it('defaults state=DEPLOYED and stack=PRODUCTION when not supplied', async () => {
|
||||
const { tx } = buildTx();
|
||||
|
||||
const host = await create(tx, { assetId: 'A-1', name: 'rack-1' });
|
||||
|
||||
expect(host.state).toBe('DEPLOYED');
|
||||
expect(host.stack).toBe('PRODUCTION');
|
||||
});
|
||||
|
||||
it('persists explicit state and stack', async () => {
|
||||
const { tx } = buildTx();
|
||||
|
||||
const host = await create(tx, {
|
||||
assetId: 'A-2',
|
||||
name: 'rack-2',
|
||||
state: 'TESTING',
|
||||
stack: 'VETTING',
|
||||
});
|
||||
|
||||
expect(host.state).toBe('TESTING');
|
||||
expect(host.stack).toBe('VETTING');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hosts.update', () => {
|
||||
it('updates state and stack when provided', async () => {
|
||||
const { tx, registry } = buildTx([
|
||||
{
|
||||
id: 'host-1',
|
||||
assetId: 'A-1',
|
||||
name: 'rack-1',
|
||||
location: null,
|
||||
notes: null,
|
||||
state: 'DEPLOYED',
|
||||
stack: 'PRODUCTION',
|
||||
},
|
||||
]);
|
||||
|
||||
await update(tx, 'host-1', { state: 'DEGRADED', stack: 'VETTING' });
|
||||
|
||||
const row = registry.get('host-1')!;
|
||||
expect(row.state).toBe('DEGRADED');
|
||||
expect(row.stack).toBe('VETTING');
|
||||
});
|
||||
|
||||
it('leaves state/stack untouched when not provided', async () => {
|
||||
const { tx, registry } = buildTx([
|
||||
{
|
||||
id: 'host-1',
|
||||
assetId: 'A-1',
|
||||
name: 'rack-1',
|
||||
location: null,
|
||||
notes: null,
|
||||
state: 'TESTING',
|
||||
stack: 'VETTING',
|
||||
},
|
||||
]);
|
||||
|
||||
await update(tx, 'host-1', { name: 'rack-1-renamed' });
|
||||
|
||||
const row = registry.get('host-1')!;
|
||||
expect(row.state).toBe('TESTING');
|
||||
expect(row.stack).toBe('VETTING');
|
||||
expect(row.name).toBe('rack-1-renamed');
|
||||
});
|
||||
});
|
||||
@@ -14,15 +14,16 @@ function mapUniqueViolation(target: unknown): string {
|
||||
|
||||
export async function list(tx: Tx, q: HostListQuery) {
|
||||
const { page, pageSize, q: search } = q;
|
||||
const where: Prisma.HostWhereInput = search
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: search } },
|
||||
{ assetId: { contains: search } },
|
||||
{ location: { contains: search } },
|
||||
],
|
||||
}
|
||||
: {};
|
||||
const where: Prisma.HostWhereInput = {};
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ name: { contains: search } },
|
||||
{ assetId: { contains: search } },
|
||||
{ location: { contains: search } },
|
||||
];
|
||||
}
|
||||
if (q.state) where.state = q.state;
|
||||
if (q.stack) where.stack = q.stack;
|
||||
const [data, total] = await Promise.all([
|
||||
tx.host.findMany({
|
||||
where,
|
||||
@@ -55,6 +56,8 @@ export async function create(tx: Tx, input: CreateHostRequest) {
|
||||
name: input.name,
|
||||
location: input.location ?? null,
|
||||
notes: input.notes ?? null,
|
||||
state: input.state ?? 'DEPLOYED',
|
||||
stack: input.stack ?? 'PRODUCTION',
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -71,6 +74,8 @@ export async function update(tx: Tx, id: string, input: UpdateHostRequest) {
|
||||
if (input.name !== undefined) data.name = input.name;
|
||||
if (input.location !== undefined) data.location = input.location;
|
||||
if (input.notes !== undefined) data.notes = input.notes;
|
||||
if (input.state !== undefined) data.state = input.state;
|
||||
if (input.stack !== undefined) data.stack = input.stack;
|
||||
try {
|
||||
return await tx.host.update({ where: { id }, data });
|
||||
} catch (err) {
|
||||
|
||||
@@ -9,13 +9,15 @@ import type { Tx } from './types.js';
|
||||
|
||||
const partModelInclude = {
|
||||
manufacturer: true,
|
||||
category: true,
|
||||
_count: { select: { parts: true } },
|
||||
} satisfies Prisma.PartModelInclude;
|
||||
|
||||
export async function list(tx: Tx, q: PartModelListQuery) {
|
||||
const { page, pageSize, manufacturerId, q: search, eolBefore } = q;
|
||||
const { page, pageSize, manufacturerId, categoryId, q: search, eolBefore } = q;
|
||||
const where: Prisma.PartModelWhereInput = {};
|
||||
if (manufacturerId) where.manufacturerId = manufacturerId;
|
||||
if (categoryId) where.categoryId = categoryId;
|
||||
if (search) where.mpn = { contains: search };
|
||||
if (eolBefore) where.eolDate = { lte: new Date(eolBefore) };
|
||||
|
||||
@@ -42,6 +44,7 @@ export async function create(tx: Tx, input: CreatePartModelRequest) {
|
||||
data: {
|
||||
manufacturerId: input.manufacturerId,
|
||||
mpn: input.mpn,
|
||||
categoryId: input.categoryId ?? null,
|
||||
eolDate: input.eolDate ? new Date(input.eolDate) : null,
|
||||
destroyOnFail: input.destroyOnFail ?? false,
|
||||
notes: input.notes ?? null,
|
||||
@@ -51,7 +54,7 @@ export async function create(tx: Tx, input: CreatePartModelRequest) {
|
||||
} catch (err) {
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (err.code === 'P2002') throw errors.conflict('MPN already exists for this manufacturer');
|
||||
if (err.code === 'P2003') throw errors.badRequest('Manufacturer does not exist');
|
||||
if (err.code === 'P2003') throw errors.badRequest('Manufacturer or category does not exist');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -63,6 +66,11 @@ export async function update(tx: Tx, id: string, input: UpdatePartModelRequest)
|
||||
data.manufacturer = { connect: { id: input.manufacturerId } };
|
||||
}
|
||||
if (input.mpn !== undefined) data.mpn = input.mpn;
|
||||
if (input.categoryId !== undefined) {
|
||||
data.category = input.categoryId
|
||||
? { connect: { id: input.categoryId } }
|
||||
: { disconnect: true };
|
||||
}
|
||||
if (input.eolDate !== undefined) {
|
||||
data.eolDate = input.eolDate ? new Date(input.eolDate) : null;
|
||||
}
|
||||
@@ -74,6 +82,7 @@ export async function update(tx: Tx, id: string, input: UpdatePartModelRequest)
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (err.code === 'P2025') throw errors.notFound('Part model');
|
||||
if (err.code === 'P2002') throw errors.conflict('MPN already exists for this manufacturer');
|
||||
if (err.code === 'P2003') throw errors.badRequest('Manufacturer or category does not exist');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { Actor, Tx } from './types.js';
|
||||
// The matrix is:
|
||||
// DEPLOYED — hostId required, binId forbidden, custodianId forbidden
|
||||
// SPARE / BROKEN / PENDING_DESTRUCTION — binId optional, hostId + custodian forbidden
|
||||
// PENDING_DROP_IN_CUSTODY / PENDING_DESTRUCTION_IN_CUSTODY
|
||||
// PENDING_DROP_IN_CUSTODY / PENDING_DESTRUCTION_IN_CUSTODY / PENDING_REPAIR
|
||||
// — custodianId required, host + bin forbidden
|
||||
// Callers only need to pass what's changing; anything omitted is inherited from `current`.
|
||||
function resolveLocation(
|
||||
@@ -43,7 +43,11 @@ function resolveLocation(
|
||||
return { binId: null, hostId, custodianId: null };
|
||||
}
|
||||
|
||||
if (state === 'PENDING_DROP_IN_CUSTODY' || state === 'PENDING_DESTRUCTION_IN_CUSTODY') {
|
||||
if (
|
||||
state === 'PENDING_DROP_IN_CUSTODY' ||
|
||||
state === 'PENDING_DESTRUCTION_IN_CUSTODY' ||
|
||||
state === 'PENDING_REPAIR'
|
||||
) {
|
||||
const custodianId =
|
||||
input.custodianId !== undefined ? input.custodianId : current.custodianId;
|
||||
if (!custodianId) throw errors.badRequest('A part in custody must name a custodian');
|
||||
@@ -65,9 +69,8 @@ function resolveLocation(
|
||||
|
||||
const partInclude = {
|
||||
manufacturer: true,
|
||||
partModel: true,
|
||||
partModel: { include: { category: true } },
|
||||
bin: { include: { room: { include: { site: true } } } },
|
||||
category: true,
|
||||
host: true,
|
||||
custodian: { select: { id: true, username: true } },
|
||||
tags: { include: { tag: true } },
|
||||
@@ -127,7 +130,6 @@ function buildWhere(q: PartListQuery): Prisma.PartWhereInput {
|
||||
if (q.custodianId) where.custodianId = q.custodianId;
|
||||
if (q.manufacturerId) where.manufacturerId = q.manufacturerId;
|
||||
if (q.partModelId) where.partModelId = q.partModelId;
|
||||
if (q.categoryId) where.categoryId = q.categoryId;
|
||||
if (q.serialNumber) where.serialNumber = { contains: q.serialNumber };
|
||||
if (q.q) {
|
||||
where.OR = [
|
||||
@@ -141,6 +143,7 @@ function buildWhere(q: PartListQuery): Prisma.PartWhereInput {
|
||||
const partModelFilter: Prisma.PartModelWhereInput = {};
|
||||
if (q.mpn) partModelFilter.mpn = { contains: q.mpn };
|
||||
if (q.eolOnly) partModelFilter.eolDate = { lt: new Date() };
|
||||
if (q.categoryId) partModelFilter.categoryId = q.categoryId;
|
||||
if (Object.keys(partModelFilter).length > 0) where.partModel = partModelFilter;
|
||||
|
||||
return where;
|
||||
@@ -196,7 +199,6 @@ export async function create(
|
||||
binId: location.binId,
|
||||
hostId: location.hostId,
|
||||
custodianId: location.custodianId,
|
||||
categoryId: input.categoryId ?? null,
|
||||
notes: input.notes ?? null,
|
||||
},
|
||||
include: partInclude,
|
||||
@@ -276,11 +278,6 @@ export async function update(
|
||||
: { disconnect: true };
|
||||
}
|
||||
|
||||
if (input.categoryId !== undefined) {
|
||||
data.category = input.categoryId
|
||||
? { connect: { id: input.categoryId } }
|
||||
: { disconnect: true };
|
||||
}
|
||||
if (input.notes !== undefined) data.notes = input.notes;
|
||||
|
||||
let part: PartWithRelations;
|
||||
@@ -357,16 +354,6 @@ export async function update(
|
||||
newValue: input.serialNumber,
|
||||
});
|
||||
}
|
||||
if (input.categoryId !== undefined && input.categoryId !== current.categoryId) {
|
||||
events.push({
|
||||
partId: part.id,
|
||||
userId,
|
||||
type: 'FIELD_UPDATED',
|
||||
field: 'category',
|
||||
oldValue: current.category?.name ?? null,
|
||||
newValue: part.category?.name ?? null,
|
||||
});
|
||||
}
|
||||
if (input.price !== undefined && input.price !== current.price) {
|
||||
events.push({
|
||||
partId: part.id,
|
||||
|
||||
@@ -514,6 +514,82 @@ describe('repairs.log — validation failures', () => {
|
||||
expect(events).toEqual(['repair.logged']);
|
||||
});
|
||||
|
||||
it('accepts a PENDING_REPAIR replacement held by the actor', async () => {
|
||||
const broken = partRow({
|
||||
id: 'p-broken',
|
||||
serialNumber: 'SN-BROKEN',
|
||||
partModelId: brokenModel.id,
|
||||
state: 'DEPLOYED',
|
||||
hostId: 'host-1',
|
||||
host: host1,
|
||||
partModel: brokenModel,
|
||||
});
|
||||
const replacement = partRow({
|
||||
id: 'p-replacement',
|
||||
serialNumber: 'SN-REPLACE',
|
||||
partModelId: replacementModel.id,
|
||||
state: 'PENDING_REPAIR',
|
||||
custodianId: actor.id,
|
||||
custodian: { id: actor.id, username: actor.username },
|
||||
partModel: replacementModel,
|
||||
});
|
||||
const { tx, registry } = buildTx({ parts: [broken, replacement], hosts: [host1] });
|
||||
|
||||
const r = await log(
|
||||
tx,
|
||||
{
|
||||
hostId: 'host-1',
|
||||
brokenSerial: 'SN-BROKEN',
|
||||
brokenMpn: 'WD-BROKEN',
|
||||
brokenManufacturerId: 'mfr-1',
|
||||
replacementSerial: 'SN-REPLACE',
|
||||
},
|
||||
actor,
|
||||
);
|
||||
|
||||
expect(r.id).toBe('repair-1');
|
||||
const updatedReplacement = registry.get('p-replacement')!;
|
||||
expect(updatedReplacement.state).toBe('DEPLOYED');
|
||||
expect(updatedReplacement.hostId).toBe('host-1');
|
||||
expect(updatedReplacement.custodianId).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a PENDING_REPAIR replacement held by someone else with 400', async () => {
|
||||
const broken = partRow({
|
||||
id: 'p-broken',
|
||||
serialNumber: 'SN-BROKEN',
|
||||
partModelId: brokenModel.id,
|
||||
state: 'DEPLOYED',
|
||||
hostId: 'host-1',
|
||||
host: host1,
|
||||
partModel: brokenModel,
|
||||
});
|
||||
const replacement = partRow({
|
||||
id: 'p-replacement',
|
||||
serialNumber: 'SN-REPLACE',
|
||||
partModelId: replacementModel.id,
|
||||
state: 'PENDING_REPAIR',
|
||||
custodianId: 'user-other',
|
||||
custodian: { id: 'user-other', username: 'someone' },
|
||||
partModel: replacementModel,
|
||||
});
|
||||
const { tx } = buildTx({ parts: [broken, replacement], hosts: [host1] });
|
||||
|
||||
await expect(
|
||||
log(
|
||||
tx,
|
||||
{
|
||||
hostId: 'host-1',
|
||||
brokenSerial: 'SN-BROKEN',
|
||||
brokenMpn: 'WD-BROKEN',
|
||||
brokenManufacturerId: 'mfr-1',
|
||||
replacementSerial: 'SN-REPLACE',
|
||||
},
|
||||
actor,
|
||||
),
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
});
|
||||
|
||||
it('rejects when broken part is on a different host than the repair', async () => {
|
||||
const broken = partRow({
|
||||
id: 'p-broken',
|
||||
|
||||
@@ -78,7 +78,7 @@ export async function log(
|
||||
): Promise<RepairWithRelations> {
|
||||
const host = await resolveHost(tx, input);
|
||||
|
||||
// 1. Resolve replacement — must exist, must be SPARE.
|
||||
// 1. Resolve replacement — must exist; must be SPARE, or a PENDING_REPAIR held by the actor.
|
||||
const replacement = await tx.part.findUnique({
|
||||
where: { serialNumber: input.replacementSerial },
|
||||
include: { partModel: true },
|
||||
@@ -86,9 +86,11 @@ export async function log(
|
||||
if (!replacement) {
|
||||
throw errors.badRequest(`Replacement part ${input.replacementSerial} not found`);
|
||||
}
|
||||
if (replacement.state !== 'SPARE') {
|
||||
const heldForRepairByActor =
|
||||
replacement.state === 'PENDING_REPAIR' && replacement.custodianId === actor.id;
|
||||
if (replacement.state !== 'SPARE' && !heldForRepairByActor) {
|
||||
throw errors.badRequest(
|
||||
`Replacement part ${input.replacementSerial} is ${replacement.state}, must be SPARE`,
|
||||
`Replacement part ${input.replacementSerial} is ${replacement.state}, must be SPARE or PENDING_REPAIR held by you`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -104,10 +106,22 @@ export async function log(
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const pm = await partModelsSvc.upsertByMpn(tx, {
|
||||
manufacturerId: input.brokenManufacturerId,
|
||||
mpn: input.brokenMpn,
|
||||
});
|
||||
let pm: { id: string; manufacturerId: string };
|
||||
if (input.brokenPartModelId) {
|
||||
const existing = await tx.partModel.findUnique({ where: { id: input.brokenPartModelId } });
|
||||
if (!existing) throw errors.badRequest('Broken part model does not exist');
|
||||
pm = { id: existing.id, manufacturerId: existing.manufacturerId };
|
||||
} else {
|
||||
if (!input.brokenMpn || !input.brokenManufacturerId) {
|
||||
throw errors.badRequest(
|
||||
'Provide brokenPartModelId or both brokenMpn and brokenManufacturerId',
|
||||
);
|
||||
}
|
||||
pm = await partModelsSvc.upsertByMpn(tx, {
|
||||
manufacturerId: input.brokenManufacturerId,
|
||||
mpn: input.brokenMpn,
|
||||
});
|
||||
}
|
||||
const created = await tx.part.create({
|
||||
data: {
|
||||
serialNumber: input.brokenSerial,
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Check, ChevronsUpDown, Plus, X } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
cn,
|
||||
} from '@vector/ui';
|
||||
import { listPartModels } from '../../lib/api/part-models.js';
|
||||
import { queryKeys } from '../../lib/queryKeys.js';
|
||||
import type { PartModel } from '../../lib/api/types.js';
|
||||
|
||||
// Async combobox over the PartModel catalog. Two outputs:
|
||||
// - onPick(model): user chose an existing PartModel — the form should hide the manufacturer
|
||||
// field and send { partModelId } at submit time.
|
||||
// - onCreateNew(mpn): user typed an MPN not in the catalog and picked the "Create new" row —
|
||||
// the form should reveal the manufacturer picker and send { mpn, manufacturerId } at submit
|
||||
// time so partModels.upsertByMpn provisions the row.
|
||||
interface PartModelComboboxProps {
|
||||
value: PartModel | null;
|
||||
newMpn: string | null;
|
||||
onPick: (model: PartModel) => void;
|
||||
onCreateNew: (mpn: string) => void;
|
||||
onClear: () => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function PartModelCombobox({
|
||||
value,
|
||||
newMpn,
|
||||
onPick,
|
||||
onCreateNew,
|
||||
onClear,
|
||||
disabled,
|
||||
placeholder = 'Search MPN…',
|
||||
}: PartModelComboboxProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [debounced, setDebounced] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebounced(search.trim()), 200);
|
||||
return () => clearTimeout(t);
|
||||
}, [search]);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.partModels.list({ q: debounced, pageSize: 20 }),
|
||||
queryFn: () => listPartModels({ q: debounced || undefined, pageSize: 20 }),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const results = useMemo(() => query.data?.data ?? [], [query.data]);
|
||||
|
||||
const typed = search.trim();
|
||||
const hasExactMatch = results.some(
|
||||
(m) => m.mpn.toLowerCase() === typed.toLowerCase(),
|
||||
);
|
||||
const canCreate = typed.length > 0 && !hasExactMatch;
|
||||
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const label = value
|
||||
? `${value.manufacturer?.name ?? ''} — ${value.mpn}`
|
||||
: newMpn
|
||||
? `New model: ${newMpn}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Popover open={open} onOpenChange={(o) => !disabled && setOpen(o)}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex-1 justify-between font-normal',
|
||||
!value && !newMpn && 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{label || placeholder}</span>
|
||||
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
align="start"
|
||||
style={{ width: triggerRef.current?.offsetWidth }}
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder="Type MPN…"
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{query.isLoading ? 'Searching…' : 'No models found.'}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{results.map((m) => (
|
||||
<CommandItem
|
||||
key={m.id}
|
||||
value={m.id}
|
||||
onSelect={() => {
|
||||
onPick(m);
|
||||
setSearch('');
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="flex-1 truncate">
|
||||
<span className="text-muted-foreground">
|
||||
{m.manufacturer?.name ?? '—'} —{' '}
|
||||
</span>
|
||||
<span className="font-mono">{m.mpn}</span>
|
||||
</span>
|
||||
<Check
|
||||
className={cn(
|
||||
'ml-auto h-4 w-4 opacity-0',
|
||||
value?.id === m.id && 'opacity-100',
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
{canCreate && (
|
||||
<CommandItem
|
||||
value={`__create__${typed}`}
|
||||
onSelect={() => {
|
||||
onCreateNew(typed);
|
||||
setSearch('');
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Create new model: <span className="font-mono">{typed}</span>
|
||||
</CommandItem>
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{(value || newMpn) && !disabled && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 shrink-0"
|
||||
onClick={onClear}
|
||||
aria-label="Clear selection"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -43,17 +43,23 @@ export function DropOffDialog({ part, onOpenChange, onConfirm, pending }: DropOf
|
||||
});
|
||||
|
||||
const destruction = part?.state === 'PENDING_DESTRUCTION_IN_CUSTODY';
|
||||
// Spares returned from custody must land in a bin — we don't have a useful "in limbo"
|
||||
// SPARE state. Destruction / broken drop-offs still allow an unassigned bin.
|
||||
const returningSpare = part?.state === 'PENDING_REPAIR';
|
||||
const title = returningSpare ? 'Return spare to bin' : 'Drop in bin';
|
||||
const description = returningSpare
|
||||
? `Return ${part?.serialNumber ?? ''} to inventory. Choose a bin — required when returning a spare.`
|
||||
: destruction
|
||||
? 'This part is flagged for destruction. It will move to pending-destruction — optionally place it in a destruction bin.'
|
||||
: `Dropping ${part?.serialNumber ?? ''} into a bin marks it broken.`;
|
||||
const confirmDisabled = pending || (returningSpare && !binId);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Drop in bin</DialogTitle>
|
||||
<DialogDescription>
|
||||
{destruction
|
||||
? 'This part is flagged for destruction. It will move to pending-destruction — optionally place it in a destruction bin.'
|
||||
: `Dropping ${part?.serialNumber ?? ''} into a bin marks it broken.`}
|
||||
</DialogDescription>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
@@ -62,10 +68,10 @@ export function DropOffDialog({ part, onOpenChange, onConfirm, pending }: DropOf
|
||||
onValueChange={(v) => setBinId(v === UNASSIGNED ? '' : v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Unassigned" />
|
||||
<SelectValue placeholder={returningSpare ? 'Select a bin' : 'Unassigned'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={UNASSIGNED}>Unassigned</SelectItem>
|
||||
{!returningSpare && <SelectItem value={UNASSIGNED}>Unassigned</SelectItem>}
|
||||
{bins.data?.data.map((b) => (
|
||||
<SelectItem key={b.id} value={b.id}>
|
||||
{b.fullPath ?? b.name}
|
||||
@@ -84,9 +90,9 @@ export function DropOffDialog({ part, onOpenChange, onConfirm, pending }: DropOf
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => onConfirm(binId || null)} disabled={pending}>
|
||||
<Button onClick={() => onConfirm(binId || null)} disabled={confirmDisabled}>
|
||||
{pending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Drop off
|
||||
{returningSpare ? 'Return' : 'Drop off'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { z } from 'zod';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { HostStack, HostState } from '@vector/shared';
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
@@ -20,6 +21,11 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Textarea,
|
||||
} from '@vector/ui';
|
||||
import { createHost, updateHost } from '../../lib/api/hosts.js';
|
||||
@@ -32,9 +38,22 @@ const Schema = z.object({
|
||||
name: z.string().min(1, 'Required').max(128),
|
||||
location: z.string().max(256).optional(),
|
||||
notes: z.string().max(4096).optional(),
|
||||
state: HostState,
|
||||
stack: HostStack,
|
||||
});
|
||||
type Values = z.infer<typeof Schema>;
|
||||
|
||||
const STATE_LABELS: Record<z.infer<typeof HostState>, string> = {
|
||||
DEPLOYED: 'Deployed',
|
||||
DEGRADED: 'Degraded',
|
||||
TESTING: 'Testing',
|
||||
};
|
||||
|
||||
const STACK_LABELS: Record<z.infer<typeof HostStack>, string> = {
|
||||
PRODUCTION: 'Production',
|
||||
VETTING: 'Vetting',
|
||||
};
|
||||
|
||||
interface HostFormDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -47,7 +66,14 @@ export function HostFormDialog({ open, onOpenChange, host }: HostFormDialogProps
|
||||
|
||||
const form = useForm<Values>({
|
||||
resolver: zodResolver(Schema),
|
||||
defaultValues: { assetId: '', name: '', location: '', notes: '' },
|
||||
defaultValues: {
|
||||
assetId: '',
|
||||
name: '',
|
||||
location: '',
|
||||
notes: '',
|
||||
state: 'DEPLOYED',
|
||||
stack: 'PRODUCTION',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -57,6 +83,8 @@ export function HostFormDialog({ open, onOpenChange, host }: HostFormDialogProps
|
||||
name: host?.name ?? '',
|
||||
location: host?.location ?? '',
|
||||
notes: host?.notes ?? '',
|
||||
state: host?.state ?? 'DEPLOYED',
|
||||
stack: host?.stack ?? 'PRODUCTION',
|
||||
});
|
||||
}, [open, host, form]);
|
||||
|
||||
@@ -68,6 +96,8 @@ export function HostFormDialog({ open, onOpenChange, host }: HostFormDialogProps
|
||||
name: values.name,
|
||||
location: values.location ? values.location : null,
|
||||
notes: values.notes ? values.notes : null,
|
||||
state: values.state,
|
||||
stack: values.stack,
|
||||
});
|
||||
}
|
||||
return createHost({
|
||||
@@ -75,6 +105,8 @@ export function HostFormDialog({ open, onOpenChange, host }: HostFormDialogProps
|
||||
name: values.name,
|
||||
location: values.location ? values.location : null,
|
||||
notes: values.notes ? values.notes : null,
|
||||
state: values.state,
|
||||
stack: values.stack,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
@@ -137,6 +169,56 @@ export function HostFormDialog({ open, onOpenChange, host }: HostFormDialogProps
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="state"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>State</FormLabel>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{HostState.options.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{STATE_LABELS[s]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="stack"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Stack</FormLabel>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{HostStack.options.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{STACK_LABELS[s]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notes"
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { DoorOpen, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Skeleton,
|
||||
cn,
|
||||
} from '@vector/ui';
|
||||
import { createRoom, deleteRoom, listRooms, updateRoom } from '../../lib/api/rooms.js';
|
||||
import { ApiRequestError } from '../../lib/api/client.js';
|
||||
import { queryKeys } from '../../lib/queryKeys.js';
|
||||
import { NamePromptDialog } from '../NamePromptDialog.js';
|
||||
import { ConfirmDialog } from '../ConfirmDialog.js';
|
||||
import type { Room } from '../../lib/api/types.js';
|
||||
|
||||
interface RoomDrawerProps {
|
||||
siteId: string | null;
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
export function RoomDrawer({ siteId, selectedId, onSelect, canEdit }: RoomDrawerProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [renaming, setRenaming] = useState<Room | null>(null);
|
||||
const [deleting, setDeleting] = useState<Room | null>(null);
|
||||
|
||||
const rooms = useQuery({
|
||||
queryKey: queryKeys.rooms.list({ siteId, pageSize: 100 }),
|
||||
queryFn: () => listRooms({ siteId: siteId!, pageSize: 100 }),
|
||||
enabled: Boolean(siteId),
|
||||
});
|
||||
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.rooms.all });
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (name: string) => createRoom({ name, siteId: siteId! }),
|
||||
onSuccess: (r) => {
|
||||
toast.success('Room created');
|
||||
invalidate();
|
||||
setCreating(false);
|
||||
onSelect(r.id);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Create failed'),
|
||||
});
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: (vars: { id: string; name: string }) => updateRoom(vars.id, { name: vars.name }),
|
||||
onSuccess: () => {
|
||||
toast.success('Room renamed');
|
||||
invalidate();
|
||||
setRenaming(null);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Rename failed'),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteRoom(id),
|
||||
onSuccess: (_, id) => {
|
||||
toast.success('Room deleted');
|
||||
invalidate();
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.bins.all });
|
||||
setDeleting(null);
|
||||
if (selectedId === id) onSelect('');
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed'),
|
||||
});
|
||||
|
||||
if (!siteId) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6 text-sm text-muted-foreground">
|
||||
Select a site to see its rooms.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Rooms
|
||||
</h2>
|
||||
{canEdit && (
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setCreating(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-2 pb-2">
|
||||
{rooms.isPending ? (
|
||||
<div className="space-y-2 px-1">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : rooms.isError ? (
|
||||
<p className="px-3 text-xs text-destructive">Failed to load rooms.</p>
|
||||
) : rooms.data && rooms.data.data.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-1 py-6 text-muted-foreground">
|
||||
<DoorOpen className="h-5 w-5" />
|
||||
<span className="text-xs">No rooms in this site</span>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{rooms.data!.data.map((r) => {
|
||||
const active = r.id === selectedId;
|
||||
return (
|
||||
<li key={r.id}>
|
||||
<div
|
||||
className={cn(
|
||||
'group flex items-center justify-between rounded-md px-2 py-1.5 text-sm',
|
||||
active
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-foreground hover:bg-accent/60',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(r.id)}
|
||||
className="flex flex-1 items-center gap-2 text-left"
|
||||
>
|
||||
<DoorOpen className="h-4 w-4 opacity-70" />
|
||||
<span className="truncate">{r.name}</span>
|
||||
</button>
|
||||
{canEdit && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100"
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-36">
|
||||
<DropdownMenuItem onSelect={() => setRenaming(r)}>
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setDeleting(r)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<NamePromptDialog
|
||||
open={creating}
|
||||
onOpenChange={setCreating}
|
||||
title="New room"
|
||||
label="Room name"
|
||||
confirmLabel="Create"
|
||||
pending={createMutation.isPending}
|
||||
onSubmit={(name) => createMutation.mutate(name)}
|
||||
/>
|
||||
<NamePromptDialog
|
||||
open={Boolean(renaming)}
|
||||
onOpenChange={(o) => !o && setRenaming(null)}
|
||||
title="Rename room"
|
||||
label="Room name"
|
||||
confirmLabel="Rename"
|
||||
initialValue={renaming?.name ?? ''}
|
||||
pending={renameMutation.isPending}
|
||||
onSubmit={(name) => renaming && renameMutation.mutate({ id: renaming.id, name })}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleting)}
|
||||
onOpenChange={(o) => !o && setDeleting(null)}
|
||||
title="Delete room?"
|
||||
description={
|
||||
deleting
|
||||
? `Remove ${deleting.name}. All bins inside will be deleted too. Parts in those bins become unassigned.`
|
||||
: undefined
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
destructive
|
||||
pending={deleteMutation.isPending}
|
||||
onConfirm={() => deleting && deleteMutation.mutate(deleting.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Building2, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Skeleton,
|
||||
cn,
|
||||
} from '@vector/ui';
|
||||
import { createSite, deleteSite, listSites, updateSite } from '../../lib/api/sites.js';
|
||||
import { ApiRequestError } from '../../lib/api/client.js';
|
||||
import { queryKeys } from '../../lib/queryKeys.js';
|
||||
import { NamePromptDialog } from '../NamePromptDialog.js';
|
||||
import { ConfirmDialog } from '../ConfirmDialog.js';
|
||||
import type { Site } from '../../lib/api/types.js';
|
||||
|
||||
interface SiteListProps {
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
export function SiteList({ selectedId, onSelect, canEdit }: SiteListProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [renaming, setRenaming] = useState<Site | null>(null);
|
||||
const [deleting, setDeleting] = useState<Site | null>(null);
|
||||
|
||||
const sites = useQuery({
|
||||
queryKey: queryKeys.sites.list({ pageSize: 100 }),
|
||||
queryFn: () => listSites({ pageSize: 100 }),
|
||||
});
|
||||
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sites.all });
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (name: string) => createSite({ name }),
|
||||
onSuccess: (s) => {
|
||||
toast.success('Site created');
|
||||
invalidate();
|
||||
setCreating(false);
|
||||
onSelect(s.id);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Create failed'),
|
||||
});
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: (vars: { id: string; name: string }) => updateSite(vars.id, { name: vars.name }),
|
||||
onSuccess: () => {
|
||||
toast.success('Site renamed');
|
||||
invalidate();
|
||||
setRenaming(null);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Rename failed'),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteSite(id),
|
||||
onSuccess: (_, id) => {
|
||||
toast.success('Site deleted');
|
||||
invalidate();
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.rooms.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.bins.all });
|
||||
setDeleting(null);
|
||||
if (selectedId === id) onSelect('');
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed'),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Sites
|
||||
</h2>
|
||||
{canEdit && (
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setCreating(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-2 pb-2">
|
||||
{sites.isPending ? (
|
||||
<div className="space-y-2 px-1">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : sites.isError ? (
|
||||
<p className="px-3 text-xs text-destructive">Failed to load sites.</p>
|
||||
) : sites.data && sites.data.data.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-1 py-6 text-muted-foreground">
|
||||
<Building2 className="h-5 w-5" />
|
||||
<span className="text-xs">No sites yet</span>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{sites.data!.data.map((s) => {
|
||||
const active = s.id === selectedId;
|
||||
return (
|
||||
<li key={s.id}>
|
||||
<div
|
||||
className={cn(
|
||||
'group flex items-center justify-between rounded-md px-2 py-1.5 text-sm',
|
||||
active
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-foreground hover:bg-accent/60',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(s.id)}
|
||||
className="flex flex-1 items-center gap-2 text-left"
|
||||
>
|
||||
<Building2 className="h-4 w-4 opacity-70" />
|
||||
<span className="truncate">{s.name}</span>
|
||||
</button>
|
||||
{canEdit && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100"
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-36">
|
||||
<DropdownMenuItem onSelect={() => setRenaming(s)}>
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setDeleting(s)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<NamePromptDialog
|
||||
open={creating}
|
||||
onOpenChange={setCreating}
|
||||
title="New site"
|
||||
label="Site name"
|
||||
confirmLabel="Create"
|
||||
pending={createMutation.isPending}
|
||||
onSubmit={(name) => createMutation.mutate(name)}
|
||||
/>
|
||||
<NamePromptDialog
|
||||
open={Boolean(renaming)}
|
||||
onOpenChange={(o) => !o && setRenaming(null)}
|
||||
title="Rename site"
|
||||
label="Site name"
|
||||
confirmLabel="Rename"
|
||||
initialValue={renaming?.name ?? ''}
|
||||
pending={renameMutation.isPending}
|
||||
onSubmit={(name) => renaming && renameMutation.mutate({ id: renaming.id, name })}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleting)}
|
||||
onOpenChange={(o) => !o && setDeleting(null)}
|
||||
title="Delete site?"
|
||||
description={
|
||||
deleting
|
||||
? `Remove ${deleting.name}. All rooms and bins inside will be deleted too. Parts in those bins become unassigned.`
|
||||
: undefined
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
destructive
|
||||
pending={deleteMutation.isPending}
|
||||
onConfirm={() => deleting && deleteMutation.mutate(deleting.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Building2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
DoorOpen,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Skeleton,
|
||||
cn,
|
||||
} from '@vector/ui';
|
||||
import { createSite, deleteSite, listSites, updateSite } from '../../lib/api/sites.js';
|
||||
import { createRoom, deleteRoom, listRooms, updateRoom } from '../../lib/api/rooms.js';
|
||||
import { ApiRequestError } from '../../lib/api/client.js';
|
||||
import { queryKeys } from '../../lib/queryKeys.js';
|
||||
import { NamePromptDialog } from '../NamePromptDialog.js';
|
||||
import { ConfirmDialog } from '../ConfirmDialog.js';
|
||||
import type { Room, Site } from '../../lib/api/types.js';
|
||||
|
||||
// A single tree view combining the former SiteList and RoomDrawer. Sites expand to show their
|
||||
// rooms inline; the whole thing shares the same URL state (?site=&room=) so deep links still
|
||||
// resolve. Each row keeps its inline rename/delete action; creation happens per level.
|
||||
interface SiteRoomTreeProps {
|
||||
siteId: string | null;
|
||||
roomId: string | null;
|
||||
onSelectSite: (id: string) => void;
|
||||
onSelectRoom: (id: string) => void;
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
type RenameTarget = { kind: 'site'; value: Site } | { kind: 'room'; value: Room };
|
||||
type DeleteTarget = { kind: 'site'; value: Site } | { kind: 'room'; value: Room };
|
||||
|
||||
export function SiteRoomTree({
|
||||
siteId,
|
||||
roomId,
|
||||
onSelectSite,
|
||||
onSelectRoom,
|
||||
canEdit,
|
||||
}: SiteRoomTreeProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [creatingSite, setCreatingSite] = useState(false);
|
||||
const [creatingRoomInSite, setCreatingRoomInSite] = useState<string | null>(null);
|
||||
const [renaming, setRenaming] = useState<RenameTarget | null>(null);
|
||||
const [deleting, setDeleting] = useState<DeleteTarget | null>(null);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
|
||||
const sites = useQuery({
|
||||
queryKey: queryKeys.sites.list({ pageSize: 100 }),
|
||||
queryFn: () => listSites({ pageSize: 100 }),
|
||||
});
|
||||
|
||||
// Ensure the selected site is expanded on load / deep link.
|
||||
useEffect(() => {
|
||||
if (siteId) setExpanded((prev) => (prev.has(siteId) ? prev : new Set(prev).add(siteId)));
|
||||
}, [siteId]);
|
||||
|
||||
const siteIds = useMemo(() => {
|
||||
const list = sites.data?.data ?? [];
|
||||
return list.filter((s) => expanded.has(s.id)).map((s) => s.id);
|
||||
}, [sites.data, expanded]);
|
||||
|
||||
const roomQueries = useQueries({
|
||||
queries: siteIds.map((id) => ({
|
||||
queryKey: queryKeys.rooms.list({ siteId: id, pageSize: 100 }),
|
||||
queryFn: () => listRooms({ siteId: id, pageSize: 100 }),
|
||||
})),
|
||||
});
|
||||
const roomsBySite = useMemo(() => {
|
||||
const m = new Map<string, Room[]>();
|
||||
siteIds.forEach((id, i) => {
|
||||
m.set(id, roomQueries[i]?.data?.data ?? []);
|
||||
});
|
||||
return m;
|
||||
}, [siteIds, roomQueries]);
|
||||
|
||||
const invalidateSites = () =>
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sites.all });
|
||||
const invalidateRooms = () =>
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.rooms.all });
|
||||
|
||||
const createSiteMutation = useMutation({
|
||||
mutationFn: (name: string) => createSite({ name }),
|
||||
onSuccess: (s) => {
|
||||
toast.success('Site created');
|
||||
invalidateSites();
|
||||
setCreatingSite(false);
|
||||
setExpanded((prev) => new Set(prev).add(s.id));
|
||||
onSelectSite(s.id);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Create failed'),
|
||||
});
|
||||
|
||||
const createRoomMutation = useMutation({
|
||||
mutationFn: (vars: { siteId: string; name: string }) =>
|
||||
createRoom({ name: vars.name, siteId: vars.siteId }),
|
||||
onSuccess: (r) => {
|
||||
toast.success('Room created');
|
||||
invalidateRooms();
|
||||
setCreatingRoomInSite(null);
|
||||
onSelectRoom(r.id);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Create failed'),
|
||||
});
|
||||
|
||||
const renameSiteMutation = useMutation({
|
||||
mutationFn: (vars: { id: string; name: string }) => updateSite(vars.id, { name: vars.name }),
|
||||
onSuccess: () => {
|
||||
toast.success('Site renamed');
|
||||
invalidateSites();
|
||||
setRenaming(null);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Rename failed'),
|
||||
});
|
||||
|
||||
const renameRoomMutation = useMutation({
|
||||
mutationFn: (vars: { id: string; name: string }) => updateRoom(vars.id, { name: vars.name }),
|
||||
onSuccess: () => {
|
||||
toast.success('Room renamed');
|
||||
invalidateRooms();
|
||||
setRenaming(null);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Rename failed'),
|
||||
});
|
||||
|
||||
const deleteSiteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteSite(id),
|
||||
onSuccess: (_, id) => {
|
||||
toast.success('Site deleted');
|
||||
invalidateSites();
|
||||
invalidateRooms();
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.bins.all });
|
||||
setDeleting(null);
|
||||
if (siteId === id) onSelectSite('');
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed'),
|
||||
});
|
||||
|
||||
const deleteRoomMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteRoom(id),
|
||||
onSuccess: (_, id) => {
|
||||
toast.success('Room deleted');
|
||||
invalidateRooms();
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.bins.all });
|
||||
setDeleting(null);
|
||||
if (roomId === id) onSelectRoom('');
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed'),
|
||||
});
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const hasSites = Boolean(sites.data && sites.data.data.length > 0);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Sites
|
||||
</h2>
|
||||
{canEdit && hasSites && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => setCreatingSite(true)}
|
||||
aria-label="Add site"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-2 pb-2">
|
||||
{sites.isPending ? (
|
||||
<div className="space-y-2 px-1">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : sites.isError ? (
|
||||
<p className="px-3 text-xs text-destructive">Failed to load sites.</p>
|
||||
) : !hasSites ? (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-muted-foreground">
|
||||
<Building2 className="h-5 w-5" />
|
||||
<span className="text-xs">No sites yet</span>
|
||||
{canEdit && (
|
||||
<Button size="sm" variant="outline" onClick={() => setCreatingSite(true)}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add site
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{sites.data!.data.map((s) => {
|
||||
const isOpen = expanded.has(s.id);
|
||||
const siteActive = s.id === siteId;
|
||||
const rooms = roomsBySite.get(s.id) ?? [];
|
||||
return (
|
||||
<li key={s.id}>
|
||||
<div
|
||||
className={cn(
|
||||
'group flex items-center rounded-md text-sm',
|
||||
siteActive
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-foreground hover:bg-accent/60',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(s.id)}
|
||||
className="flex h-8 w-7 items-center justify-center text-muted-foreground"
|
||||
aria-label={isOpen ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
{isOpen ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelectSite(s.id);
|
||||
setExpanded((prev) => new Set(prev).add(s.id));
|
||||
}}
|
||||
className="flex flex-1 items-center gap-2 py-1.5 text-left"
|
||||
>
|
||||
<Building2 className="h-4 w-4 opacity-70" />
|
||||
<span className="truncate">{s.name}</span>
|
||||
</button>
|
||||
{canEdit && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mr-1 h-6 w-6 opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100"
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setCreatingRoomInSite(s.id)}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add room
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setRenaming({ kind: 'site', value: s })}
|
||||
>
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setDeleting({ kind: 'site', value: s })}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<ul className="ml-6 mt-0.5 space-y-0.5 border-l border-border pl-1">
|
||||
{rooms.length === 0 ? (
|
||||
<li className="px-2 py-1 text-xs text-muted-foreground">
|
||||
No rooms yet
|
||||
{canEdit && (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="h-auto px-1.5 py-0 text-xs"
|
||||
onClick={() => setCreatingRoomInSite(s.id)}
|
||||
>
|
||||
+ add
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
) : (
|
||||
rooms.map((r) => {
|
||||
const roomActive = r.id === roomId;
|
||||
return (
|
||||
<li key={r.id}>
|
||||
<div
|
||||
className={cn(
|
||||
'group flex items-center rounded-md text-sm',
|
||||
roomActive
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-foreground hover:bg-accent/60',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectRoom(r.id)}
|
||||
className="flex flex-1 items-center gap-2 py-1.5 pl-2 text-left"
|
||||
>
|
||||
<DoorOpen className="h-3.5 w-3.5 opacity-70" />
|
||||
<span className="truncate">{r.name}</span>
|
||||
</button>
|
||||
{canEdit && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mr-1 h-6 w-6 opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100"
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-36">
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
setRenaming({ kind: 'room', value: r })
|
||||
}
|
||||
>
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
setDeleting({ kind: 'room', value: r })
|
||||
}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<NamePromptDialog
|
||||
open={creatingSite}
|
||||
onOpenChange={setCreatingSite}
|
||||
title="New site"
|
||||
label="Site name"
|
||||
confirmLabel="Create"
|
||||
pending={createSiteMutation.isPending}
|
||||
onSubmit={(name) => createSiteMutation.mutate(name)}
|
||||
/>
|
||||
<NamePromptDialog
|
||||
open={Boolean(creatingRoomInSite)}
|
||||
onOpenChange={(o) => !o && setCreatingRoomInSite(null)}
|
||||
title="New room"
|
||||
label="Room name"
|
||||
confirmLabel="Create"
|
||||
pending={createRoomMutation.isPending}
|
||||
onSubmit={(name) =>
|
||||
creatingRoomInSite &&
|
||||
createRoomMutation.mutate({ siteId: creatingRoomInSite, name })
|
||||
}
|
||||
/>
|
||||
<NamePromptDialog
|
||||
open={Boolean(renaming)}
|
||||
onOpenChange={(o) => !o && setRenaming(null)}
|
||||
title={renaming?.kind === 'site' ? 'Rename site' : 'Rename room'}
|
||||
label={renaming?.kind === 'site' ? 'Site name' : 'Room name'}
|
||||
confirmLabel="Rename"
|
||||
initialValue={renaming?.value.name ?? ''}
|
||||
pending={renameSiteMutation.isPending || renameRoomMutation.isPending}
|
||||
onSubmit={(name) => {
|
||||
if (!renaming) return;
|
||||
if (renaming.kind === 'site') {
|
||||
renameSiteMutation.mutate({ id: renaming.value.id, name });
|
||||
} else {
|
||||
renameRoomMutation.mutate({ id: renaming.value.id, name });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleting)}
|
||||
onOpenChange={(o) => !o && setDeleting(null)}
|
||||
title={deleting?.kind === 'site' ? 'Delete site?' : 'Delete room?'}
|
||||
description={
|
||||
deleting
|
||||
? deleting.kind === 'site'
|
||||
? `Remove ${deleting.value.name}. All rooms and bins inside will be deleted too. Parts in those bins become unassigned.`
|
||||
: `Remove ${deleting.value.name}. All bins inside will be deleted too. Parts in those bins become unassigned.`
|
||||
: undefined
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
destructive
|
||||
pending={deleteSiteMutation.isPending || deleteRoomMutation.isPending}
|
||||
onConfirm={() => {
|
||||
if (!deleting) return;
|
||||
if (deleting.kind === 'site') deleteSiteMutation.mutate(deleting.value.id);
|
||||
else deleteRoomMutation.mutate(deleting.value.id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from '@vector/ui';
|
||||
import { createPartModel, updatePartModel } from '../../lib/api/part-models.js';
|
||||
import { listManufacturers } from '../../lib/api/manufacturers.js';
|
||||
import { createCategory, listCategories } from '../../lib/api/categories.js';
|
||||
import { ApiRequestError } from '../../lib/api/client.js';
|
||||
import { queryKeys } from '../../lib/queryKeys.js';
|
||||
import type { PartModel } from '../../lib/api/types.js';
|
||||
@@ -38,6 +39,7 @@ import type { PartModel } from '../../lib/api/types.js';
|
||||
const Schema = z.object({
|
||||
manufacturerId: z.string().uuid('Pick a manufacturer'),
|
||||
mpn: z.string().min(1, 'Required').max(128),
|
||||
categoryId: z.string().optional(), // '' = unassigned
|
||||
eolDate: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, 'YYYY-MM-DD')
|
||||
@@ -48,6 +50,8 @@ const Schema = z.object({
|
||||
});
|
||||
type Values = z.infer<typeof Schema>;
|
||||
|
||||
const UNASSIGNED = '__none__';
|
||||
|
||||
function isoToDateInput(iso: string | null): string {
|
||||
if (!iso) return '';
|
||||
return new Date(iso).toISOString().slice(0, 10);
|
||||
@@ -72,6 +76,7 @@ export function PartModelFormDialog({
|
||||
defaultValues: {
|
||||
manufacturerId: '',
|
||||
mpn: '',
|
||||
categoryId: '',
|
||||
eolDate: '',
|
||||
destroyOnFail: false,
|
||||
notes: '',
|
||||
@@ -83,6 +88,7 @@ export function PartModelFormDialog({
|
||||
form.reset({
|
||||
manufacturerId: partModel?.manufacturerId ?? '',
|
||||
mpn: partModel?.mpn ?? '',
|
||||
categoryId: partModel?.categoryId ?? '',
|
||||
eolDate: isoToDateInput(partModel?.eolDate ?? null),
|
||||
destroyOnFail: partModel?.destroyOnFail ?? false,
|
||||
notes: partModel?.notes ?? '',
|
||||
@@ -95,11 +101,28 @@ export function PartModelFormDialog({
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const categories = useQuery({
|
||||
queryKey: queryKeys.categories.list({ pageSize: 100 }),
|
||||
queryFn: () => listCategories({ pageSize: 100 }),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const createCategoryMutation = useMutation({
|
||||
mutationFn: (name: string) => createCategory({ name }),
|
||||
onSuccess: (cat) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.categories.all });
|
||||
form.setValue('categoryId', cat.id);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Could not add category'),
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: Values) => {
|
||||
const payload = {
|
||||
manufacturerId: values.manufacturerId,
|
||||
mpn: values.mpn,
|
||||
categoryId: values.categoryId ? values.categoryId : null,
|
||||
eolDate: values.eolDate ? values.eolDate : null,
|
||||
destroyOnFail: values.destroyOnFail,
|
||||
notes: values.notes ? values.notes : null,
|
||||
@@ -166,6 +189,47 @@ export function PartModelFormDialog({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="categoryId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Category</FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={field.value ? field.value : UNASSIGNED}
|
||||
onValueChange={(v) => {
|
||||
if (v === '__new__') {
|
||||
const name = window.prompt('New category name')?.trim();
|
||||
if (name) createCategoryMutation.mutate(name);
|
||||
return;
|
||||
}
|
||||
field.onChange(v === UNASSIGNED ? '' : v);
|
||||
}}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Unassigned" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={UNASSIGNED}>Unassigned</SelectItem>
|
||||
{categories.data?.data.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="__new__">+ Add category…</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<FormDescription>
|
||||
Groups like GPU / RAM / SSD describe this model family.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="eolDate"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
@@ -6,6 +6,8 @@ import { z } from 'zod';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { PartState } from '@vector/shared';
|
||||
import { PartModelCombobox } from '../common/PartModelCombobox.js';
|
||||
import type { PartModel } from '../../lib/api/types.js';
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
@@ -38,12 +40,13 @@ import { queryKeys } from '../../lib/queryKeys.js';
|
||||
import { partStateOptions } from './PartStateBadge.js';
|
||||
|
||||
// Schema reflects the server's CreatePartRequest but keeps strings for the form, letting the
|
||||
// submit handler coerce to the network shape.
|
||||
// submit handler coerce to the network shape. The combobox drives partModelId xor (mpn+mfr).
|
||||
const PartFormSchema = z
|
||||
.object({
|
||||
serialNumber: z.string().min(1, 'Required').max(128),
|
||||
mpn: z.string().min(1, 'Required').max(128),
|
||||
manufacturerId: z.string().uuid('Select a manufacturer'),
|
||||
partModelId: z.string().optional(), // set when an existing model is picked
|
||||
mpn: z.string().max(128).optional(), // set when creating a new model
|
||||
manufacturerId: z.string().optional(),
|
||||
state: PartState,
|
||||
binId: z.string().optional(), // '' = none
|
||||
hostId: z.string().optional(), // '' = none
|
||||
@@ -51,6 +54,22 @@ const PartFormSchema = z
|
||||
notes: z.string().max(4096).optional(),
|
||||
})
|
||||
.superRefine((v, ctx) => {
|
||||
const hasModel = Boolean(v.partModelId);
|
||||
const hasNew = Boolean(v.mpn && v.mpn.length > 0);
|
||||
if (!hasModel && !hasNew) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Pick a part model or enter a new MPN',
|
||||
path: ['partModelId'],
|
||||
});
|
||||
}
|
||||
if (hasNew && !v.manufacturerId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Select a manufacturer for the new model',
|
||||
path: ['manufacturerId'],
|
||||
});
|
||||
}
|
||||
if (v.state === 'DEPLOYED' && !v.hostId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
@@ -73,10 +92,13 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
|
||||
const editing = Boolean(part);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [pickedModel, setPickedModel] = useState<PartModel | null>(null);
|
||||
|
||||
const form = useForm<PartFormValues>({
|
||||
resolver: zodResolver(PartFormSchema),
|
||||
defaultValues: {
|
||||
serialNumber: '',
|
||||
partModelId: '',
|
||||
mpn: '',
|
||||
manufacturerId: '',
|
||||
state: 'SPARE',
|
||||
@@ -89,29 +111,33 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
form.reset(
|
||||
part
|
||||
? {
|
||||
serialNumber: part.serialNumber,
|
||||
mpn: part.partModel.mpn,
|
||||
manufacturerId: part.manufacturerId,
|
||||
state: part.state,
|
||||
binId: part.binId ?? '',
|
||||
hostId: part.hostId ?? '',
|
||||
price: part.price != null ? String(part.price) : '',
|
||||
notes: part.notes ?? '',
|
||||
}
|
||||
: {
|
||||
serialNumber: '',
|
||||
mpn: '',
|
||||
manufacturerId: '',
|
||||
state: 'SPARE',
|
||||
binId: '',
|
||||
hostId: '',
|
||||
price: '',
|
||||
notes: '',
|
||||
},
|
||||
);
|
||||
if (part) {
|
||||
setPickedModel(part.partModel ?? null);
|
||||
form.reset({
|
||||
serialNumber: part.serialNumber,
|
||||
partModelId: part.partModelId,
|
||||
mpn: '',
|
||||
manufacturerId: '',
|
||||
state: part.state,
|
||||
binId: part.binId ?? '',
|
||||
hostId: part.hostId ?? '',
|
||||
price: part.price != null ? String(part.price) : '',
|
||||
notes: part.notes ?? '',
|
||||
});
|
||||
} else {
|
||||
setPickedModel(null);
|
||||
form.reset({
|
||||
serialNumber: '',
|
||||
partModelId: '',
|
||||
mpn: '',
|
||||
manufacturerId: '',
|
||||
state: 'SPARE',
|
||||
binId: '',
|
||||
hostId: '',
|
||||
price: '',
|
||||
notes: '',
|
||||
});
|
||||
}
|
||||
}, [open, part, form]);
|
||||
|
||||
const watchedState = form.watch('state');
|
||||
@@ -137,16 +163,18 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: PartFormValues) => {
|
||||
const deployed = values.state === 'DEPLOYED';
|
||||
const payload = {
|
||||
const base = {
|
||||
serialNumber: values.serialNumber,
|
||||
mpn: values.mpn,
|
||||
manufacturerId: values.manufacturerId,
|
||||
state: values.state,
|
||||
binId: deployed ? null : values.binId ? values.binId : null,
|
||||
hostId: deployed ? (values.hostId ? values.hostId : null) : null,
|
||||
price: values.price === '' ? null : Number(values.price),
|
||||
notes: values.notes ? values.notes : null,
|
||||
};
|
||||
const modelPayload = values.partModelId
|
||||
? { partModelId: values.partModelId }
|
||||
: { mpn: values.mpn!, manufacturerId: values.manufacturerId! };
|
||||
const payload = { ...base, ...modelPayload };
|
||||
return editing && part
|
||||
? updatePart(part.id, payload)
|
||||
: createPart(payload);
|
||||
@@ -191,60 +219,79 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="serialNumber"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Serial</FormLabel>
|
||||
<FormControl>
|
||||
<Input autoFocus {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="mpn"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>MPN</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="manufacturerId"
|
||||
name="serialNumber"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Manufacturer</FormLabel>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select manufacturer" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{manufacturers.data?.data.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormLabel>Serial</FormLabel>
|
||||
<FormControl>
|
||||
<Input autoFocus {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="partModelId"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>Part model</FormLabel>
|
||||
<PartModelCombobox
|
||||
value={pickedModel}
|
||||
newMpn={form.watch('mpn') || null}
|
||||
onPick={(m) => {
|
||||
setPickedModel(m);
|
||||
form.setValue('partModelId', m.id, { shouldValidate: true });
|
||||
form.setValue('mpn', '');
|
||||
form.setValue('manufacturerId', '');
|
||||
}}
|
||||
onCreateNew={(mpn) => {
|
||||
setPickedModel(null);
|
||||
form.setValue('partModelId', '');
|
||||
form.setValue('mpn', mpn, { shouldValidate: true });
|
||||
}}
|
||||
onClear={() => {
|
||||
setPickedModel(null);
|
||||
form.setValue('partModelId', '');
|
||||
form.setValue('mpn', '');
|
||||
form.setValue('manufacturerId', '');
|
||||
}}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!pickedModel && form.watch('mpn') && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="manufacturerId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Manufacturer (for new model)</FormLabel>
|
||||
<Select value={field.value ?? ''} onValueChange={field.onChange}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select manufacturer" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{manufacturers.data?.data.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -8,6 +8,7 @@ const STATE_LABEL: Record<PartState, string> = {
|
||||
PENDING_DESTRUCTION: 'Pending destruction',
|
||||
PENDING_DROP_IN_CUSTODY: 'In custody',
|
||||
PENDING_DESTRUCTION_IN_CUSTODY: 'In custody (destroy)',
|
||||
PENDING_REPAIR: 'Held for repair',
|
||||
};
|
||||
|
||||
const STATE_VARIANT: Record<PartState, BadgeProps['variant']> = {
|
||||
@@ -17,6 +18,7 @@ const STATE_VARIANT: Record<PartState, BadgeProps['variant']> = {
|
||||
PENDING_DESTRUCTION: 'destructive',
|
||||
PENDING_DROP_IN_CUSTODY: 'outline',
|
||||
PENDING_DESTRUCTION_IN_CUSTODY: 'outline',
|
||||
PENDING_REPAIR: 'outline',
|
||||
};
|
||||
|
||||
export function PartStateBadge({ state }: { state: PartState }) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
@@ -34,16 +34,42 @@ import { listFms } from '../../lib/api/fms.js';
|
||||
import { listParts } from '../../lib/api/parts.js';
|
||||
import { ApiRequestError } from '../../lib/api/client.js';
|
||||
import { queryKeys } from '../../lib/queryKeys.js';
|
||||
import type { Repair } from '../../lib/api/types.js';
|
||||
import type { PartModel, Repair } from '../../lib/api/types.js';
|
||||
import { PartModelCombobox } from '../common/PartModelCombobox.js';
|
||||
|
||||
const Schema = z.object({
|
||||
hostId: z.string().uuid('Pick a host'),
|
||||
brokenSerial: z.string().trim().min(1, 'Required').max(128),
|
||||
brokenMpn: z.string().trim().min(1, 'Required').max(128),
|
||||
brokenManufacturerId: z.string().uuid('Select a manufacturer'),
|
||||
replacementSerial: z.string().trim().min(1, 'Required').max(128),
|
||||
fmId: z.string().optional(),
|
||||
});
|
||||
// When the broken serial matches an existing Part the model fields are skipped entirely;
|
||||
// otherwise the tech either picks an existing PartModel (partModelId) or types a new MPN
|
||||
// and a manufacturer. The refine mirrors LogRepairRequest.superRefine on the server.
|
||||
const Schema = z
|
||||
.object({
|
||||
hostId: z.string().uuid('Pick a host'),
|
||||
brokenSerial: z.string().trim().min(1, 'Required').max(128),
|
||||
brokenPartModelId: z.string().uuid().optional(),
|
||||
brokenMpn: z.string().trim().max(128).optional(),
|
||||
brokenManufacturerId: z.string().uuid().optional(),
|
||||
replacementSerial: z.string().trim().min(1, 'Required').max(128),
|
||||
fmId: z.string().optional(),
|
||||
brokenExists: z.boolean().optional(),
|
||||
})
|
||||
.superRefine((v, ctx) => {
|
||||
if (v.brokenExists) return;
|
||||
const hasModel = Boolean(v.brokenPartModelId);
|
||||
const hasNew = Boolean(v.brokenMpn && v.brokenMpn.length > 0);
|
||||
if (!hasModel && !hasNew) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Pick a part model or enter a new MPN',
|
||||
path: ['brokenPartModelId'],
|
||||
});
|
||||
}
|
||||
if (hasNew && !v.brokenManufacturerId) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Select a manufacturer for the new model',
|
||||
path: ['brokenManufacturerId'],
|
||||
});
|
||||
}
|
||||
});
|
||||
type Values = z.infer<typeof Schema>;
|
||||
|
||||
const NO_FM = '__none__';
|
||||
@@ -57,27 +83,34 @@ interface LogRepairDialogProps {
|
||||
export function LogRepairDialog({ open, onOpenChange, onLogged }: LogRepairDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [pickedModel, setPickedModel] = useState<PartModel | null>(null);
|
||||
|
||||
const form = useForm<Values>({
|
||||
resolver: zodResolver(Schema),
|
||||
defaultValues: {
|
||||
hostId: '',
|
||||
brokenSerial: '',
|
||||
brokenPartModelId: '',
|
||||
brokenMpn: '',
|
||||
brokenManufacturerId: '',
|
||||
replacementSerial: '',
|
||||
fmId: '',
|
||||
brokenExists: false,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setPickedModel(null);
|
||||
form.reset({
|
||||
hostId: '',
|
||||
brokenSerial: '',
|
||||
brokenPartModelId: '',
|
||||
brokenMpn: '',
|
||||
brokenManufacturerId: '',
|
||||
replacementSerial: '',
|
||||
fmId: '',
|
||||
brokenExists: false,
|
||||
});
|
||||
}, [open, form]);
|
||||
|
||||
@@ -115,16 +148,30 @@ export function LogRepairDialog({ open, onOpenChange, onLogged }: LogRepairDialo
|
||||
(p) => p.serialNumber === brokenSerial,
|
||||
);
|
||||
|
||||
// Keep a form-level flag so the zod refine can skip model validation when the broken part
|
||||
// is already in the catalog (server just reuses the existing PartModel).
|
||||
useEffect(() => {
|
||||
form.setValue('brokenExists', Boolean(existingBroken), { shouldValidate: true });
|
||||
}, [existingBroken, form]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (v: Values) =>
|
||||
logRepair({
|
||||
mutationFn: (v: Values) => {
|
||||
const base = {
|
||||
hostId: v.hostId,
|
||||
brokenSerial: v.brokenSerial.trim(),
|
||||
brokenMpn: v.brokenMpn.trim(),
|
||||
brokenManufacturerId: v.brokenManufacturerId,
|
||||
replacementSerial: v.replacementSerial.trim(),
|
||||
fmId: v.fmId ? v.fmId : undefined,
|
||||
}),
|
||||
};
|
||||
// If the broken part is already catalogued, the server ignores model fields entirely.
|
||||
if (existingBroken) return logRepair(base);
|
||||
const modelPayload = v.brokenPartModelId
|
||||
? { brokenPartModelId: v.brokenPartModelId }
|
||||
: {
|
||||
brokenMpn: v.brokenMpn?.trim(),
|
||||
brokenManufacturerId: v.brokenManufacturerId,
|
||||
};
|
||||
return logRepair({ ...base, ...modelPayload });
|
||||
},
|
||||
onSuccess: (repair) => {
|
||||
toast.success('Repair logged');
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.repairs.all });
|
||||
@@ -217,45 +264,68 @@ export function LogRepairDialog({ open, onOpenChange, onLogged }: LogRepairDialo
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brokenMpn"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Broken MPN</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
{!existingBroken && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brokenPartModelId"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>Broken part model</FormLabel>
|
||||
<PartModelCombobox
|
||||
value={pickedModel}
|
||||
newMpn={form.watch('brokenMpn') || null}
|
||||
onPick={(m) => {
|
||||
setPickedModel(m);
|
||||
form.setValue('brokenPartModelId', m.id, { shouldValidate: true });
|
||||
form.setValue('brokenMpn', '');
|
||||
form.setValue('brokenManufacturerId', '');
|
||||
}}
|
||||
onCreateNew={(mpn) => {
|
||||
setPickedModel(null);
|
||||
form.setValue('brokenPartModelId', '');
|
||||
form.setValue('brokenMpn', mpn, { shouldValidate: true });
|
||||
}}
|
||||
onClear={() => {
|
||||
setPickedModel(null);
|
||||
form.setValue('brokenPartModelId', '');
|
||||
form.setValue('brokenMpn', '');
|
||||
form.setValue('brokenManufacturerId', '');
|
||||
}}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!pickedModel && form.watch('brokenMpn') && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brokenManufacturerId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Manufacturer (for new model)</FormLabel>
|
||||
<Select value={field.value ?? ''} onValueChange={field.onChange}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{manufacturers.data?.data.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brokenManufacturerId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Broken manufacturer</FormLabel>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{manufacturers.data?.data.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -11,3 +11,8 @@ export async function dropOff(partId: string, input: DropOffRequest): Promise<Pa
|
||||
const res = await api.post<Part>(`/custody/${partId}/drop-off`, input);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function takeForRepair(partId: string): Promise<Part> {
|
||||
const res = await api.post<Part>(`/custody/${partId}/take-for-repair`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ export type HostListFilters = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
q?: string;
|
||||
state?: string;
|
||||
stack?: string;
|
||||
};
|
||||
|
||||
export function listHosts(filters: HostListFilters = {}) {
|
||||
|
||||
@@ -10,6 +10,7 @@ export type PartModelListFilters = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
manufacturerId?: string;
|
||||
categoryId?: string;
|
||||
q?: string;
|
||||
eolBefore?: string;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import type { FmStatus, PartEventType, PartState, Role } from '@vector/shared';
|
||||
import type {
|
||||
FmStatus,
|
||||
HostState,
|
||||
HostStack,
|
||||
PartEventType,
|
||||
PartState,
|
||||
Role,
|
||||
} from '@vector/shared';
|
||||
|
||||
// Shapes mirror Prisma rows the API returns (dates serialized as ISO strings).
|
||||
// Keep these in sync with apps/api/src/services responses.
|
||||
@@ -14,12 +21,14 @@ export interface PartModel {
|
||||
id: string;
|
||||
manufacturerId: string;
|
||||
mpn: string;
|
||||
categoryId: string | null;
|
||||
eolDate: string | null;
|
||||
destroyOnFail: boolean;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
manufacturer?: Manufacturer;
|
||||
category?: Category | null;
|
||||
_count?: { parts: number };
|
||||
}
|
||||
|
||||
@@ -59,7 +68,6 @@ export interface Part {
|
||||
price: number | null;
|
||||
state: PartState;
|
||||
binId: string | null;
|
||||
categoryId: string | null;
|
||||
hostId: string | null;
|
||||
custodianId: string | null;
|
||||
notes: string | null;
|
||||
@@ -99,6 +107,8 @@ export interface Host {
|
||||
name: string;
|
||||
location: string | null;
|
||||
notes: string | null;
|
||||
state: HostState;
|
||||
stack: HostStack;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ const STATE_LABELS: Record<PartState, string> = {
|
||||
PENDING_DESTRUCTION: 'Pending destruction',
|
||||
PENDING_DROP_IN_CUSTODY: 'In custody',
|
||||
PENDING_DESTRUCTION_IN_CUSTODY: 'In custody (destroy)',
|
||||
PENDING_REPAIR: 'Held for repair',
|
||||
};
|
||||
|
||||
const STATE_COLORS: Record<PartState, string> = {
|
||||
@@ -44,10 +45,11 @@ const STATE_COLORS: Record<PartState, string> = {
|
||||
PENDING_DESTRUCTION: 'hsl(38 92% 50%)',
|
||||
PENDING_DROP_IN_CUSTODY: 'hsl(262 83% 58%)',
|
||||
PENDING_DESTRUCTION_IN_CUSTODY: 'hsl(340 82% 52%)',
|
||||
PENDING_REPAIR: 'hsl(197 80% 50%)',
|
||||
};
|
||||
|
||||
function currency(cents: number): string {
|
||||
return (cents / 100).toLocaleString(undefined, { style: 'currency', currency: 'USD' });
|
||||
function currency(dollars: number): string {
|
||||
return dollars.toLocaleString(undefined, { style: 'currency', currency: 'USD' });
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
@@ -159,7 +161,7 @@ export default function Dashboard() {
|
||||
.map((s) => ({
|
||||
name: STATE_LABELS[s.state],
|
||||
state: s.state,
|
||||
value: s.totalPrice / 100,
|
||||
value: s.totalPrice,
|
||||
}))}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Edit, MoreHorizontal, Plus, Server, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -55,6 +56,25 @@ export default function Hosts() {
|
||||
header: 'Name',
|
||||
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'state',
|
||||
header: 'State',
|
||||
cell: ({ row }) => {
|
||||
const s = row.original.state;
|
||||
const variant =
|
||||
s === 'DEPLOYED' ? 'secondary' : s === 'DEGRADED' ? 'destructive' : 'outline';
|
||||
return <Badge variant={variant}>{s}</Badge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'stack',
|
||||
header: 'Stack',
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{row.original.stack}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'location',
|
||||
header: 'Location',
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import { ChevronRight, MapPin } from 'lucide-react';
|
||||
import { PageHeader } from '../components/layout/PageHeader.js';
|
||||
import { SiteList } from '../components/locations/SiteList.js';
|
||||
import { RoomDrawer } from '../components/locations/RoomDrawer.js';
|
||||
import { SiteRoomTree } from '../components/locations/SiteRoomTree.js';
|
||||
import { BinGrid } from '../components/locations/BinGrid.js';
|
||||
import { listSites } from '../lib/api/sites.js';
|
||||
import { listRooms } from '../lib/api/rooms.js';
|
||||
import { queryKeys } from '../lib/queryKeys.js';
|
||||
import { useAuth } from '../contexts/AuthContext.js';
|
||||
|
||||
export default function Locations() {
|
||||
@@ -20,23 +25,94 @@ export default function Locations() {
|
||||
void setRoomId(id || null);
|
||||
};
|
||||
|
||||
const sites = useQuery({
|
||||
queryKey: queryKeys.sites.list({ pageSize: 100 }),
|
||||
queryFn: () => listSites({ pageSize: 100 }),
|
||||
});
|
||||
const rooms = useQuery({
|
||||
queryKey: queryKeys.rooms.list({ siteId, pageSize: 100 }),
|
||||
queryFn: () => listRooms({ siteId: siteId!, pageSize: 100 }),
|
||||
enabled: Boolean(siteId),
|
||||
});
|
||||
|
||||
const siteName = useMemo(
|
||||
() => sites.data?.data.find((s) => s.id === siteId)?.name,
|
||||
[sites.data, siteId],
|
||||
);
|
||||
const roomName = useMemo(
|
||||
() => rooms.data?.data.find((r) => r.id === roomId)?.name,
|
||||
[rooms.data, roomId],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-var(--spacing-topbar,3.25rem)-3rem)] flex-col gap-4">
|
||||
<PageHeader
|
||||
title="Locations"
|
||||
description="Sites → Rooms → Bins. Select a site to drill in."
|
||||
description="Sites → Rooms → Bins. Pick a room to see its bins."
|
||||
/>
|
||||
<div className="grid min-h-0 flex-1 grid-cols-[16rem_16rem_1fr] overflow-hidden rounded-lg border border-border bg-card">
|
||||
<div className="grid min-h-0 flex-1 grid-cols-[18rem_1fr] overflow-hidden rounded-lg border border-border bg-card">
|
||||
<div className="border-r border-border">
|
||||
<SiteList selectedId={siteId} onSelect={handleSite} canEdit={canEdit} />
|
||||
<SiteRoomTree
|
||||
siteId={siteId}
|
||||
roomId={roomId}
|
||||
onSelectSite={handleSite}
|
||||
onSelectRoom={handleRoom}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-r border-border">
|
||||
<RoomDrawer siteId={siteId} selectedId={roomId} onSelect={handleRoom} canEdit={canEdit} />
|
||||
</div>
|
||||
<div>
|
||||
<BinGrid roomId={roomId} canEdit={canEdit} />
|
||||
<div className="flex min-h-0 flex-col">
|
||||
<Breadcrumb siteName={siteName} roomName={roomName} />
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{roomId ? (
|
||||
<BinGrid roomId={roomId} canEdit={canEdit} />
|
||||
) : (
|
||||
<EmptyPane siteSelected={Boolean(siteId)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Breadcrumb({
|
||||
siteName,
|
||||
roomName,
|
||||
}: {
|
||||
siteName: string | undefined;
|
||||
roomName: string | undefined;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 border-b border-border px-4 py-2 text-sm text-muted-foreground">
|
||||
{siteName ? (
|
||||
<>
|
||||
<span className="text-foreground">{siteName}</span>
|
||||
{roomName && (
|
||||
<>
|
||||
<ChevronRight className="h-3.5 w-3.5 opacity-60" />
|
||||
<span className="text-foreground">{roomName}</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span>Select a site to begin.</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyPane({ siteSelected }: { siteSelected: boolean }) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-8">
|
||||
<div className="flex max-w-sm flex-col items-center gap-2 rounded-lg border border-dashed border-border bg-muted/30 px-8 py-10 text-center">
|
||||
<MapPin className="h-6 w-6 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">
|
||||
{siteSelected ? 'Pick a room' : 'Pick a site and room'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Bins live inside rooms. Expand a site in the tree and choose a room to manage its bins.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Hand, PackageCheck } from 'lucide-react';
|
||||
import { Hand, PackageCheck, Undo2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@vector/ui';
|
||||
import { PageHeader } from '../components/layout/PageHeader.js';
|
||||
@@ -74,13 +74,24 @@ export default function MyCustody() {
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
size: 140,
|
||||
cell: ({ row }) => (
|
||||
<Button size="sm" variant="outline" onClick={() => setDropping(row.original)}>
|
||||
<PackageCheck className="h-3.5 w-3.5" />
|
||||
Drop in bin
|
||||
</Button>
|
||||
),
|
||||
size: 160,
|
||||
cell: ({ row }) => {
|
||||
const pending = row.original.state === 'PENDING_REPAIR';
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setDropping(row.original)}
|
||||
>
|
||||
{pending ? (
|
||||
<Undo2 className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<PackageCheck className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{pending ? 'Return to bin' : 'Drop in bin'}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
|
||||
@@ -58,6 +58,16 @@ export default function PartModels() {
|
||||
<span className="font-mono text-xs font-medium">{row.original.mpn}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'category',
|
||||
header: 'Category',
|
||||
cell: ({ row }) =>
|
||||
row.original.category ? (
|
||||
<Badge variant="outline">{row.original.category.name}</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'eolDate',
|
||||
header: 'EOL',
|
||||
@@ -87,7 +97,7 @@ export default function PartModels() {
|
||||
row.original.destroyOnFail ? (
|
||||
<Check className="h-4 w-4 text-foreground" />
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
<span className="text-xs text-muted-foreground">No</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { parseAsString } from 'nuqs';
|
||||
import { toast } from 'sonner';
|
||||
import { Edit, MoreHorizontal, Package, Plus, Trash2 } from 'lucide-react';
|
||||
import { Edit, HandHelping, MoreHorizontal, Package, Plus, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
@@ -26,7 +26,9 @@ import { PartBulkStateDialog } from '../components/parts/PartBulkStateDialog.js'
|
||||
import { ConfirmDialog } from '../components/ConfirmDialog.js';
|
||||
import { listParts, deletePart } from '../lib/api/parts.js';
|
||||
import { listManufacturers } from '../lib/api/manufacturers.js';
|
||||
import { listCategories } from '../lib/api/categories.js';
|
||||
import { listTags } from '../lib/api/tags.js';
|
||||
import { takeForRepair } from '../lib/api/custody.js';
|
||||
import { ApiRequestError } from '../lib/api/client.js';
|
||||
import type { Part } from '../lib/api/types.js';
|
||||
import { queryKeys } from '../lib/queryKeys.js';
|
||||
@@ -35,12 +37,14 @@ import { useAuth } from '../contexts/AuthContext.js';
|
||||
type PartsFilters = {
|
||||
state: string | null;
|
||||
manufacturerId: string | null;
|
||||
categoryId: string | null;
|
||||
tagId: string | null;
|
||||
};
|
||||
|
||||
const filterParsers = {
|
||||
state: parseAsString,
|
||||
manufacturerId: parseAsString,
|
||||
categoryId: parseAsString,
|
||||
tagId: parseAsString,
|
||||
};
|
||||
|
||||
@@ -62,6 +66,10 @@ export default function Parts() {
|
||||
queryKey: queryKeys.manufacturers.list({ pageSize: 100 }),
|
||||
queryFn: () => listManufacturers({ pageSize: 100 }),
|
||||
});
|
||||
const categoriesQuery = useQuery({
|
||||
queryKey: queryKeys.categories.list({ pageSize: 100 }),
|
||||
queryFn: () => listCategories({ pageSize: 100 }),
|
||||
});
|
||||
const tagsQuery = useQuery({
|
||||
queryKey: queryKeys.tags.list({ pageSize: 100 }),
|
||||
queryFn: () => listTags({ pageSize: 100 }),
|
||||
@@ -79,6 +87,17 @@ export default function Parts() {
|
||||
},
|
||||
});
|
||||
|
||||
const takeForRepairMutation = useMutation({
|
||||
mutationFn: (id: string) => takeForRepair(id),
|
||||
onSuccess: () => {
|
||||
toast.success('Part moved into your custody');
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.custody.all });
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Could not take part'),
|
||||
});
|
||||
|
||||
const columns = useMemo<ColumnDef<Part>[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -107,6 +126,18 @@ export default function Parts() {
|
||||
<span className="text-sm text-muted-foreground">{row.original.manufacturer.name}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'category',
|
||||
header: 'Category',
|
||||
cell: ({ row }) =>
|
||||
row.original.partModel.category ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{row.original.partModel.category.name}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'state',
|
||||
header: 'State',
|
||||
@@ -159,7 +190,7 @@ export default function Parts() {
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem onSelect={() => navigate(`/parts/${row.original.id}`)}>
|
||||
View
|
||||
</DropdownMenuItem>
|
||||
@@ -167,6 +198,15 @@ export default function Parts() {
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
{row.original.state === 'SPARE' && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => takeForRepairMutation.mutate(row.original.id)}
|
||||
disabled={takeForRepairMutation.isPending}
|
||||
>
|
||||
<HandHelping className="h-3.5 w-3.5" />
|
||||
Take into custody
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
@@ -184,7 +224,7 @@ export default function Parts() {
|
||||
),
|
||||
},
|
||||
],
|
||||
[navigate, isAdmin],
|
||||
[navigate, isAdmin, takeForRepairMutation],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -213,6 +253,7 @@ export default function Parts() {
|
||||
sort: params.sort,
|
||||
state: params.filters.state,
|
||||
manufacturerId: params.filters.manufacturerId,
|
||||
categoryId: params.filters.categoryId,
|
||||
tagId: params.filters.tagId,
|
||||
})
|
||||
}
|
||||
@@ -224,6 +265,7 @@ export default function Parts() {
|
||||
sort: params.sort,
|
||||
state: params.filters.state ?? undefined,
|
||||
manufacturerId: params.filters.manufacturerId ?? undefined,
|
||||
categoryId: params.filters.categoryId ?? undefined,
|
||||
tagId: params.filters.tagId ?? undefined,
|
||||
})
|
||||
}
|
||||
@@ -239,12 +281,15 @@ export default function Parts() {
|
||||
toolbar={({ filters, setFilter }) => (
|
||||
<PartsFilters
|
||||
manufacturers={manufacturers.data?.data ?? []}
|
||||
categories={categoriesQuery.data?.data ?? []}
|
||||
tags={tagsQuery.data?.data ?? []}
|
||||
state={filters.state ?? ALL}
|
||||
manufacturerId={filters.manufacturerId ?? ALL}
|
||||
categoryId={filters.categoryId ?? ALL}
|
||||
tagId={filters.tagId ?? ALL}
|
||||
onState={(v) => setFilter('state', v === ALL ? null : v)}
|
||||
onManufacturer={(v) => setFilter('manufacturerId', v === ALL ? null : v)}
|
||||
onCategory={(v) => setFilter('categoryId', v === ALL ? null : v)}
|
||||
onTag={(v) => setFilter('tagId', v === ALL ? null : v)}
|
||||
/>
|
||||
)}
|
||||
@@ -296,23 +341,29 @@ export default function Parts() {
|
||||
|
||||
interface PartsFiltersProps {
|
||||
manufacturers: { id: string; name: string }[];
|
||||
categories: { id: string; name: string }[];
|
||||
tags: { id: string; name: string }[];
|
||||
state: string;
|
||||
manufacturerId: string;
|
||||
categoryId: string;
|
||||
tagId: string;
|
||||
onState: (v: string) => void;
|
||||
onManufacturer: (v: string) => void;
|
||||
onCategory: (v: string) => void;
|
||||
onTag: (v: string) => void;
|
||||
}
|
||||
|
||||
function PartsFilters({
|
||||
manufacturers,
|
||||
categories,
|
||||
tags,
|
||||
state,
|
||||
manufacturerId,
|
||||
categoryId,
|
||||
tagId,
|
||||
onState,
|
||||
onManufacturer,
|
||||
onCategory,
|
||||
onTag,
|
||||
}: PartsFiltersProps) {
|
||||
return (
|
||||
@@ -343,6 +394,19 @@ function PartsFilters({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={categoryId} onValueChange={onCategory}>
|
||||
<SelectTrigger className="h-8 w-40 text-xs">
|
||||
<SelectValue placeholder="Category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>All categories</SelectItem>
|
||||
{categories.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={tagId} onValueChange={onTag}>
|
||||
<SelectTrigger className="h-8 w-36 text-xs">
|
||||
<SelectValue placeholder="Tag" />
|
||||
|
||||
@@ -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;
|
||||
@@ -62,6 +62,8 @@ model PartModel {
|
||||
eolDate DateTime?
|
||||
destroyOnFail Boolean @default(false)
|
||||
notes String?
|
||||
categoryId String?
|
||||
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
parts Part[]
|
||||
@@ -69,6 +71,7 @@ model PartModel {
|
||||
@@unique([manufacturerId, mpn])
|
||||
@@index([manufacturerId])
|
||||
@@index([eolDate])
|
||||
@@index([categoryId])
|
||||
}
|
||||
|
||||
model Site {
|
||||
@@ -106,12 +109,12 @@ model Bin {
|
||||
}
|
||||
|
||||
model Category {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
description String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
parts Part[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
partModels PartModel[]
|
||||
}
|
||||
|
||||
model Part {
|
||||
@@ -125,8 +128,6 @@ model Part {
|
||||
state String @default("SPARE")
|
||||
binId String?
|
||||
bin Bin? @relation(fields: [binId], references: [id], onDelete: SetNull)
|
||||
categoryId String?
|
||||
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||
hostId String?
|
||||
host Host? @relation(fields: [hostId], references: [id], onDelete: SetNull)
|
||||
custodianId String?
|
||||
@@ -144,7 +145,6 @@ model Part {
|
||||
@@index([binId])
|
||||
@@index([manufacturerId])
|
||||
@@index([partModelId])
|
||||
@@index([categoryId])
|
||||
@@index([hostId])
|
||||
@@index([custodianId])
|
||||
}
|
||||
@@ -191,11 +191,16 @@ model Host {
|
||||
name String @unique
|
||||
location String?
|
||||
notes String?
|
||||
state String @default("DEPLOYED")
|
||||
stack String @default("PRODUCTION")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
parts Part[]
|
||||
fms Fm[]
|
||||
repairs Repair[]
|
||||
|
||||
@@index([state])
|
||||
@@index([stack])
|
||||
}
|
||||
|
||||
model Fm {
|
||||
|
||||
@@ -17,6 +17,16 @@ async function main() {
|
||||
|
||||
console.log(`Seeded admin user: ${admin.username} (${admin.email})`);
|
||||
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()
|
||||
|
||||
@@ -7,9 +7,16 @@ export const PartState = z.enum([
|
||||
'PENDING_DESTRUCTION',
|
||||
'PENDING_DROP_IN_CUSTODY',
|
||||
'PENDING_DESTRUCTION_IN_CUSTODY',
|
||||
'PENDING_REPAIR',
|
||||
]);
|
||||
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 type Role = z.infer<typeof Role>;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { HostStack, HostState } from './enums.js';
|
||||
import { PaginationQuery } from './pagination.js';
|
||||
|
||||
const AssetId = z
|
||||
@@ -12,6 +13,8 @@ export const CreateHostRequest = z.object({
|
||||
name: z.string().min(1).max(128),
|
||||
location: z.string().max(256).optional().nullable(),
|
||||
notes: z.string().max(4096).optional().nullable(),
|
||||
state: HostState.optional(),
|
||||
stack: HostStack.optional(),
|
||||
});
|
||||
export type CreateHostRequest = z.infer<typeof CreateHostRequest>;
|
||||
|
||||
@@ -21,11 +24,15 @@ export const UpdateHostRequest = z
|
||||
name: z.string().min(1).max(128).optional(),
|
||||
location: z.string().max(256).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' });
|
||||
export type UpdateHostRequest = z.infer<typeof UpdateHostRequest>;
|
||||
|
||||
export const HostListQuery = PaginationQuery.extend({
|
||||
q: z.string().max(128).optional(),
|
||||
state: HostState.optional(),
|
||||
stack: HostStack.optional(),
|
||||
});
|
||||
export type HostListQuery = z.infer<typeof HostListQuery>;
|
||||
|
||||
@@ -9,6 +9,7 @@ export const CreatePartModelRequest = z.object({
|
||||
eolDate: IsoDate.nullable().optional(),
|
||||
destroyOnFail: z.boolean().optional(),
|
||||
notes: z.string().max(4096).nullable().optional(),
|
||||
categoryId: z.string().uuid().nullable().optional(),
|
||||
});
|
||||
export type CreatePartModelRequest = z.infer<typeof CreatePartModelRequest>;
|
||||
|
||||
@@ -19,12 +20,14 @@ export const UpdatePartModelRequest = z
|
||||
eolDate: IsoDate.nullable().optional(),
|
||||
destroyOnFail: z.boolean().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' });
|
||||
export type UpdatePartModelRequest = z.infer<typeof UpdatePartModelRequest>;
|
||||
|
||||
export const PartModelListQuery = PaginationQuery.extend({
|
||||
manufacturerId: z.string().uuid().optional(),
|
||||
categoryId: z.string().uuid().optional(),
|
||||
q: z.string().max(128).optional(),
|
||||
eolBefore: IsoDate.optional(),
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ export function allowedLocationFieldsForState(state: PartState): {
|
||||
return { hostId: 'required', binId: 'forbidden', custodianId: 'forbidden' };
|
||||
case 'PENDING_DROP_IN_CUSTODY':
|
||||
case 'PENDING_DESTRUCTION_IN_CUSTODY':
|
||||
case 'PENDING_REPAIR':
|
||||
return { hostId: 'forbidden', binId: 'forbidden', custodianId: 'required' };
|
||||
case 'SPARE':
|
||||
case 'BROKEN':
|
||||
@@ -49,7 +50,6 @@ export const CreatePartRequest = z
|
||||
hostId: z.string().uuid().optional().nullable(),
|
||||
custodianId: z.string().uuid().optional().nullable(),
|
||||
notes: z.string().max(4096).optional().nullable(),
|
||||
categoryId: z.string().uuid().optional().nullable(),
|
||||
tagIds: z.array(z.string().uuid()).max(32).optional(),
|
||||
})
|
||||
.superRefine((v, ctx) => {
|
||||
@@ -115,7 +115,6 @@ export const UpdatePartRequest = z
|
||||
hostId: z.string().uuid().nullable().optional(),
|
||||
custodianId: z.string().uuid().nullable().optional(),
|
||||
notes: z.string().max(4096).nullable().optional(),
|
||||
categoryId: z.string().uuid().nullable().optional(),
|
||||
tagIds: z.array(z.string().uuid()).max(32).optional(),
|
||||
})
|
||||
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' })
|
||||
|
||||
@@ -8,20 +8,33 @@ export const LogRepairRequest = z
|
||||
hostId: z.string().uuid().optional(),
|
||||
assetId: z.string().trim().min(1).max(128).optional(),
|
||||
brokenSerial: z.string().trim().min(1).max(128),
|
||||
brokenMpn: z.string().trim().min(1).max(128),
|
||||
brokenManufacturerId: z.string().uuid(),
|
||||
// When the broken serial isn't in Vector yet we ingest it. Provide either a known PartModel
|
||||
// (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),
|
||||
fmId: z.string().uuid().optional(),
|
||||
})
|
||||
.superRefine((v, ctx) => {
|
||||
const has = [v.hostId, v.assetId].filter((x) => x !== undefined && x !== '').length;
|
||||
if (has !== 1) {
|
||||
const hostHas = [v.hostId, v.assetId].filter((x) => x !== undefined && x !== '').length;
|
||||
if (hostHas !== 1) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Provide exactly one of hostId or assetId',
|
||||
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>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user