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:
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `categoryId` on the `Part` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Host" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"assetId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"location" TEXT,
|
||||
"notes" TEXT,
|
||||
"state" TEXT NOT NULL DEFAULT 'DEPLOYED',
|
||||
"stack" TEXT NOT NULL DEFAULT 'PRODUCTION',
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
INSERT INTO "new_Host" ("assetId", "createdAt", "id", "location", "name", "notes", "updatedAt") SELECT "assetId", "createdAt", "id", "location", "name", "notes", "updatedAt" FROM "Host";
|
||||
DROP TABLE "Host";
|
||||
ALTER TABLE "new_Host" RENAME TO "Host";
|
||||
CREATE UNIQUE INDEX "Host_assetId_key" ON "Host"("assetId");
|
||||
CREATE UNIQUE INDEX "Host_name_key" ON "Host"("name");
|
||||
CREATE INDEX "Host_state_idx" ON "Host"("state");
|
||||
CREATE INDEX "Host_stack_idx" ON "Host"("stack");
|
||||
CREATE TABLE "new_Part" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"serialNumber" TEXT NOT NULL,
|
||||
"partModelId" TEXT NOT NULL,
|
||||
"manufacturerId" TEXT NOT NULL,
|
||||
"price" REAL,
|
||||
"state" TEXT NOT NULL DEFAULT 'SPARE',
|
||||
"binId" TEXT,
|
||||
"hostId" TEXT,
|
||||
"custodianId" TEXT,
|
||||
"notes" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Part_partModelId_fkey" FOREIGN KEY ("partModelId") REFERENCES "PartModel" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "Part_manufacturerId_fkey" FOREIGN KEY ("manufacturerId") REFERENCES "Manufacturer" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "Part_binId_fkey" FOREIGN KEY ("binId") REFERENCES "Bin" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT "Part_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT "Part_custodianId_fkey" FOREIGN KEY ("custodianId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_Part" ("binId", "createdAt", "custodianId", "hostId", "id", "manufacturerId", "notes", "partModelId", "price", "serialNumber", "state", "updatedAt") SELECT "binId", "createdAt", "custodianId", "hostId", "id", "manufacturerId", "notes", "partModelId", "price", "serialNumber", "state", "updatedAt" FROM "Part";
|
||||
DROP TABLE "Part";
|
||||
ALTER TABLE "new_Part" RENAME TO "Part";
|
||||
CREATE UNIQUE INDEX "Part_serialNumber_key" ON "Part"("serialNumber");
|
||||
CREATE INDEX "Part_state_idx" ON "Part"("state");
|
||||
CREATE INDEX "Part_binId_idx" ON "Part"("binId");
|
||||
CREATE INDEX "Part_manufacturerId_idx" ON "Part"("manufacturerId");
|
||||
CREATE INDEX "Part_partModelId_idx" ON "Part"("partModelId");
|
||||
CREATE INDEX "Part_hostId_idx" ON "Part"("hostId");
|
||||
CREATE INDEX "Part_custodianId_idx" ON "Part"("custodianId");
|
||||
CREATE TABLE "new_PartModel" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"manufacturerId" TEXT NOT NULL,
|
||||
"mpn" TEXT NOT NULL,
|
||||
"eolDate" DATETIME,
|
||||
"destroyOnFail" BOOLEAN NOT NULL DEFAULT false,
|
||||
"notes" TEXT,
|
||||
"categoryId" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "PartModel_manufacturerId_fkey" FOREIGN KEY ("manufacturerId") REFERENCES "Manufacturer" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "PartModel_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_PartModel" ("createdAt", "destroyOnFail", "eolDate", "id", "manufacturerId", "mpn", "notes", "updatedAt") SELECT "createdAt", "destroyOnFail", "eolDate", "id", "manufacturerId", "mpn", "notes", "updatedAt" FROM "PartModel";
|
||||
DROP TABLE "PartModel";
|
||||
ALTER TABLE "new_PartModel" RENAME TO "PartModel";
|
||||
CREATE INDEX "PartModel_manufacturerId_idx" ON "PartModel"("manufacturerId");
|
||||
CREATE INDEX "PartModel_eolDate_idx" ON "PartModel"("eolDate");
|
||||
CREATE INDEX "PartModel_categoryId_idx" ON "PartModel"("categoryId");
|
||||
CREATE UNIQUE INDEX "PartModel_manufacturerId_mpn_key" ON "PartModel"("manufacturerId", "mpn");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
@@ -62,6 +62,8 @@ model PartModel {
|
||||
eolDate DateTime?
|
||||
destroyOnFail Boolean @default(false)
|
||||
notes String?
|
||||
categoryId String?
|
||||
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
parts Part[]
|
||||
@@ -69,6 +71,7 @@ model PartModel {
|
||||
@@unique([manufacturerId, mpn])
|
||||
@@index([manufacturerId])
|
||||
@@index([eolDate])
|
||||
@@index([categoryId])
|
||||
}
|
||||
|
||||
model Site {
|
||||
@@ -106,12 +109,12 @@ model Bin {
|
||||
}
|
||||
|
||||
model Category {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
description String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
parts Part[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
partModels PartModel[]
|
||||
}
|
||||
|
||||
model Part {
|
||||
@@ -125,8 +128,6 @@ model Part {
|
||||
state String @default("SPARE")
|
||||
binId String?
|
||||
bin Bin? @relation(fields: [binId], references: [id], onDelete: SetNull)
|
||||
categoryId String?
|
||||
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||
hostId String?
|
||||
host Host? @relation(fields: [hostId], references: [id], onDelete: SetNull)
|
||||
custodianId String?
|
||||
@@ -144,7 +145,6 @@ model Part {
|
||||
@@index([binId])
|
||||
@@index([manufacturerId])
|
||||
@@index([partModelId])
|
||||
@@index([categoryId])
|
||||
@@index([hostId])
|
||||
@@index([custodianId])
|
||||
}
|
||||
@@ -191,11 +191,16 @@ model Host {
|
||||
name String @unique
|
||||
location String?
|
||||
notes String?
|
||||
state String @default("DEPLOYED")
|
||||
stack String @default("PRODUCTION")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
parts Part[]
|
||||
fms Fm[]
|
||||
repairs Repair[]
|
||||
|
||||
@@index([state])
|
||||
@@index([stack])
|
||||
}
|
||||
|
||||
model Fm {
|
||||
|
||||
@@ -17,6 +17,16 @@ async function main() {
|
||||
|
||||
console.log(`Seeded admin user: ${admin.username} (${admin.email})`);
|
||||
console.log('Default password: admin — change this immediately!');
|
||||
|
||||
const categoryNames = ['GPU', 'RAM', 'SSD', 'HDD', 'NIC', 'CPU', 'PSU', 'MOBO'];
|
||||
for (const name of categoryNames) {
|
||||
await prisma.category.upsert({
|
||||
where: { name },
|
||||
update: {},
|
||||
create: { name },
|
||||
});
|
||||
}
|
||||
console.log(`Seeded ${categoryNames.length} part categories.`);
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
@@ -7,9 +7,16 @@ export const PartState = z.enum([
|
||||
'PENDING_DESTRUCTION',
|
||||
'PENDING_DROP_IN_CUSTODY',
|
||||
'PENDING_DESTRUCTION_IN_CUSTODY',
|
||||
'PENDING_REPAIR',
|
||||
]);
|
||||
export type PartState = z.infer<typeof PartState>;
|
||||
|
||||
export const HostState = z.enum(['DEPLOYED', 'DEGRADED', 'TESTING']);
|
||||
export type HostState = z.infer<typeof HostState>;
|
||||
|
||||
export const HostStack = z.enum(['PRODUCTION', 'VETTING']);
|
||||
export type HostStack = z.infer<typeof HostStack>;
|
||||
|
||||
export const Role = z.enum(['ADMIN', 'TECHNICIAN']);
|
||||
export type Role = z.infer<typeof Role>;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { HostStack, HostState } from './enums.js';
|
||||
import { PaginationQuery } from './pagination.js';
|
||||
|
||||
const AssetId = z
|
||||
@@ -12,6 +13,8 @@ export const CreateHostRequest = z.object({
|
||||
name: z.string().min(1).max(128),
|
||||
location: z.string().max(256).optional().nullable(),
|
||||
notes: z.string().max(4096).optional().nullable(),
|
||||
state: HostState.optional(),
|
||||
stack: HostStack.optional(),
|
||||
});
|
||||
export type CreateHostRequest = z.infer<typeof CreateHostRequest>;
|
||||
|
||||
@@ -21,11 +24,15 @@ export const UpdateHostRequest = z
|
||||
name: z.string().min(1).max(128).optional(),
|
||||
location: z.string().max(256).nullable().optional(),
|
||||
notes: z.string().max(4096).nullable().optional(),
|
||||
state: HostState.optional(),
|
||||
stack: HostStack.optional(),
|
||||
})
|
||||
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
|
||||
export type UpdateHostRequest = z.infer<typeof UpdateHostRequest>;
|
||||
|
||||
export const HostListQuery = PaginationQuery.extend({
|
||||
q: z.string().max(128).optional(),
|
||||
state: HostState.optional(),
|
||||
stack: HostStack.optional(),
|
||||
});
|
||||
export type HostListQuery = z.infer<typeof HostListQuery>;
|
||||
|
||||
@@ -9,6 +9,7 @@ export const CreatePartModelRequest = z.object({
|
||||
eolDate: IsoDate.nullable().optional(),
|
||||
destroyOnFail: z.boolean().optional(),
|
||||
notes: z.string().max(4096).nullable().optional(),
|
||||
categoryId: z.string().uuid().nullable().optional(),
|
||||
});
|
||||
export type CreatePartModelRequest = z.infer<typeof CreatePartModelRequest>;
|
||||
|
||||
@@ -19,12 +20,14 @@ export const UpdatePartModelRequest = z
|
||||
eolDate: IsoDate.nullable().optional(),
|
||||
destroyOnFail: z.boolean().optional(),
|
||||
notes: z.string().max(4096).nullable().optional(),
|
||||
categoryId: z.string().uuid().nullable().optional(),
|
||||
})
|
||||
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
|
||||
export type UpdatePartModelRequest = z.infer<typeof UpdatePartModelRequest>;
|
||||
|
||||
export const PartModelListQuery = PaginationQuery.extend({
|
||||
manufacturerId: z.string().uuid().optional(),
|
||||
categoryId: z.string().uuid().optional(),
|
||||
q: z.string().max(128).optional(),
|
||||
eolBefore: IsoDate.optional(),
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ export function allowedLocationFieldsForState(state: PartState): {
|
||||
return { hostId: 'required', binId: 'forbidden', custodianId: 'forbidden' };
|
||||
case 'PENDING_DROP_IN_CUSTODY':
|
||||
case 'PENDING_DESTRUCTION_IN_CUSTODY':
|
||||
case 'PENDING_REPAIR':
|
||||
return { hostId: 'forbidden', binId: 'forbidden', custodianId: 'required' };
|
||||
case 'SPARE':
|
||||
case 'BROKEN':
|
||||
@@ -49,7 +50,6 @@ export const CreatePartRequest = z
|
||||
hostId: z.string().uuid().optional().nullable(),
|
||||
custodianId: z.string().uuid().optional().nullable(),
|
||||
notes: z.string().max(4096).optional().nullable(),
|
||||
categoryId: z.string().uuid().optional().nullable(),
|
||||
tagIds: z.array(z.string().uuid()).max(32).optional(),
|
||||
})
|
||||
.superRefine((v, ctx) => {
|
||||
@@ -115,7 +115,6 @@ export const UpdatePartRequest = z
|
||||
hostId: z.string().uuid().nullable().optional(),
|
||||
custodianId: z.string().uuid().nullable().optional(),
|
||||
notes: z.string().max(4096).nullable().optional(),
|
||||
categoryId: z.string().uuid().nullable().optional(),
|
||||
tagIds: z.array(z.string().uuid()).max(32).optional(),
|
||||
})
|
||||
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' })
|
||||
|
||||
@@ -8,20 +8,33 @@ export const LogRepairRequest = z
|
||||
hostId: z.string().uuid().optional(),
|
||||
assetId: z.string().trim().min(1).max(128).optional(),
|
||||
brokenSerial: z.string().trim().min(1).max(128),
|
||||
brokenMpn: z.string().trim().min(1).max(128),
|
||||
brokenManufacturerId: z.string().uuid(),
|
||||
// When the broken serial isn't in Vector yet we ingest it. Provide either a known PartModel
|
||||
// (brokenPartModelId) or the manufacturer + mpn pair to auto-create it.
|
||||
brokenPartModelId: z.string().uuid().optional(),
|
||||
brokenMpn: z.string().trim().min(1).max(128).optional(),
|
||||
brokenManufacturerId: z.string().uuid().optional(),
|
||||
replacementSerial: z.string().trim().min(1).max(128),
|
||||
fmId: z.string().uuid().optional(),
|
||||
})
|
||||
.superRefine((v, ctx) => {
|
||||
const has = [v.hostId, v.assetId].filter((x) => x !== undefined && x !== '').length;
|
||||
if (has !== 1) {
|
||||
const hostHas = [v.hostId, v.assetId].filter((x) => x !== undefined && x !== '').length;
|
||||
if (hostHas !== 1) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Provide exactly one of hostId or assetId',
|
||||
path: ['hostId'],
|
||||
});
|
||||
}
|
||||
const hasModel =
|
||||
v.brokenPartModelId !== undefined ||
|
||||
(v.brokenMpn !== undefined && v.brokenManufacturerId !== undefined);
|
||||
if (!hasModel) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Provide brokenPartModelId or both brokenMpn and brokenManufacturerId',
|
||||
path: ['brokenPartModelId'],
|
||||
});
|
||||
}
|
||||
});
|
||||
export type LogRepairRequest = z.infer<typeof LogRepairRequest>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user