feat: laundry-list polish pass
CI / Lint · Typecheck · Test · Build (push) Successful in 44s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 59s

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:
2026-04-17 13:36:11 -04:00
parent 3d77f2846d
commit 60255f20bb
39 changed files with 1731 additions and 630 deletions
@@ -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;
+13 -8
View File
@@ -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 {
+10
View File
@@ -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()