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