60255f20bb
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>
199 lines
7.2 KiB
TypeScript
199 lines
7.2 KiB
TypeScript
import { z } from 'zod';
|
|
import { PartState } from './enums.js';
|
|
import { PaginationQuery } from './pagination.js';
|
|
|
|
// A part create/update can either reference an existing PartModel directly (partModelId)
|
|
// or auto-provision one via manufacturerId + mpn. Exactly one form is required on create.
|
|
const modelSelector = z
|
|
.object({
|
|
partModelId: z.string().uuid().optional(),
|
|
manufacturerId: z.string().uuid().optional(),
|
|
mpn: z.string().min(1).max(128).optional(),
|
|
})
|
|
.refine(
|
|
(v) => v.partModelId !== undefined || (v.manufacturerId !== undefined && v.mpn !== undefined),
|
|
{ message: 'Provide partModelId or both manufacturerId and mpn' },
|
|
);
|
|
|
|
// Which of hostId / binId / custodianId may be set for a given state.
|
|
// `null` counts as "not set" for the purposes of these checks — callers are expected
|
|
// to treat undefined / null consistently when wiring a request.
|
|
export function allowedLocationFieldsForState(state: PartState): {
|
|
hostId: 'required' | 'forbidden';
|
|
binId: 'optional' | 'forbidden';
|
|
custodianId: 'required' | 'forbidden';
|
|
} {
|
|
switch (state) {
|
|
case 'DEPLOYED':
|
|
return { hostId: 'required', binId: 'forbidden', custodianId: 'forbidden' };
|
|
case 'PENDING_DROP_IN_CUSTODY':
|
|
case 'PENDING_DESTRUCTION_IN_CUSTODY':
|
|
case 'PENDING_REPAIR':
|
|
return { hostId: 'forbidden', binId: 'forbidden', custodianId: 'required' };
|
|
case 'SPARE':
|
|
case 'BROKEN':
|
|
case 'PENDING_DESTRUCTION':
|
|
default:
|
|
return { hostId: 'forbidden', binId: 'optional', custodianId: 'forbidden' };
|
|
}
|
|
}
|
|
|
|
export const CreatePartRequest = z
|
|
.object({
|
|
serialNumber: z.string().min(1).max(128),
|
|
partModelId: z.string().uuid().optional(),
|
|
manufacturerId: z.string().uuid().optional(),
|
|
mpn: z.string().min(1).max(128).optional(),
|
|
price: z.number().nonnegative().optional().nullable(),
|
|
state: PartState.optional(),
|
|
binId: z.string().uuid().optional().nullable(),
|
|
hostId: z.string().uuid().optional().nullable(),
|
|
custodianId: z.string().uuid().optional().nullable(),
|
|
notes: z.string().max(4096).optional().nullable(),
|
|
tagIds: z.array(z.string().uuid()).max(32).optional(),
|
|
})
|
|
.superRefine((v, ctx) => {
|
|
const parsed = modelSelector.safeParse({
|
|
partModelId: v.partModelId,
|
|
manufacturerId: v.manufacturerId,
|
|
mpn: v.mpn,
|
|
});
|
|
if (!parsed.success) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: 'Provide partModelId or both manufacturerId and mpn',
|
|
path: ['partModelId'],
|
|
});
|
|
}
|
|
const state = v.state ?? 'SPARE';
|
|
const rules = allowedLocationFieldsForState(state);
|
|
if (rules.hostId === 'required' && !v.hostId) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: 'A deployed part must be assigned to a host',
|
|
path: ['hostId'],
|
|
});
|
|
}
|
|
if (rules.hostId === 'forbidden' && v.hostId) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: 'Only deployed parts can be assigned to a host',
|
|
path: ['hostId'],
|
|
});
|
|
}
|
|
if (rules.binId === 'forbidden' && v.binId) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: 'This state cannot have a bin assignment',
|
|
path: ['binId'],
|
|
});
|
|
}
|
|
if (rules.custodianId === 'required' && !v.custodianId) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: 'A part in custody must name a custodian',
|
|
path: ['custodianId'],
|
|
});
|
|
}
|
|
if (rules.custodianId === 'forbidden' && v.custodianId) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: 'Only parts in a custody state can have a custodian',
|
|
path: ['custodianId'],
|
|
});
|
|
}
|
|
});
|
|
export type CreatePartRequest = z.infer<typeof CreatePartRequest>;
|
|
|
|
export const UpdatePartRequest = z
|
|
.object({
|
|
serialNumber: z.string().min(1).max(128).optional(),
|
|
partModelId: z.string().uuid().optional(),
|
|
price: z.number().nonnegative().nullable().optional(),
|
|
state: PartState.optional(),
|
|
binId: z.string().uuid().nullable().optional(),
|
|
hostId: z.string().uuid().nullable().optional(),
|
|
custodianId: z.string().uuid().nullable().optional(),
|
|
notes: z.string().max(4096).nullable().optional(),
|
|
tagIds: z.array(z.string().uuid()).max(32).optional(),
|
|
})
|
|
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' })
|
|
.superRefine((v, ctx) => {
|
|
// When state is supplied we can enforce the full matrix against the input fields.
|
|
// When state is absent the server resolver still enforces the invariant using
|
|
// current-row state + input overlay, so we keep zod to input-level sanity checks.
|
|
if (v.state) {
|
|
const rules = allowedLocationFieldsForState(v.state);
|
|
if (rules.hostId === 'forbidden' && v.hostId) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: 'Only deployed parts can be assigned to a host',
|
|
path: ['hostId'],
|
|
});
|
|
}
|
|
if (rules.binId === 'forbidden' && v.binId) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: 'This state cannot have a bin assignment',
|
|
path: ['binId'],
|
|
});
|
|
}
|
|
if (rules.custodianId === 'forbidden' && v.custodianId) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: 'Only parts in a custody state can have a custodian',
|
|
path: ['custodianId'],
|
|
});
|
|
}
|
|
} else if (v.binId && v.hostId) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: 'A part cannot be assigned to both a host and a bin',
|
|
path: ['binId'],
|
|
});
|
|
}
|
|
});
|
|
export type UpdatePartRequest = z.infer<typeof UpdatePartRequest>;
|
|
|
|
export const PartListQuery = PaginationQuery.extend({
|
|
state: PartState.optional(),
|
|
binId: z.string().uuid().optional(),
|
|
manufacturerId: z.string().uuid().optional(),
|
|
partModelId: z.string().uuid().optional(),
|
|
hostId: z.string().uuid().optional(),
|
|
custodianId: z.string().uuid().optional(),
|
|
mpn: z.string().max(128).optional(),
|
|
serialNumber: z.string().max(128).optional(),
|
|
q: z.string().max(128).optional(),
|
|
categoryId: z.string().uuid().optional(),
|
|
tagId: z.string().uuid().optional(),
|
|
eolOnly: z
|
|
.union([z.literal('true'), z.literal('false'), z.boolean()])
|
|
.transform((v) => v === true || v === 'true')
|
|
.optional(),
|
|
});
|
|
export type PartListQuery = z.infer<typeof PartListQuery>;
|
|
|
|
export const PartEventsQuery = PaginationQuery;
|
|
export type PartEventsQuery = z.infer<typeof PartEventsQuery>;
|
|
|
|
export const BulkPartsRequest = z
|
|
.object({
|
|
ids: z.array(z.string().uuid()).min(1).max(500),
|
|
state: PartState.optional(),
|
|
binId: z.string().uuid().nullable().optional(),
|
|
hostId: z.string().uuid().nullable().optional(),
|
|
addTagIds: z.array(z.string().uuid()).max(32).optional(),
|
|
removeTagIds: z.array(z.string().uuid()).max(32).optional(),
|
|
})
|
|
.refine(
|
|
(v) =>
|
|
v.state !== undefined ||
|
|
v.binId !== undefined ||
|
|
v.hostId !== undefined ||
|
|
(v.addTagIds && v.addTagIds.length > 0) ||
|
|
(v.removeTagIds && v.removeTagIds.length > 0),
|
|
{ message: 'At least one mutation field is required' },
|
|
);
|
|
export type BulkPartsRequest = z.infer<typeof BulkPartsRequest>;
|