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:
@@ -7,9 +7,16 @@ export const PartState = z.enum([
|
||||
'PENDING_DESTRUCTION',
|
||||
'PENDING_DROP_IN_CUSTODY',
|
||||
'PENDING_DESTRUCTION_IN_CUSTODY',
|
||||
'PENDING_REPAIR',
|
||||
]);
|
||||
export type PartState = z.infer<typeof PartState>;
|
||||
|
||||
export const HostState = z.enum(['DEPLOYED', 'DEGRADED', 'TESTING']);
|
||||
export type HostState = z.infer<typeof HostState>;
|
||||
|
||||
export const HostStack = z.enum(['PRODUCTION', 'VETTING']);
|
||||
export type HostStack = z.infer<typeof HostStack>;
|
||||
|
||||
export const Role = z.enum(['ADMIN', 'TECHNICIAN']);
|
||||
export type Role = z.infer<typeof Role>;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { HostStack, HostState } from './enums.js';
|
||||
import { PaginationQuery } from './pagination.js';
|
||||
|
||||
const AssetId = z
|
||||
@@ -12,6 +13,8 @@ export const CreateHostRequest = z.object({
|
||||
name: z.string().min(1).max(128),
|
||||
location: z.string().max(256).optional().nullable(),
|
||||
notes: z.string().max(4096).optional().nullable(),
|
||||
state: HostState.optional(),
|
||||
stack: HostStack.optional(),
|
||||
});
|
||||
export type CreateHostRequest = z.infer<typeof CreateHostRequest>;
|
||||
|
||||
@@ -21,11 +24,15 @@ export const UpdateHostRequest = z
|
||||
name: z.string().min(1).max(128).optional(),
|
||||
location: z.string().max(256).nullable().optional(),
|
||||
notes: z.string().max(4096).nullable().optional(),
|
||||
state: HostState.optional(),
|
||||
stack: HostStack.optional(),
|
||||
})
|
||||
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
|
||||
export type UpdateHostRequest = z.infer<typeof UpdateHostRequest>;
|
||||
|
||||
export const HostListQuery = PaginationQuery.extend({
|
||||
q: z.string().max(128).optional(),
|
||||
state: HostState.optional(),
|
||||
stack: HostStack.optional(),
|
||||
});
|
||||
export type HostListQuery = z.infer<typeof HostListQuery>;
|
||||
|
||||
@@ -9,6 +9,7 @@ export const CreatePartModelRequest = z.object({
|
||||
eolDate: IsoDate.nullable().optional(),
|
||||
destroyOnFail: z.boolean().optional(),
|
||||
notes: z.string().max(4096).nullable().optional(),
|
||||
categoryId: z.string().uuid().nullable().optional(),
|
||||
});
|
||||
export type CreatePartModelRequest = z.infer<typeof CreatePartModelRequest>;
|
||||
|
||||
@@ -19,12 +20,14 @@ export const UpdatePartModelRequest = z
|
||||
eolDate: IsoDate.nullable().optional(),
|
||||
destroyOnFail: z.boolean().optional(),
|
||||
notes: z.string().max(4096).nullable().optional(),
|
||||
categoryId: z.string().uuid().nullable().optional(),
|
||||
})
|
||||
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
|
||||
export type UpdatePartModelRequest = z.infer<typeof UpdatePartModelRequest>;
|
||||
|
||||
export const PartModelListQuery = PaginationQuery.extend({
|
||||
manufacturerId: z.string().uuid().optional(),
|
||||
categoryId: z.string().uuid().optional(),
|
||||
q: z.string().max(128).optional(),
|
||||
eolBefore: IsoDate.optional(),
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ export function allowedLocationFieldsForState(state: PartState): {
|
||||
return { hostId: 'required', binId: 'forbidden', custodianId: 'forbidden' };
|
||||
case 'PENDING_DROP_IN_CUSTODY':
|
||||
case 'PENDING_DESTRUCTION_IN_CUSTODY':
|
||||
case 'PENDING_REPAIR':
|
||||
return { hostId: 'forbidden', binId: 'forbidden', custodianId: 'required' };
|
||||
case 'SPARE':
|
||||
case 'BROKEN':
|
||||
@@ -49,7 +50,6 @@ export const CreatePartRequest = z
|
||||
hostId: z.string().uuid().optional().nullable(),
|
||||
custodianId: z.string().uuid().optional().nullable(),
|
||||
notes: z.string().max(4096).optional().nullable(),
|
||||
categoryId: z.string().uuid().optional().nullable(),
|
||||
tagIds: z.array(z.string().uuid()).max(32).optional(),
|
||||
})
|
||||
.superRefine((v, ctx) => {
|
||||
@@ -115,7 +115,6 @@ export const UpdatePartRequest = z
|
||||
hostId: z.string().uuid().nullable().optional(),
|
||||
custodianId: z.string().uuid().nullable().optional(),
|
||||
notes: z.string().max(4096).nullable().optional(),
|
||||
categoryId: z.string().uuid().nullable().optional(),
|
||||
tagIds: z.array(z.string().uuid()).max(32).optional(),
|
||||
})
|
||||
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' })
|
||||
|
||||
@@ -8,20 +8,33 @@ export const LogRepairRequest = z
|
||||
hostId: z.string().uuid().optional(),
|
||||
assetId: z.string().trim().min(1).max(128).optional(),
|
||||
brokenSerial: z.string().trim().min(1).max(128),
|
||||
brokenMpn: z.string().trim().min(1).max(128),
|
||||
brokenManufacturerId: z.string().uuid(),
|
||||
// When the broken serial isn't in Vector yet we ingest it. Provide either a known PartModel
|
||||
// (brokenPartModelId) or the manufacturer + mpn pair to auto-create it.
|
||||
brokenPartModelId: z.string().uuid().optional(),
|
||||
brokenMpn: z.string().trim().min(1).max(128).optional(),
|
||||
brokenManufacturerId: z.string().uuid().optional(),
|
||||
replacementSerial: z.string().trim().min(1).max(128),
|
||||
fmId: z.string().uuid().optional(),
|
||||
})
|
||||
.superRefine((v, ctx) => {
|
||||
const has = [v.hostId, v.assetId].filter((x) => x !== undefined && x !== '').length;
|
||||
if (has !== 1) {
|
||||
const hostHas = [v.hostId, v.assetId].filter((x) => x !== undefined && x !== '').length;
|
||||
if (hostHas !== 1) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Provide exactly one of hostId or assetId',
|
||||
path: ['hostId'],
|
||||
});
|
||||
}
|
||||
const hasModel =
|
||||
v.brokenPartModelId !== undefined ||
|
||||
(v.brokenMpn !== undefined && v.brokenManufacturerId !== undefined);
|
||||
if (!hasModel) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Provide brokenPartModelId or both brokenMpn and brokenManufacturerId',
|
||||
path: ['brokenPartModelId'],
|
||||
});
|
||||
}
|
||||
});
|
||||
export type LogRepairRequest = z.infer<typeof LogRepairRequest>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user