feat: rework EOL, repairs, and hosts for real workflow
CI / Lint · Typecheck · Test · Build (push) Successful in 48s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 1m1s

Four domain-model changes driven by exercising the deployed 2.0 build:

- EOL moves from manufacturer to MPN via new PartModel catalog table,
  so alerts fire on the thing that actually ages.
- Repairs re-home to Host (required hostId + problem text) with an
  optional RepairJobPart join for affected parts; drop Part.replacementPartId.
- New /repairs/:id detail page with editable problem, part list, and
  a RepairComment thread (REPAIR_COMMENTED events fan out to each
  problem part's timeline).
- Host.assetId (required, unique) surfaces prominently on the repair
  page so techs can confirm they're touching the right box.

Single destructive migration reshapes existing dev data. All 7 packages
typecheck clean; 30 API tests pass (9 new covering host membership,
upsertByMpn idempotency + race, assetId 409, comment userId stamping).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 10:17:29 -04:00
parent 23bd0f0c6a
commit 0f952d6c1b
50 changed files with 2665 additions and 602 deletions
+2
View File
@@ -14,6 +14,7 @@ import { errorHandler } from './middleware/error.js';
import authRoutes from './routes/auth.js';
import userRoutes from './routes/users.js';
import manufacturerRoutes from './routes/manufacturers.js';
import partModelRoutes from './routes/part-models.js';
import siteRoutes from './routes/sites.js';
import roomRoutes from './routes/rooms.js';
import binRoutes from './routes/bins.js';
@@ -79,6 +80,7 @@ app.use('/api/auth', authLimiter, authRoutes);
app.use('/api', requireCsrf);
app.use('/api/users', userRoutes);
app.use('/api/manufacturers', manufacturerRoutes);
app.use('/api/part-models', partModelRoutes);
app.use('/api/sites', siteRoutes);
app.use('/api/rooms', roomRoutes);
app.use('/api/bins', binRoutes);
+15
View File
@@ -48,6 +48,21 @@ export async function update(req: Request<{ id: string }>, res: Response, next:
}
}
export async function listDeployedParts(
req: Request<{ id: string }>,
res: Response,
next: NextFunction,
) {
try {
const parts = await prisma.$transaction((tx) =>
svc.listDeployedParts(tx, req.params.id),
);
res.json(parts);
} catch (err) {
next(err);
}
}
export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
await prisma.$transaction((tx) => svc.remove(tx, req.params.id));
+58
View File
@@ -0,0 +1,58 @@
import type { NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db';
import type {
CreatePartModelRequest,
PartModelListQuery,
UpdatePartModelRequest,
} from '@vector/shared';
import * as svc from '../services/part-models.js';
import { errors } from '../lib/http-error.js';
export async function list(req: Request, res: Response, next: NextFunction) {
try {
const q = req.validated!.query as PartModelListQuery;
const result = await prisma.$transaction((tx) => svc.list(tx, q));
res.json(result);
} catch (err) {
next(err);
}
}
export async function get(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const model = await prisma.$transaction((tx) => svc.get(tx, req.params.id));
if (!model) throw errors.notFound('Part model');
res.json(model);
} catch (err) {
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as CreatePartModelRequest;
const model = await prisma.$transaction((tx) => svc.create(tx, input));
res.status(201).json(model);
} catch (err) {
next(err);
}
}
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as UpdatePartModelRequest;
const model = await prisma.$transaction((tx) => svc.update(tx, req.params.id, input));
res.json(model);
} catch (err) {
next(err);
}
}
export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
await prisma.$transaction((tx) => svc.remove(tx, req.params.id));
res.status(204).end();
} catch (err) {
next(err);
}
}
+32 -13
View File
@@ -1,7 +1,9 @@
import type { NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db';
import type {
CreateRepairCommentRequest,
CreateRepairJobRequest,
RepairCommentListQuery,
RepairJobListQuery,
UpdateRepairJobRequest,
} from '@vector/shared';
@@ -28,19 +30,6 @@ export async function get(req: Request<{ id: string }>, res: Response, next: Nex
}
}
export async function listForPart(
req: Request<{ id: string }>,
res: Response,
next: NextFunction,
) {
try {
const repairs = await prisma.$transaction((tx) => svc.listForPart(tx, req.params.id));
res.json(repairs);
} catch (err) {
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as CreateRepairJobRequest;
@@ -73,3 +62,33 @@ export async function remove(req: Request<{ id: string }>, res: Response, next:
next(err);
}
}
export async function listComments(
req: Request<{ id: string }>,
res: Response,
next: NextFunction,
) {
try {
const q = req.validated!.query as RepairCommentListQuery;
const result = await prisma.$transaction((tx) => svc.listComments(tx, req.params.id, q));
res.json(result);
} catch (err) {
next(err);
}
}
export async function addComment(
req: Request<{ id: string }>,
res: Response,
next: NextFunction,
) {
try {
const input = req.validated!.body as CreateRepairCommentRequest;
const comment = await prisma.$transaction((tx) =>
svc.addComment(tx, req.params.id, input, req.user ?? null),
);
res.status(201).json(comment);
} catch (err) {
next(err);
}
}
+1
View File
@@ -13,6 +13,7 @@ const router = Router();
router.get('/', requireAuth, validate('query', HostListQuery), ctrl.list);
router.post('/', requireAuth, requireRole('ADMIN'), validate('body', CreateHostRequest), ctrl.create);
router.get('/:id', requireAuth, ctrl.get);
router.get('/:id/deployed-parts', requireAuth, ctrl.listDeployedParts);
router.patch('/:id', requireAuth, requireRole('ADMIN'), validate('body', UpdateHostRequest), ctrl.update);
router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove);
+31
View File
@@ -0,0 +1,31 @@
import { Router } from 'express';
import {
CreatePartModelRequest,
PartModelListQuery,
UpdatePartModelRequest,
} from '@vector/shared';
import * as ctrl from '../controllers/part-models.js';
import { requireAuth, requireRole } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
const router = Router();
router.get('/', requireAuth, validate('query', PartModelListQuery), ctrl.list);
router.get('/:id', requireAuth, ctrl.get);
router.post(
'/',
requireAuth,
requireRole('ADMIN'),
validate('body', CreatePartModelRequest),
ctrl.create,
);
router.patch(
'/:id',
requireAuth,
requireRole('ADMIN'),
validate('body', UpdatePartModelRequest),
ctrl.update,
);
router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove);
export default router;
-3
View File
@@ -9,7 +9,6 @@ import {
} from '@vector/shared';
import * as ctrl from '../controllers/parts.js';
import * as tagsCtrl from '../controllers/tags.js';
import * as repairsCtrl from '../controllers/repairs.js';
import { requireAuth, requireRole } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
@@ -27,6 +26,4 @@ router.get('/:id/tags', requireAuth, tagsCtrl.listForPart);
router.post('/:id/tags', requireAuth, validate('body', AssignTagsRequest), tagsCtrl.assignToPart);
router.delete('/:id/tags/:tagId', requireAuth, tagsCtrl.unassignFromPart);
router.get('/:id/repairs', requireAuth, repairsCtrl.listForPart);
export default router;
+15
View File
@@ -1,6 +1,8 @@
import { Router } from 'express';
import {
CreateRepairCommentRequest,
CreateRepairJobRequest,
RepairCommentListQuery,
RepairJobListQuery,
UpdateRepairJobRequest,
} from '@vector/shared';
@@ -16,4 +18,17 @@ router.get('/:id', requireAuth, ctrl.get);
router.patch('/:id', requireAuth, validate('body', UpdateRepairJobRequest), ctrl.update);
router.delete('/:id', requireAuth, ctrl.remove);
router.get(
'/:id/comments',
requireAuth,
validate('query', RepairCommentListQuery),
ctrl.listComments,
);
router.post(
'/:id/comments',
requireAuth,
validate('body', CreateRepairCommentRequest),
ctrl.addComment,
);
export default router;
+58 -26
View File
@@ -12,10 +12,16 @@ function makeTx(args: {
state: string;
binId: string | null;
createdAt: Date;
manufacturerId: string;
partModelId: string;
}[];
openRepairs: number;
eolManufacturers: { id: string; name: string; eolDate: Date | null }[];
eolPartModels: {
id: string;
mpn: string;
eolDate: Date | null;
manufacturerId: string;
manufacturer: { name: string };
}[];
bins: { id: string; name: string; room: { name: string; site: { name: string } } }[];
}): Tx {
const tx = {
@@ -32,8 +38,8 @@ function makeTx(args: {
repairJob: {
count: async () => args.openRepairs,
},
manufacturer: {
findMany: async () => args.eolManufacturers,
partModel: {
findMany: async () => args.eolPartModels,
},
bin: {
findMany: async () => args.bins,
@@ -55,7 +61,7 @@ describe('analytics.dashboard', () => {
],
parts: [],
openRepairs: 4,
eolManufacturers: [],
eolPartModels: [],
bins: [],
});
@@ -73,13 +79,13 @@ describe('analytics.dashboard', () => {
partCount: 4,
stateRows: [],
parts: [
{ id: 'p1', state: 'SPARE', binId: null, createdAt: daysAgo(10), manufacturerId: 'm' },
{ id: 'p2', state: 'SPARE', binId: null, createdAt: daysAgo(60), manufacturerId: 'm' },
{ id: 'p3', state: 'SPARE', binId: null, createdAt: daysAgo(400), manufacturerId: 'm' },
{ id: 'p4', state: 'SPARE', binId: null, createdAt: daysAgo(900), manufacturerId: 'm' },
{ id: 'p1', state: 'SPARE', binId: null, createdAt: daysAgo(10), partModelId: 'pm' },
{ id: 'p2', state: 'SPARE', binId: null, createdAt: daysAgo(60), partModelId: 'pm' },
{ id: 'p3', state: 'SPARE', binId: null, createdAt: daysAgo(400), partModelId: 'pm' },
{ id: 'p4', state: 'SPARE', binId: null, createdAt: daysAgo(900), partModelId: 'pm' },
],
openRepairs: 0,
eolManufacturers: [],
eolPartModels: [],
bins: [],
});
@@ -98,13 +104,13 @@ describe('analytics.dashboard', () => {
partCount: 4,
stateRows: [],
parts: [
{ id: '1', state: 'SPARE', binId: 'b1', createdAt: daysAgo(1), manufacturerId: 'm' },
{ id: '2', state: 'SPARE', binId: 'b1', createdAt: daysAgo(1), manufacturerId: 'm' },
{ id: '3', state: 'SPARE', binId: 'b2', createdAt: daysAgo(1), manufacturerId: 'm' },
{ id: '4', state: 'SPARE', binId: null, createdAt: daysAgo(1), manufacturerId: 'm' },
{ id: '1', state: 'SPARE', binId: 'b1', createdAt: daysAgo(1), partModelId: 'pm' },
{ id: '2', state: 'SPARE', binId: 'b1', createdAt: daysAgo(1), partModelId: 'pm' },
{ id: '3', state: 'SPARE', binId: 'b2', createdAt: daysAgo(1), partModelId: 'pm' },
{ id: '4', state: 'SPARE', binId: null, createdAt: daysAgo(1), partModelId: 'pm' },
],
openRepairs: 0,
eolManufacturers: [],
eolPartModels: [],
bins: [
{ id: 'b1', name: 'A1', room: { name: 'Lab', site: { name: 'HQ' } } },
{ id: 'b2', name: 'B2', room: { name: 'Lab', site: { name: 'HQ' } } },
@@ -118,27 +124,53 @@ describe('analytics.dashboard', () => {
]);
});
it('flags manufacturers whose EOL has passed and have deployed parts', async () => {
it('flags part models whose EOL has passed and have deployed parts', async () => {
const tx = makeTx({
partCount: 3,
stateRows: [],
parts: [
{ id: '1', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), manufacturerId: 'm1' },
{ id: '2', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), manufacturerId: 'm1' },
{ id: '3', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), manufacturerId: 'm2' },
{ id: '1', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm1' },
{ id: '2', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm1' },
{ id: '3', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm2' },
],
openRepairs: 0,
eolManufacturers: [
{ id: 'm1', name: 'Acme', eolDate: daysAgo(30) },
{ id: 'm2', name: 'Beta', eolDate: daysAgo(10) },
{ id: 'm3', name: 'Gamma', eolDate: daysAgo(5) },
eolPartModels: [
{
id: 'pm1',
mpn: 'ACM-100',
eolDate: daysAgo(30),
manufacturerId: 'm1',
manufacturer: { name: 'Acme' },
},
{
id: 'pm2',
mpn: 'BET-200',
eolDate: daysAgo(10),
manufacturerId: 'm2',
manufacturer: { name: 'Beta' },
},
{
id: 'pm3',
mpn: 'GAM-300',
eolDate: daysAgo(5),
manufacturerId: 'm3',
manufacturer: { name: 'Gamma' },
},
],
bins: [],
});
const r = await dashboard(tx);
expect(r.deployedPastEol.map((m) => m.name)).toEqual(['Acme', 'Beta']);
expect(r.deployedPastEol[0]).toMatchObject({ manufacturerId: 'm1', deployedCount: 2 });
expect(r.deployedPastEol[1]).toMatchObject({ manufacturerId: 'm2', deployedCount: 1 });
expect(r.deployedPastEol.map((m) => m.mpn)).toEqual(['ACM-100', 'BET-200']);
expect(r.deployedPastEol[0]).toMatchObject({
partModelId: 'pm1',
manufacturerName: 'Acme',
deployedCount: 2,
});
expect(r.deployedPastEol[1]).toMatchObject({
partModelId: 'pm2',
manufacturerName: 'Beta',
deployedCount: 1,
});
});
});
+19 -11
View File
@@ -13,7 +13,7 @@ const AGE_BUCKETS: { label: string; maxDays: number | null }[] = [
];
export async function dashboard(tx: Tx): Promise<DashboardAnalytics> {
const [totalParts, stateRows, parts, openRepairs, manufacturersWithEol] = await Promise.all([
const [totalParts, stateRows, parts, openRepairs, partModelsWithEol] = await Promise.all([
tx.part.count(),
tx.part.groupBy({
by: ['state'],
@@ -21,12 +21,18 @@ export async function dashboard(tx: Tx): Promise<DashboardAnalytics> {
_sum: { price: true },
}),
tx.part.findMany({
select: { id: true, state: true, binId: true, createdAt: true, manufacturerId: true },
select: { id: true, state: true, binId: true, createdAt: true, partModelId: true },
}),
tx.repairJob.count({ where: { status: { in: ['PENDING', 'IN_PROGRESS'] } } }),
tx.manufacturer.findMany({
tx.partModel.findMany({
where: { eolDate: { not: null, lte: new Date() } },
select: { id: true, name: true, eolDate: true },
select: {
id: true,
mpn: true,
eolDate: true,
manufacturerId: true,
manufacturer: { select: { name: true } },
},
}),
]);
@@ -69,17 +75,19 @@ export async function dashboard(tx: Tx): Promise<DashboardAnalytics> {
count: binCounts.get(id) ?? 0,
}));
const deployedByMfg = new Map<string, number>();
const deployedByModel = new Map<string, number>();
for (const part of parts) {
if (part.state !== 'DEPLOYED') continue;
deployedByMfg.set(part.manufacturerId, (deployedByMfg.get(part.manufacturerId) ?? 0) + 1);
deployedByModel.set(part.partModelId, (deployedByModel.get(part.partModelId) ?? 0) + 1);
}
const deployedPastEol = manufacturersWithEol
const deployedPastEol = partModelsWithEol
.map((m) => ({
manufacturerId: m.id,
name: m.name,
eolDate: m.eolDate ? m.eolDate.toISOString() : null,
deployedCount: deployedByMfg.get(m.id) ?? 0,
partModelId: m.id,
mpn: m.mpn,
manufacturerId: m.manufacturerId,
manufacturerName: m.manufacturer.name,
eolDate: m.eolDate ? m.eolDate.toISOString() : '',
deployedCount: deployedByModel.get(m.id) ?? 0,
}))
.filter((m) => m.deployedCount > 0)
.sort((a, b) => b.deployedCount - a.deployedCount);
+21 -4
View File
@@ -7,12 +7,18 @@ import type {
import { errors } from '../lib/http-error.js';
import type { Tx } from './types.js';
function mapUniqueViolation(target: unknown): string {
if (Array.isArray(target) && target.includes('assetId')) return 'Asset ID already in use';
return 'Host name already exists';
}
export async function list(tx: Tx, q: HostListQuery) {
const { page, pageSize, q: search } = q;
const where: Prisma.HostWhereInput = search
? {
OR: [
{ name: { contains: search } },
{ assetId: { contains: search } },
{ location: { contains: search } },
],
}
@@ -33,10 +39,19 @@ export function get(tx: Tx, id: string) {
return tx.host.findUnique({ where: { id } });
}
export function listDeployedParts(tx: Tx, hostId: string) {
return tx.part.findMany({
where: { hostId, state: 'DEPLOYED' },
orderBy: { serialNumber: 'asc' },
include: { partModel: true, manufacturer: true },
});
}
export async function create(tx: Tx, input: CreateHostRequest) {
try {
return await tx.host.create({
data: {
assetId: input.assetId,
name: input.name,
location: input.location ?? null,
notes: input.notes ?? null,
@@ -44,7 +59,7 @@ export async function create(tx: Tx, input: CreateHostRequest) {
});
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
throw errors.conflict('Host name already exists');
throw errors.conflict(mapUniqueViolation(err.meta?.target));
}
throw err;
}
@@ -52,6 +67,7 @@ export async function create(tx: Tx, input: CreateHostRequest) {
export async function update(tx: Tx, id: string, input: UpdateHostRequest) {
const data: Prisma.HostUpdateInput = {};
if (input.assetId !== undefined) data.assetId = input.assetId;
if (input.name !== undefined) data.name = input.name;
if (input.location !== undefined) data.location = input.location;
if (input.notes !== undefined) data.notes = input.notes;
@@ -60,7 +76,7 @@ export async function update(tx: Tx, id: string, input: UpdateHostRequest) {
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2025') throw errors.notFound('Host');
if (err.code === 'P2002') throw errors.conflict('Host name already exists');
if (err.code === 'P2002') throw errors.conflict(mapUniqueViolation(err.meta?.target));
}
throw err;
}
@@ -70,8 +86,9 @@ export async function remove(tx: Tx, id: string) {
try {
await tx.host.delete({ where: { id } });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
throw errors.notFound('Host');
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2025') throw errors.notFound('Host');
if (err.code === 'P2003') throw errors.conflict('Cannot delete: host has repairs assigned');
}
throw err;
}
+1 -7
View File
@@ -22,12 +22,7 @@ export async function list(tx: Tx, q: PaginationQuery) {
export async function create(tx: Tx, input: CreateManufacturerRequest) {
try {
return await tx.manufacturer.create({
data: {
name: input.name,
eolDate: input.eolDate ? new Date(input.eolDate) : null,
},
});
return await tx.manufacturer.create({ data: { name: input.name } });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
throw errors.conflict('Manufacturer already exists');
@@ -40,7 +35,6 @@ export async function update(tx: Tx, id: string, input: UpdateManufacturerReques
try {
const data: Prisma.ManufacturerUpdateInput = {};
if (input.name !== undefined) data.name = input.name;
if (input.eolDate !== undefined) data.eolDate = input.eolDate ? new Date(input.eolDate) : null;
return await tx.manufacturer.update({ where: { id }, data });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
+132
View File
@@ -0,0 +1,132 @@
import { Prisma } from '@vector/db';
import type {
CreatePartModelRequest,
PartModelListQuery,
UpdatePartModelRequest,
} from '@vector/shared';
import { errors } from '../lib/http-error.js';
import type { Tx } from './types.js';
const partModelInclude = {
manufacturer: true,
_count: { select: { parts: true } },
} satisfies Prisma.PartModelInclude;
export async function list(tx: Tx, q: PartModelListQuery) {
const { page, pageSize, manufacturerId, q: search, eolBefore } = q;
const where: Prisma.PartModelWhereInput = {};
if (manufacturerId) where.manufacturerId = manufacturerId;
if (search) where.mpn = { contains: search };
if (eolBefore) where.eolDate = { lte: new Date(eolBefore) };
const [data, total] = await Promise.all([
tx.partModel.findMany({
where,
orderBy: [{ manufacturer: { name: 'asc' } }, { mpn: 'asc' }],
include: partModelInclude,
skip: (page - 1) * pageSize,
take: pageSize,
}),
tx.partModel.count({ where }),
]);
return { data, page, pageSize, total };
}
export function get(tx: Tx, id: string) {
return tx.partModel.findUnique({ where: { id }, include: partModelInclude });
}
export async function create(tx: Tx, input: CreatePartModelRequest) {
try {
return await tx.partModel.create({
data: {
manufacturerId: input.manufacturerId,
mpn: input.mpn,
eolDate: input.eolDate ? new Date(input.eolDate) : null,
notes: input.notes ?? null,
},
include: partModelInclude,
});
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2002') throw errors.conflict('MPN already exists for this manufacturer');
if (err.code === 'P2003') throw errors.badRequest('Manufacturer does not exist');
}
throw err;
}
}
export async function update(tx: Tx, id: string, input: UpdatePartModelRequest) {
const data: Prisma.PartModelUpdateInput = {};
if (input.manufacturerId !== undefined) {
data.manufacturer = { connect: { id: input.manufacturerId } };
}
if (input.mpn !== undefined) data.mpn = input.mpn;
if (input.eolDate !== undefined) {
data.eolDate = input.eolDate ? new Date(input.eolDate) : null;
}
if (input.notes !== undefined) data.notes = input.notes;
try {
return await tx.partModel.update({ where: { id }, data, include: partModelInclude });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2025') throw errors.notFound('Part model');
if (err.code === 'P2002') throw errors.conflict('MPN already exists for this manufacturer');
}
throw err;
}
}
export async function remove(tx: Tx, id: string) {
try {
await tx.partModel.delete({ where: { id } });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2025') throw errors.notFound('Part model');
if (err.code === 'P2003') {
throw errors.conflict('Cannot delete: part model has parts assigned');
}
}
throw err;
}
}
// Returns an existing PartModel for (manufacturerId, mpn) or creates one on the fly.
// Used by the parts service so a create/update with { manufacturerId, mpn } shorthand
// transparently provisions a catalog row.
export async function upsertByMpn(
tx: Tx,
input: { manufacturerId: string; mpn: string },
) {
const existing = await tx.partModel.findUnique({
where: {
manufacturerId_mpn: {
manufacturerId: input.manufacturerId,
mpn: input.mpn,
},
},
});
if (existing) return existing;
try {
return await tx.partModel.create({
data: { manufacturerId: input.manufacturerId, mpn: input.mpn },
});
} catch (err) {
// Lost the race; fetch the row that won.
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
const winner = await tx.partModel.findUnique({
where: {
manufacturerId_mpn: {
manufacturerId: input.manufacturerId,
mpn: input.mpn,
},
},
});
if (winner) return winner;
}
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2003') {
throw errors.badRequest('Manufacturer does not exist');
}
throw err;
}
}
+74 -34
View File
@@ -6,13 +6,16 @@ import type {
UpdatePartRequest,
} from '@vector/shared';
import { errors } from '../lib/http-error.js';
import * as partModelsSvc from './part-models.js';
import * as tagsSvc from './tags.js';
import type { Actor, Tx } from './types.js';
const partInclude = {
manufacturer: true,
partModel: true,
bin: { include: { room: { include: { site: true } } } },
category: true,
host: true,
tags: { include: { tag: true } },
} satisfies Prisma.PartInclude;
@@ -40,26 +43,51 @@ function flattenTags(part: PartWithRelations): PartWithPath {
return out;
}
// Resolves a CreatePartRequest's partModelId — either the explicit one passed in, or looked up
// via (manufacturerId, mpn) shorthand that auto-creates a PartModel catalog row on first use.
// Exactly one of those two forms is required; the zod schema enforces that at the boundary.
async function resolvePartModel(
tx: Tx,
input: { partModelId?: string; manufacturerId?: string; mpn?: string },
): Promise<{ partModelId: string; manufacturerId: string }> {
if (input.partModelId) {
const pm = await tx.partModel.findUnique({ where: { id: input.partModelId } });
if (!pm) throw errors.badRequest('Part model does not exist');
return { partModelId: pm.id, manufacturerId: pm.manufacturerId };
}
if (input.manufacturerId && input.mpn) {
const pm = await partModelsSvc.upsertByMpn(tx, {
manufacturerId: input.manufacturerId,
mpn: input.mpn,
});
return { partModelId: pm.id, manufacturerId: pm.manufacturerId };
}
throw errors.badRequest('Provide partModelId or both manufacturerId and mpn');
}
function buildWhere(q: PartListQuery): Prisma.PartWhereInput {
const where: Prisma.PartWhereInput = {};
if (q.state) where.state = q.state;
if (q.binId) where.binId = q.binId;
if (q.hostId) where.hostId = q.hostId;
if (q.manufacturerId) where.manufacturerId = q.manufacturerId;
if (q.partModelId) where.partModelId = q.partModelId;
if (q.categoryId) where.categoryId = q.categoryId;
if (q.mpn) where.mpn = { contains: q.mpn };
if (q.serialNumber) where.serialNumber = { contains: q.serialNumber };
if (q.q) {
where.OR = [
{ serialNumber: { contains: q.q } },
{ mpn: { contains: q.q } },
{ partModel: { mpn: { contains: q.q } } },
{ notes: { contains: q.q } },
];
}
if (q.tagId) where.tags = { some: { tagId: q.tagId } };
if (q.eolOnly) {
// Parts attached to a manufacturer with an EOL date that has already passed.
where.manufacturer = { eolDate: { lt: new Date() } };
}
const partModelFilter: Prisma.PartModelWhereInput = {};
if (q.mpn) partModelFilter.mpn = { contains: q.mpn };
if (q.eolOnly) partModelFilter.eolDate = { lt: new Date() };
if (Object.keys(partModelFilter).length > 0) where.partModel = partModelFilter;
return where;
}
@@ -89,17 +117,23 @@ export async function create(
input: CreatePartRequest,
actor: Actor | null,
): Promise<PartWithPath> {
const { partModelId, manufacturerId } = await resolvePartModel(tx, input);
// If caller also supplied manufacturerId explicitly, it must match the part model's.
if (input.manufacturerId && input.manufacturerId !== manufacturerId) {
throw errors.badRequest('manufacturerId does not match the selected part model');
}
try {
const p = await tx.part.create({
data: {
serialNumber: input.serialNumber,
mpn: input.mpn,
manufacturerId: input.manufacturerId,
partModelId,
manufacturerId,
price: input.price ?? null,
state: input.state ?? 'SPARE',
binId: input.binId ?? null,
hostId: input.hostId ?? null,
categoryId: input.categoryId ?? null,
replacementPartId: input.replacementPartId ?? null,
notes: input.notes ?? null,
},
include: partInclude,
@@ -136,25 +170,26 @@ export async function update(
const data: Prisma.PartUpdateInput = {};
if (input.serialNumber !== undefined) data.serialNumber = input.serialNumber;
if (input.mpn !== undefined) data.mpn = input.mpn;
if (input.manufacturerId !== undefined) {
data.manufacturer = { connect: { id: input.manufacturerId } };
if (input.partModelId !== undefined) {
const pm = await tx.partModel.findUnique({ where: { id: input.partModelId } });
if (!pm) throw errors.badRequest('Part model does not exist');
data.partModel = { connect: { id: pm.id } };
// Keep denormalized manufacturerId consistent with the chosen model.
data.manufacturer = { connect: { id: pm.manufacturerId } };
}
if (input.price !== undefined) data.price = input.price;
if (input.state !== undefined) data.state = input.state;
if (input.binId !== undefined) {
data.bin = input.binId ? { connect: { id: input.binId } } : { disconnect: true };
}
if (input.hostId !== undefined) {
data.host = input.hostId ? { connect: { id: input.hostId } } : { disconnect: true };
}
if (input.categoryId !== undefined) {
data.category = input.categoryId
? { connect: { id: input.categoryId } }
: { disconnect: true };
}
if (input.replacementPartId !== undefined) {
data.replacement = input.replacementPartId
? { connect: { id: input.replacementPartId } }
: { disconnect: true };
}
if (input.notes !== undefined) data.notes = input.notes;
let part: PartWithRelations;
@@ -191,14 +226,24 @@ export async function update(
newValue: binPath(part.bin),
});
}
if (input.mpn !== undefined && input.mpn !== current.mpn) {
if (input.hostId !== undefined && input.hostId !== current.hostId) {
events.push({
partId: part.id,
userId,
type: 'LOCATION_CHANGED',
field: 'host',
oldValue: current.host?.name ?? null,
newValue: part.host?.name ?? null,
});
}
if (input.partModelId !== undefined && input.partModelId !== current.partModelId) {
events.push({
partId: part.id,
userId,
type: 'FIELD_UPDATED',
field: 'mpn',
oldValue: current.mpn,
newValue: input.mpn,
field: 'partModel',
oldValue: current.partModel.mpn,
newValue: part.partModel.mpn,
});
}
if (input.serialNumber !== undefined && input.serialNumber !== current.serialNumber) {
@@ -211,16 +256,6 @@ export async function update(
newValue: input.serialNumber,
});
}
if (input.manufacturerId !== undefined && input.manufacturerId !== current.manufacturerId) {
events.push({
partId: part.id,
userId,
type: 'FIELD_UPDATED',
field: 'manufacturer',
oldValue: current.manufacturer.name,
newValue: part.manufacturer.name,
});
}
if (input.categoryId !== undefined && input.categoryId !== current.categoryId) {
events.push({
partId: part.id,
@@ -267,8 +302,11 @@ export async function remove(tx: Tx, id: string) {
try {
await tx.part.delete({ where: { id } });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
throw errors.notFound('Part');
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2025') throw errors.notFound('Part');
if (err.code === 'P2003') {
throw errors.conflict('Cannot delete: part is referenced by a repair');
}
}
throw err;
}
@@ -295,6 +333,7 @@ export interface BulkPartsInput {
ids: string[];
state?: CreatePartRequest['state'];
binId?: string | null;
hostId?: string | null;
addTagIds?: string[];
removeTagIds?: string[];
}
@@ -312,12 +351,13 @@ export async function bulkUpdate(tx: Tx, input: BulkPartsInput, actor: Actor | n
const patch: UpdatePartRequest = {};
if (input.state !== undefined) patch.state = input.state;
if (input.binId !== undefined) patch.binId = input.binId;
if (input.hostId !== undefined) patch.hostId = input.hostId;
if (Object.keys(patch).length > 0) {
await update(tx, id, patch, actor);
}
if (input.addTagIds || input.removeTagIds) {
const existing = await tx.partTag.findMany({ where: { partId: id }, select: { tagId: true } });
let next = new Set(existing.map((r) => r.tagId));
const next = new Set(existing.map((r) => r.tagId));
(input.addTagIds ?? []).forEach((t) => next.add(t));
(input.removeTagIds ?? []).forEach((t) => next.delete(t));
await tagsSvc.setPartTags(tx, id, [...next], actor);
+225
View File
@@ -0,0 +1,225 @@
import { describe, expect, it, vi } from 'vitest';
import { Prisma } from '@vector/db';
import type { Tx, Actor } from './types.js';
import { create, addComment, listComments } from './repairs.js';
import * as partModels from './part-models.js';
import * as hosts from './hosts.js';
import { AppError } from '../lib/http-error.js';
const actor: Actor = { id: 'user-1', username: 'tech', role: 'ADMIN' };
// Fabricate a minimal Prisma.PrismaClientKnownRequestError without requiring a live client.
function prismaError(code: string, meta?: Record<string, unknown>) {
return new Prisma.PrismaClientKnownRequestError('simulated', {
code,
clientVersion: 'test',
meta,
});
}
describe('repairs.create — host membership', () => {
it('rejects a problem-part that is not on the chosen host', async () => {
const partEventCreateMany = vi.fn();
const repairCreate = vi.fn();
const tx = {
host: { findUnique: async () => ({ id: 'host-1' }) },
part: {
findMany: async () => [{ id: 'part-a', hostId: 'host-2' }],
},
repairJob: { create: repairCreate },
partEvent: { createMany: partEventCreateMany },
} as unknown as Tx;
await expect(
create(
tx,
{ hostId: 'host-1', problem: 'fan noise', problemPartIds: ['part-a'] },
actor,
),
).rejects.toMatchObject({ status: 400 });
expect(repairCreate).not.toHaveBeenCalled();
expect(partEventCreateMany).not.toHaveBeenCalled();
});
it('succeeds with empty problemPartIds and emits no REPAIR_STARTED events', async () => {
const partEventCreateMany = vi.fn();
const repairCreate = vi.fn(async () => ({
id: 'repair-1',
hostId: 'host-1',
problem: 'power drops',
status: 'PENDING',
problemParts: [],
}));
const tx = {
host: { findUnique: async () => ({ id: 'host-1' }) },
part: { findMany: async () => [] },
repairJob: { create: repairCreate },
partEvent: { createMany: partEventCreateMany },
} as unknown as Tx;
const r = await create(tx, { hostId: 'host-1', problem: 'power drops' }, actor);
expect(r.id).toBe('repair-1');
expect(repairCreate).toHaveBeenCalledOnce();
expect(partEventCreateMany).not.toHaveBeenCalled();
});
});
describe('part-models.upsertByMpn', () => {
it('returns the existing row without creating when (manufacturerId, mpn) is taken', async () => {
const existing = { id: 'pm-1', manufacturerId: 'mfr-1', mpn: 'WD-1000', eolDate: null };
const create = vi.fn();
const tx = {
partModel: {
findUnique: async () => existing,
create,
},
} as unknown as Tx;
const r1 = await partModels.upsertByMpn(tx, { manufacturerId: 'mfr-1', mpn: 'WD-1000' });
const r2 = await partModels.upsertByMpn(tx, { manufacturerId: 'mfr-1', mpn: 'WD-1000' });
expect(r1).toBe(existing);
expect(r2).toBe(existing);
expect(create).not.toHaveBeenCalled();
});
it('recovers from a race by re-fetching the winning row on P2002', async () => {
const winner = { id: 'pm-9', manufacturerId: 'mfr-1', mpn: 'WD-1000', eolDate: null };
let findCall = 0;
const tx = {
partModel: {
findUnique: async () => {
findCall += 1;
if (findCall === 1) return null;
return winner;
},
create: async () => {
throw prismaError('P2002');
},
},
} as unknown as Tx;
const r = await partModels.upsertByMpn(tx, { manufacturerId: 'mfr-1', mpn: 'WD-1000' });
expect(r).toBe(winner);
});
});
describe('hosts.create — assetId uniqueness', () => {
it('surfaces a P2002 on assetId as a 409 with the Asset ID message', async () => {
const tx = {
host: {
create: async () => {
throw prismaError('P2002', { target: ['assetId'] });
},
},
} as unknown as Tx;
await expect(
hosts.create(tx, { assetId: 'ASSET-001', name: 'rack-1' }),
).rejects.toMatchObject({ status: 409, message: 'Asset ID already in use' });
});
it('falls through to the name-uniqueness message for other unique targets', async () => {
const tx = {
host: {
create: async () => {
throw prismaError('P2002', { target: ['name'] });
},
},
} as unknown as Tx;
await expect(
hosts.create(tx, { assetId: 'ASSET-002', name: 'rack-1' }),
).rejects.toMatchObject({ status: 409, message: 'Host name already exists' });
});
});
describe('repairs.addComment / listComments', () => {
it('stamps userId from the actor and returns it via listComments', async () => {
const stored: {
id: string;
repairJobId: string;
userId: string | null;
content: string;
createdAt: Date;
user: { id: string; username: string } | null;
}[] = [];
let nextId = 1;
const tx = {
repairJob: {
findUnique: async ({ include }: { include?: unknown }) => {
if (include) return { id: 'repair-1', problemParts: [] };
return { id: 'repair-1' };
},
},
repairComment: {
create: async ({ data }: { data: { repairJobId: string; userId: string | null; content: string } }) => {
const row = {
id: `c-${nextId++}`,
repairJobId: data.repairJobId,
userId: data.userId,
content: data.content,
createdAt: new Date(),
user: data.userId ? { id: data.userId, username: actor.username } : null,
};
stored.push(row);
return row;
},
findMany: async () => stored,
count: async () => stored.length,
},
partEvent: { createMany: vi.fn() },
} as unknown as Tx;
const created = await addComment(tx, 'repair-1', { content: 'Checked fans' }, actor);
expect(created.userId).toBe(actor.id);
const page = await listComments(tx, 'repair-1', { page: 1, pageSize: 20 });
expect(page.total).toBe(1);
expect(page.data[0]?.userId).toBe(actor.id);
expect(page.data[0]?.content).toBe('Checked fans');
});
it('emits a REPAIR_COMMENTED PartEvent for each problem part', async () => {
const partEventCreateMany = vi.fn();
const tx = {
repairJob: {
findUnique: async () => ({
id: 'repair-1',
problemParts: [{ partId: 'part-a' }, { partId: 'part-b' }],
}),
},
repairComment: {
create: async ({ data }: { data: { repairJobId: string; userId: string | null; content: string } }) => ({
id: 'c-1',
...data,
createdAt: new Date(),
user: null,
}),
},
partEvent: { createMany: partEventCreateMany },
} as unknown as Tx;
await addComment(tx, 'repair-1', { content: 'ping' }, actor);
expect(partEventCreateMany).toHaveBeenCalledOnce();
const call = partEventCreateMany.mock.calls[0]![0] as {
data: { partId: string; type: string; userId: string | null }[];
};
expect(call.data.map((d) => d.partId)).toEqual(['part-a', 'part-b']);
expect(call.data.every((d) => d.type === 'REPAIR_COMMENTED')).toBe(true);
expect(call.data.every((d) => d.userId === actor.id)).toBe(true);
});
it('returns 404 when the repair does not exist', async () => {
const tx = {
repairJob: { findUnique: async () => null },
} as unknown as Tx;
await expect(
addComment(tx, 'missing', { content: 'hi' }, actor),
).rejects.toBeInstanceOf(AppError);
});
});
+159 -29
View File
@@ -1,6 +1,8 @@
import { Prisma } from '@vector/db';
import type {
CreateRepairCommentRequest,
CreateRepairJobRequest,
RepairCommentListQuery,
RepairJobListQuery,
UpdateRepairJobRequest,
} from '@vector/shared';
@@ -8,21 +10,29 @@ import { errors } from '../lib/http-error.js';
import type { Actor, Tx } from './types.js';
const repairInclude = {
part: {
include: { manufacturer: true },
},
host: true,
assignee: { select: { id: true, username: true, email: true, role: true } },
problemParts: {
include: {
part: {
include: { partModel: true, manufacturer: true },
},
},
},
} satisfies Prisma.RepairJobInclude;
const commentInclude = {
user: { select: { id: true, username: true } },
} satisfies Prisma.RepairCommentInclude;
export async function list(tx: Tx, q: RepairJobListQuery) {
const { page, pageSize, status, partId, hostId, assigneeId, openOnly } = q;
const { page, pageSize, status, hostId, assigneeId, openOnly, problemPartId } = q;
const where: Prisma.RepairJobWhereInput = {};
if (status) where.status = status;
if (partId) where.partId = partId;
if (hostId) where.hostId = hostId;
if (assigneeId) where.assigneeId = assigneeId;
if (openOnly) where.status = { in: ['PENDING', 'IN_PROGRESS'] };
if (problemPartId) where.problemParts = { some: { partId: problemPartId } };
const [data, total] = await Promise.all([
tx.repairJob.findMany({
@@ -41,45 +51,73 @@ export function get(tx: Tx, id: string) {
return tx.repairJob.findUnique({ where: { id }, include: repairInclude });
}
export function listForPart(tx: Tx, partId: string) {
export function listForHost(tx: Tx, hostId: string) {
return tx.repairJob.findMany({
where: { partId },
where: { hostId },
orderBy: { openedAt: 'desc' },
include: repairInclude,
});
}
// Validates that the submitted problem-part ids are all attached to the named host.
// Parts that aren't on the host, or that don't exist, cause the whole repair create/update to fail
// — no silent skipping. Parts can be in any state (a repair can target a SPARE that was tagged as
// faulty during intake); the host-membership check is what matters.
async function validateProblemParts(tx: Tx, hostId: string, partIds: string[] | undefined) {
if (!partIds || partIds.length === 0) return;
const uniqueIds = [...new Set(partIds)];
const rows = await tx.part.findMany({
where: { id: { in: uniqueIds } },
select: { id: true, hostId: true },
});
const found = new Map(rows.map((r) => [r.id, r]));
for (const id of uniqueIds) {
const row = found.get(id);
if (!row) throw errors.badRequest(`Part ${id} does not exist`);
if (row.hostId !== hostId) {
throw errors.badRequest(`Part ${id} is not on the selected host`);
}
}
}
export async function create(
tx: Tx,
input: CreateRepairJobRequest,
actor: Actor | null,
) {
const part = await tx.part.findUnique({ where: { id: input.partId } });
if (!part) throw errors.notFound('Part');
const host = await tx.host.findUnique({ where: { id: input.hostId } });
if (!host) throw errors.notFound('Host');
await validateProblemParts(tx, input.hostId, input.problemPartIds);
try {
const repair = await tx.repairJob.create({
data: {
partId: input.partId,
hostId: input.hostId ?? null,
hostId: input.hostId,
assigneeId: input.assigneeId ?? null,
notes: input.notes ?? null,
problem: input.problem,
status: 'PENDING',
problemParts: input.problemPartIds && input.problemPartIds.length > 0
? { create: [...new Set(input.problemPartIds)].map((partId) => ({ partId })) }
: undefined,
},
include: repairInclude,
});
await tx.partEvent.create({
data: {
partId: part.id,
if (input.problemPartIds && input.problemPartIds.length > 0) {
await tx.partEvent.createMany({
data: [...new Set(input.problemPartIds)].map((partId) => ({
partId,
userId: actor?.id ?? null,
type: 'REPAIR_STARTED',
newValue: repair.id,
},
})),
});
}
return repair;
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2003') {
throw errors.badRequest('Invalid host or assignee id');
throw errors.badRequest('Invalid host, assignee, or part id');
}
throw err;
}
@@ -91,21 +129,25 @@ export async function update(
input: UpdateRepairJobRequest,
actor: Actor | null,
) {
const current = await tx.repairJob.findUnique({ where: { id } });
const current = await tx.repairJob.findUnique({
where: { id },
include: { problemParts: { select: { partId: true } }, host: true },
});
if (!current) throw errors.notFound('Repair');
const data: Prisma.RepairJobUpdateInput = {};
let terminalTransition: 'COMPLETED' | 'CANCELLED' | null = null;
if (input.status !== undefined && input.status !== current.status) {
data.status = input.status;
// closedAt follows terminal status transitions.
const nowTerminal = input.status === 'COMPLETED' || input.status === 'CANCELLED';
const wasTerminal = current.status === 'COMPLETED' || current.status === 'CANCELLED';
if (nowTerminal && !wasTerminal) data.closedAt = new Date();
if (nowTerminal && !wasTerminal) {
data.closedAt = new Date();
terminalTransition = input.status as 'COMPLETED' | 'CANCELLED';
}
if (!nowTerminal && wasTerminal) data.closedAt = null;
}
if (input.hostId !== undefined) {
data.host = input.hostId ? { connect: { id: input.hostId } } : { disconnect: true };
}
if (input.problem !== undefined) data.problem = input.problem;
if (input.assigneeId !== undefined) {
data.assignee = input.assigneeId
? { connect: { id: input.assigneeId } }
@@ -113,22 +155,56 @@ export async function update(
}
if (input.notes !== undefined) data.notes = input.notes;
// Problem-parts follow full-replace semantics: the request carries the final desired set.
let addedPartIds: string[] = [];
if (input.problemPartIds !== undefined) {
await validateProblemParts(tx, current.hostId, input.problemPartIds);
const existing = new Set(current.problemParts.map((p) => p.partId));
const desired = new Set(input.problemPartIds);
addedPartIds = [...desired].filter((p) => !existing.has(p));
const removed = [...existing].filter((p) => !desired.has(p));
if (removed.length > 0) {
await tx.repairJobPart.deleteMany({
where: { repairJobId: id, partId: { in: removed } },
});
}
if (addedPartIds.length > 0) {
await tx.repairJobPart.createMany({
data: addedPartIds.map((partId) => ({ repairJobId: id, partId })),
});
}
}
const repair = await tx.repairJob.update({
where: { id },
data,
include: repairInclude,
});
if (input.status === 'COMPLETED' && current.status !== 'COMPLETED') {
await tx.partEvent.create({
data: {
partId: repair.partId,
userId: actor?.id ?? null,
type: 'REPAIR_COMPLETED',
const userId = actor?.id ?? null;
if (addedPartIds.length > 0) {
await tx.partEvent.createMany({
data: addedPartIds.map((partId) => ({
partId,
userId,
type: 'REPAIR_STARTED',
newValue: repair.id,
},
})),
});
}
if (terminalTransition !== null) {
const partIds = repair.problemParts.map((p) => p.partId);
if (partIds.length > 0) {
await tx.partEvent.createMany({
data: partIds.map((partId) => ({
partId,
userId,
type: terminalTransition === 'COMPLETED' ? 'REPAIR_COMPLETED' : 'REPAIR_CANCELLED',
newValue: repair.id,
})),
});
}
}
return repair;
}
@@ -143,3 +219,57 @@ export async function remove(tx: Tx, id: string) {
throw err;
}
}
export async function listComments(tx: Tx, repairJobId: string, q: RepairCommentListQuery) {
const { page, pageSize } = q;
const repair = await tx.repairJob.findUnique({ where: { id: repairJobId }, select: { id: true } });
if (!repair) throw errors.notFound('Repair');
const [data, total] = await Promise.all([
tx.repairComment.findMany({
where: { repairJobId },
orderBy: { createdAt: 'asc' },
include: commentInclude,
skip: (page - 1) * pageSize,
take: pageSize,
}),
tx.repairComment.count({ where: { repairJobId } }),
]);
return { data, page, pageSize, total };
}
export async function addComment(
tx: Tx,
repairJobId: string,
input: CreateRepairCommentRequest,
actor: Actor | null,
) {
const repair = await tx.repairJob.findUnique({
where: { id: repairJobId },
include: { problemParts: { select: { partId: true } } },
});
if (!repair) throw errors.notFound('Repair');
const comment = await tx.repairComment.create({
data: {
repairJobId,
userId: actor?.id ?? null,
content: input.content,
},
include: commentInclude,
});
// Surface the comment on each problem-part's timeline so a part owner sees the activity
// without having to navigate through to the repair.
if (repair.problemParts.length > 0) {
await tx.partEvent.createMany({
data: repair.problemParts.map((p) => ({
partId: p.partId,
userId: actor?.id ?? null,
type: 'REPAIR_COMMENTED',
newValue: repair.id,
})),
});
}
return comment;
}
+4
View File
@@ -12,7 +12,9 @@ import Parts from './pages/Parts.js';
import PartDetail from './pages/PartDetail.js';
import Locations from './pages/Locations.js';
import Manufacturers from './pages/Manufacturers.js';
import PartModels from './pages/PartModels.js';
import Repairs from './pages/Repairs.js';
import RepairDetail from './pages/RepairDetail.js';
import Hosts from './pages/Hosts.js';
import Users from './pages/admin/Users.js';
import Webhooks from './pages/admin/Webhooks.js';
@@ -54,7 +56,9 @@ export default function App() {
<Route path="/parts/:id" element={<PartDetail />} />
<Route path="/locations" element={<Locations />} />
<Route path="/manufacturers" element={<Manufacturers />} />
<Route path="/part-models" element={<PartModels />} />
<Route path="/repairs" element={<Repairs />} />
<Route path="/repairs/:id" element={<RepairDetail />} />
<Route path="/hosts" element={<Hosts />} />
<Route
path="/admin/users"
@@ -28,6 +28,7 @@ import { queryKeys } from '../../lib/queryKeys.js';
import type { Host } from '../../lib/api/types.js';
const Schema = z.object({
assetId: z.string().trim().min(1, 'Required').max(64),
name: z.string().min(1, 'Required').max(128),
location: z.string().max(256).optional(),
notes: z.string().max(4096).optional(),
@@ -46,12 +47,13 @@ export function HostFormDialog({ open, onOpenChange, host }: HostFormDialogProps
const form = useForm<Values>({
resolver: zodResolver(Schema),
defaultValues: { name: '', location: '', notes: '' },
defaultValues: { assetId: '', name: '', location: '', notes: '' },
});
useEffect(() => {
if (!open) return;
form.reset({
assetId: host?.assetId ?? '',
name: host?.name ?? '',
location: host?.location ?? '',
notes: host?.notes ?? '',
@@ -60,12 +62,20 @@ export function HostFormDialog({ open, onOpenChange, host }: HostFormDialogProps
const mutation = useMutation({
mutationFn: async (values: Values) => {
const payload = {
if (editing && host) {
return updateHost(host.id, {
assetId: values.assetId,
name: values.name,
location: values.location ? values.location : null,
notes: values.notes ? values.notes : null,
};
return editing && host ? updateHost(host.id, payload) : createHost(payload);
});
}
return createHost({
assetId: values.assetId,
name: values.name,
location: values.location ? values.location : null,
notes: values.notes ? values.notes : null,
});
},
onSuccess: () => {
toast.success(editing ? 'Host updated' : 'Host created');
@@ -88,6 +98,19 @@ export function HostFormDialog({ open, onOpenChange, host }: HostFormDialogProps
<Form {...form}>
<form onSubmit={form.handleSubmit((v) => mutation.mutate(v))} className="space-y-3">
<FormField
control={form.control}
name="assetId"
render={({ field }) => (
<FormItem>
<FormLabel>Asset ID</FormLabel>
<FormControl>
<Input autoFocus placeholder="e.g. ASSET-001" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
@@ -95,7 +118,7 @@ export function HostFormDialog({ open, onOpenChange, host }: HostFormDialogProps
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input autoFocus {...field} />
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -4,6 +4,7 @@ import {
ChevronsLeft,
ChevronsRight,
LayoutDashboard,
Layers,
type LucideIcon,
MapPinned,
Package,
@@ -25,6 +26,7 @@ interface NavItem {
const NAV_ITEMS: NavItem[] = [
{ to: '/', label: 'Dashboard', icon: LayoutDashboard },
{ to: '/parts', label: 'Parts', icon: Package },
{ to: '/part-models', label: 'Part models', icon: Layers },
{ to: '/locations', label: 'Locations', icon: MapPinned },
{ to: '/manufacturers', label: 'Manufacturers', icon: Boxes },
{ to: '/repairs', label: 'Repairs', icon: Wrench },
@@ -15,7 +15,6 @@ import {
DialogTitle,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
@@ -32,11 +31,6 @@ import type { Manufacturer } from '../../lib/api/types.js';
const Schema = z.object({
name: z.string().min(1, 'Required').max(128),
eolDate: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, 'YYYY-MM-DD')
.or(z.literal(''))
.optional(),
});
type Values = z.infer<typeof Schema>;
@@ -46,11 +40,6 @@ interface ManufacturerFormDialogProps {
manufacturer?: Manufacturer | null;
}
function isoToDateInput(iso: string | null): string {
if (!iso) return '';
return new Date(iso).toISOString().slice(0, 10);
}
export function ManufacturerFormDialog({
open,
onOpenChange,
@@ -61,23 +50,17 @@ export function ManufacturerFormDialog({
const form = useForm<Values>({
resolver: zodResolver(Schema),
defaultValues: { name: '', eolDate: '' },
defaultValues: { name: '' },
});
useEffect(() => {
if (!open) return;
form.reset({
name: manufacturer?.name ?? '',
eolDate: isoToDateInput(manufacturer?.eolDate ?? null),
});
form.reset({ name: manufacturer?.name ?? '' });
}, [open, manufacturer, form]);
const mutation = useMutation({
mutationFn: async (values: Values) => {
const payload = {
name: values.name,
eolDate: values.eolDate ? values.eolDate : null,
};
const payload = { name: values.name };
return editing && manufacturer
? updateManufacturer(manufacturer.id, payload)
: createManufacturer(payload);
@@ -98,8 +81,8 @@ export function ManufacturerFormDialog({
<DialogTitle>{editing ? 'Edit manufacturer' : 'New manufacturer'}</DialogTitle>
<DialogDescription>
{editing
? 'Update this manufacturer. EOL drives replacement alerts on parts.'
: 'Add a manufacturer. Names must be unique.'}
? 'Update the manufacturer record.'
: 'Add a manufacturer. Names must be unique. EOL is tracked per part model.'}
</DialogDescription>
</DialogHeader>
@@ -118,23 +101,6 @@ export function ManufacturerFormDialog({
</FormItem>
)}
/>
<FormField
control={form.control}
name="eolDate"
render={({ field }) => (
<FormItem>
<FormLabel>End-of-life date</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormDescription>
Optional. Parts from this manufacturer will show a replacement alert past this
date.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
@@ -0,0 +1,207 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { z } from 'zod';
import { Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Textarea,
} from '@vector/ui';
import { createPartModel, updatePartModel } from '../../lib/api/part-models.js';
import { listManufacturers } from '../../lib/api/manufacturers.js';
import { ApiRequestError } from '../../lib/api/client.js';
import { queryKeys } from '../../lib/queryKeys.js';
import type { PartModel } from '../../lib/api/types.js';
const Schema = z.object({
manufacturerId: z.string().uuid('Pick a manufacturer'),
mpn: z.string().min(1, 'Required').max(128),
eolDate: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, 'YYYY-MM-DD')
.or(z.literal(''))
.optional(),
notes: z.string().max(4096).optional(),
});
type Values = z.infer<typeof Schema>;
function isoToDateInput(iso: string | null): string {
if (!iso) return '';
return new Date(iso).toISOString().slice(0, 10);
}
interface PartModelFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
partModel?: PartModel | null;
}
export function PartModelFormDialog({
open,
onOpenChange,
partModel,
}: PartModelFormDialogProps) {
const editing = Boolean(partModel);
const queryClient = useQueryClient();
const form = useForm<Values>({
resolver: zodResolver(Schema),
defaultValues: { manufacturerId: '', mpn: '', eolDate: '', notes: '' },
});
useEffect(() => {
if (!open) return;
form.reset({
manufacturerId: partModel?.manufacturerId ?? '',
mpn: partModel?.mpn ?? '',
eolDate: isoToDateInput(partModel?.eolDate ?? null),
notes: partModel?.notes ?? '',
});
}, [open, partModel, form]);
const manufacturers = useQuery({
queryKey: queryKeys.manufacturers.list({ pageSize: 100 }),
queryFn: () => listManufacturers({ pageSize: 100 }),
enabled: open,
});
const mutation = useMutation({
mutationFn: async (values: Values) => {
const payload = {
manufacturerId: values.manufacturerId,
mpn: values.mpn,
eolDate: values.eolDate ? values.eolDate : null,
notes: values.notes ? values.notes : null,
};
return editing && partModel
? updatePartModel(partModel.id, payload)
: createPartModel(payload);
},
onSuccess: () => {
toast.success(editing ? 'Part model updated' : 'Part model created');
queryClient.invalidateQueries({ queryKey: queryKeys.partModels.all });
onOpenChange(false);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Save failed'),
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{editing ? 'Edit part model' : 'New part model'}</DialogTitle>
<DialogDescription>
A part model (MPN) is the catalog entry that carries an end-of-life date.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit((v) => mutation.mutate(v))} className="space-y-3">
<FormField
control={form.control}
name="manufacturerId"
render={({ field }) => (
<FormItem>
<FormLabel>Manufacturer</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select manufacturer" />
</SelectTrigger>
</FormControl>
<SelectContent>
{manufacturers.data?.data.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="mpn"
render={({ field }) => (
<FormItem>
<FormLabel>MPN</FormLabel>
<FormControl>
<Input autoFocus {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="eolDate"
render={({ field }) => (
<FormItem>
<FormLabel>EOL date</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormDescription>
Deployed parts past this date surface on the dashboard.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>Notes</FormLabel>
<FormControl>
<Textarea rows={3} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={mutation.isPending}
>
Cancel
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
{editing ? 'Save changes' : 'Create'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
@@ -4,10 +4,12 @@ import {
ArrowRight,
CheckCircle2,
MapPin,
MessageSquare,
Package,
Pencil,
Tag,
Wrench,
XCircle,
type LucideIcon,
} from 'lucide-react';
import type { PartEventType } from '@vector/shared';
@@ -22,6 +24,8 @@ const EVENT_ICON: Record<PartEventType, LucideIcon> = {
FIELD_UPDATED: Pencil,
REPAIR_STARTED: Wrench,
REPAIR_COMPLETED: Wrench,
REPAIR_CANCELLED: XCircle,
REPAIR_COMMENTED: MessageSquare,
TAG_ADDED: Tag,
TAG_REMOVED: Tag,
};
@@ -33,6 +37,8 @@ const EVENT_TITLE: Record<PartEventType, string> = {
FIELD_UPDATED: 'Field updated',
REPAIR_STARTED: 'Repair started',
REPAIR_COMPLETED: 'Repair completed',
REPAIR_CANCELLED: 'Repair cancelled',
REPAIR_COMMENTED: 'Repair comment',
TAG_ADDED: 'Tag added',
TAG_REMOVED: 'Tag removed',
};
@@ -80,7 +80,7 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
part
? {
serialNumber: part.serialNumber,
mpn: part.mpn,
mpn: part.partModel.mpn,
manufacturerId: part.manufacturerId,
state: part.state,
binId: part.binId ?? '',
@@ -1,74 +1,53 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Plus } from 'lucide-react';
import { Button, Skeleton } from '@vector/ui';
import { listRepairsForPart } from '../../lib/api/repairs.js';
import { Link } from 'react-router-dom';
import { Skeleton } from '@vector/ui';
import { listRepairs } from '../../lib/api/repairs.js';
import { queryKeys } from '../../lib/queryKeys.js';
import { RepairStatusBadge } from '../repairs/RepairStatusBadge.js';
import { RepairFormDialog } from '../repairs/RepairFormDialog.js';
import type { RepairJob } from '../../lib/api/types.js';
interface PartRepairSectionProps {
partId: string;
}
export function PartRepairSection({ partId }: PartRepairSectionProps) {
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<RepairJob | null>(null);
const query = useQuery({
queryKey: [...queryKeys.parts.detail(partId), 'repairs'],
queryFn: () => listRepairsForPart(partId),
queryKey: queryKeys.repairs.list({ problemPartId: partId, pageSize: 50 }),
queryFn: () => listRepairs({ problemPartId: partId, pageSize: 50 }),
});
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">Repair history</p>
<Button variant="outline" size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-3.5 w-3.5" />
Open repair
</Button>
</div>
<p className="text-sm font-medium">Repairs touching this part</p>
{query.isPending ? (
<Skeleton className="h-16 w-full" />
) : !query.data || query.data.length === 0 ? (
<p className="text-xs text-muted-foreground">No repair jobs yet.</p>
) : !query.data || query.data.data.length === 0 ? (
<p className="text-xs text-muted-foreground">No repairs reference this part yet.</p>
) : (
<ul className="divide-y divide-border rounded-md border border-border text-sm">
{query.data.map((repair) => (
{query.data.data.map((repair) => (
<li
key={repair.id}
className="flex items-center justify-between gap-3 px-3 py-2"
>
<div className="flex items-center gap-2">
<div className="flex min-w-0 items-center gap-2">
<RepairStatusBadge status={repair.status} />
<span className="text-xs text-muted-foreground">
Opened {new Date(repair.openedAt).toLocaleDateString()}
{repair.host ? ` · ${repair.host.name}` : ''}
<Link
to={`/repairs/${repair.id}`}
className="truncate text-xs text-foreground hover:underline"
>
{repair.problem}
</Link>
<span className="shrink-0 text-xs text-muted-foreground">
· {repair.host.name}
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => setEditing(repair)}
>
Edit
</Button>
<span className="text-xs text-muted-foreground">
{new Date(repair.openedAt).toLocaleDateString()}
</span>
</li>
))}
</ul>
)}
<RepairFormDialog
open={createOpen}
onOpenChange={setCreateOpen}
defaultPartId={partId}
/>
<RepairFormDialog
open={Boolean(editing)}
onOpenChange={(o) => !o && setEditing(null)}
repair={editing}
/>
</div>
);
}
@@ -0,0 +1,118 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2, Send } from 'lucide-react';
import { toast } from 'sonner';
import { Button, Skeleton, Textarea } from '@vector/ui';
import { addRepairComment, listRepairComments } from '../../lib/api/repairs.js';
import { ApiRequestError } from '../../lib/api/client.js';
import { queryKeys } from '../../lib/queryKeys.js';
function formatWhen(iso: string) {
return new Date(iso).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function initialsOf(username: string | undefined | null): string {
if (!username) return '?';
return username.slice(0, 2).toUpperCase();
}
export function RepairCommentThread({ repairId }: { repairId: string }) {
const queryClient = useQueryClient();
const [content, setContent] = useState('');
const query = useQuery({
queryKey: queryKeys.repairs.comments(repairId),
queryFn: () => listRepairComments(repairId, { pageSize: 100 }),
});
const mutation = useMutation({
mutationFn: (body: string) => addRepairComment(repairId, { content: body }),
onSuccess: () => {
setContent('');
queryClient.invalidateQueries({ queryKey: queryKeys.repairs.comments(repairId) });
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Post failed'),
});
const submit = () => {
const trimmed = content.trim();
if (!trimmed) return;
mutation.mutate(trimmed);
};
return (
<div className="space-y-4">
{query.isPending ? (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
) : query.isError ? (
<p className="text-sm text-destructive">Could not load comments.</p>
) : query.data && query.data.data.length > 0 ? (
<ol className="space-y-3">
{query.data.data.map((c) => (
<li key={c.id} className="flex gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-accent text-[10px] font-semibold text-accent-foreground">
{initialsOf(c.user?.username)}
</div>
<div className="flex-1 space-y-0.5">
<div className="flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground">
<span className="font-medium text-foreground">
{c.user?.username ?? 'Unknown user'}
</span>
<span>·</span>
<span>{formatWhen(c.createdAt)}</span>
</div>
<p className="whitespace-pre-wrap text-sm text-foreground">{c.content}</p>
</div>
</li>
))}
</ol>
) : (
<p className="text-sm text-muted-foreground">No comments yet. Start the thread below.</p>
)}
<div className="space-y-2">
<Textarea
rows={3}
placeholder="Leave a comment..."
value={content}
onChange={(e) => setContent(e.target.value)}
disabled={mutation.isPending}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
submit();
}
}}
/>
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">
{content.length}/4000 · Ctrl/+Enter to post
</span>
<Button
size="sm"
onClick={submit}
disabled={mutation.isPending || content.trim().length === 0}
>
{mutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Send className="h-3.5 w-3.5" />
)}
Post
</Button>
</div>
</div>
</div>
);
}
@@ -7,6 +7,7 @@ import { Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import {
Button,
Checkbox,
Dialog,
DialogContent,
DialogDescription,
@@ -25,44 +26,38 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
Skeleton,
Textarea,
} from '@vector/ui';
import { createRepair, updateRepair } from '../../lib/api/repairs.js';
import { listHosts } from '../../lib/api/hosts.js';
import { createRepair } from '../../lib/api/repairs.js';
import { listHosts, listHostDeployedParts } from '../../lib/api/hosts.js';
import { ApiRequestError } from '../../lib/api/client.js';
import { queryKeys } from '../../lib/queryKeys.js';
import type { RepairJob } from '../../lib/api/types.js';
import { repairStatusOptions } from './RepairStatusBadge.js';
const NONE = '__none__';
const CreateSchema = z.object({
partId: z.string().uuid('Pick a valid part id'),
hostId: z.string().optional(),
notes: z.string().max(4096).optional(),
});
const EditSchema = z.object({
status: z.enum(['PENDING', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED']),
hostId: z.string().optional(),
hostId: z.string().uuid('Pick a host'),
problem: z.string().trim().min(1, 'Describe the problem').max(2000),
problemPartIds: z.array(z.string().uuid()).max(100),
notes: z.string().max(4096).optional(),
});
type CreateValues = z.infer<typeof CreateSchema>;
type EditValues = z.infer<typeof EditSchema>;
interface RepairFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
repair?: RepairJob | null;
defaultPartId?: string;
defaultHostId?: string;
defaultProblemPartIds?: string[];
onCreated?: (repair: RepairJob) => void;
}
export function RepairFormDialog({
open,
onOpenChange,
repair,
defaultPartId,
defaultHostId,
defaultProblemPartIds,
onCreated,
}: RepairFormDialogProps) {
const editing = Boolean(repair);
const queryClient = useQueryClient();
const hostsQuery = useQuery({
@@ -71,123 +66,102 @@ export function RepairFormDialog({
enabled: open,
});
const createForm = useForm<CreateValues>({
const form = useForm<CreateValues>({
resolver: zodResolver(CreateSchema),
defaultValues: { partId: '', hostId: NONE, notes: '' },
});
const editForm = useForm<EditValues>({
resolver: zodResolver(EditSchema),
defaultValues: { status: 'PENDING', hostId: NONE, notes: '' },
defaultValues: {
hostId: '',
problem: '',
problemPartIds: [],
notes: '',
},
});
useEffect(() => {
if (!open) return;
if (editing && repair) {
editForm.reset({
status: repair.status,
hostId: repair.hostId ?? NONE,
notes: repair.notes ?? '',
form.reset({
hostId: defaultHostId ?? '',
problem: '',
problemPartIds: defaultProblemPartIds ?? [],
notes: '',
});
}, [open, defaultHostId, defaultProblemPartIds, form]);
const hostId = form.watch('hostId');
const selectedPartIds = form.watch('problemPartIds');
const deployedQuery = useQuery({
queryKey: queryKeys.hosts.deployedParts(hostId),
queryFn: () => listHostDeployedParts(hostId),
enabled: open && Boolean(hostId),
});
} else {
createForm.reset({ partId: defaultPartId ?? '', hostId: NONE, notes: '' });
}
}, [open, editing, repair, defaultPartId, createForm, editForm]);
const createMutation = useMutation({
mutationFn: async (values: CreateValues) =>
createRepair({
partId: values.partId,
hostId: values.hostId && values.hostId !== NONE ? values.hostId : null,
hostId: values.hostId,
problem: values.problem,
problemPartIds:
values.problemPartIds.length > 0 ? values.problemPartIds : undefined,
notes: values.notes ? values.notes : null,
}),
onSuccess: () => {
onSuccess: (repair) => {
toast.success('Repair opened');
queryClient.invalidateQueries({ queryKey: queryKeys.repairs.all });
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
onOpenChange(false);
onCreated?.(repair);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Save failed'),
});
const editMutation = useMutation({
mutationFn: async (values: EditValues) =>
updateRepair(repair!.id, {
status: values.status,
hostId: values.hostId && values.hostId !== NONE ? values.hostId : null,
notes: values.notes ? values.notes : null,
}),
onSuccess: () => {
toast.success('Repair updated');
queryClient.invalidateQueries({ queryKey: queryKeys.repairs.all });
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
onOpenChange(false);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Save failed'),
});
const pending = createMutation.isPending;
const pending = createMutation.isPending || editMutation.isPending;
function togglePart(partId: string, checked: boolean) {
const next = checked
? [...new Set([...selectedPartIds, partId])]
: selectedPartIds.filter((id) => id !== partId);
form.setValue('problemPartIds', next, { shouldValidate: true, shouldDirty: true });
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{editing ? 'Edit repair' : 'Open repair'}</DialogTitle>
<DialogTitle>Open repair</DialogTitle>
<DialogDescription>
{editing
? 'Advance status, re-assign the host, or update notes.'
: 'Open a repair job for a part. Status starts as PENDING.'}
Create a repair against a host. Select the deployed parts involved (optional).
</DialogDescription>
</DialogHeader>
{editing ? (
<Form {...editForm}>
<Form {...form}>
<form
onSubmit={editForm.handleSubmit((v) => editMutation.mutate(v))}
onSubmit={form.handleSubmit((v) => createMutation.mutate(v))}
className="space-y-3"
>
<FormField
control={editForm.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{repairStatusOptions.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editForm.control}
control={form.control}
name="hostId"
render={({ field }) => (
<FormItem>
<FormLabel>Host</FormLabel>
<Select onValueChange={field.onChange} value={field.value ?? NONE}>
<Select
onValueChange={(v) => {
field.onChange(v);
form.setValue('problemPartIds', [], {
shouldValidate: false,
});
}}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="None" />
<SelectValue placeholder="Select host" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={NONE}>None</SelectItem>
{hostsQuery.data?.data.map((h) => (
<SelectItem key={h.id} value={h.id}>
{h.name}
{h.assetId} {h.name}
</SelectItem>
))}
</SelectContent>
@@ -196,100 +170,93 @@ export function RepairFormDialog({
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="notes"
control={form.control}
name="problem"
render={({ field }) => (
<FormItem>
<FormLabel>Notes</FormLabel>
<FormLabel>Problem</FormLabel>
<FormControl>
<Textarea rows={3} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={pending}
>
Cancel
</Button>
<Button type="submit" disabled={pending}>
{pending && <Loader2 className="h-4 w-4 animate-spin" />}
Save changes
</Button>
</DialogFooter>
</form>
</Form>
) : (
<Form {...createForm}>
<form
onSubmit={createForm.handleSubmit((v) => createMutation.mutate(v))}
className="space-y-3"
>
<FormField
control={createForm.control}
name="partId"
render={({ field }) => (
<FormItem>
<FormLabel>Part ID</FormLabel>
<FormControl>
<input
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
placeholder="Part UUID"
autoFocus
<Textarea
rows={3}
placeholder="Short description of what's wrong."
{...field}
/>
</FormControl>
<FormDescription>
Paste the part UUID to open a repair against it.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{hostId && (
<FormField
control={createForm.control}
name="hostId"
render={({ field }) => (
control={form.control}
name="problemPartIds"
render={() => (
<FormItem>
<FormLabel>Host (optional)</FormLabel>
<Select onValueChange={field.onChange} value={field.value ?? NONE}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="None" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={NONE}>None</SelectItem>
{hostsQuery.data?.data.map((h) => (
<SelectItem key={h.id} value={h.id}>
{h.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormLabel>Affected parts (optional)</FormLabel>
<FormDescription>
Select deployed parts involved in this problem.
</FormDescription>
<div className="max-h-40 overflow-y-auto rounded-md border border-border">
{deployedQuery.isPending ? (
<Skeleton className="m-2 h-12" />
) : !deployedQuery.data || deployedQuery.data.length === 0 ? (
<p className="p-3 text-xs text-muted-foreground">
No deployed parts on this host.
</p>
) : (
<ul className="divide-y divide-border">
{deployedQuery.data.map((part) => {
const checked = selectedPartIds.includes(part.id);
return (
<li
key={part.id}
className="flex items-center gap-2 px-3 py-2 text-sm"
>
<Checkbox
id={`pp-${part.id}`}
checked={checked}
onCheckedChange={(v) => togglePart(part.id, v === true)}
/>
<label
htmlFor={`pp-${part.id}`}
className="flex-1 cursor-pointer select-none"
>
<span className="font-mono text-xs">
{part.serialNumber}
</span>{' '}
<span className="text-xs text-muted-foreground">
{part.partModel.mpn}
</span>
</label>
</li>
);
})}
</ul>
)}
</div>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={createForm.control}
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>Notes</FormLabel>
<FormLabel>Notes (optional)</FormLabel>
<FormControl>
<Textarea rows={3} {...field} />
<Textarea rows={2} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
@@ -306,7 +273,6 @@ export function RepairFormDialog({
</DialogFooter>
</form>
</Form>
)}
</DialogContent>
</Dialog>
);
+6 -1
View File
@@ -1,7 +1,7 @@
import type { CreateHostRequest, UpdateHostRequest } from '@vector/shared';
import { api } from './client.js';
import { getList } from './paginated.js';
import type { Host } from './types.js';
import type { Host, Part } from './types.js';
export type HostListFilters = {
page?: number;
@@ -18,6 +18,11 @@ export async function getHost(id: string): Promise<Host> {
return res.data;
}
export async function listHostDeployedParts(id: string): Promise<Part[]> {
const res = await api.get<Part[]>(`/hosts/${id}/deployed-parts`);
return res.data;
}
export async function createHost(input: CreateHostRequest): Promise<Host> {
const res = await api.post<Host>('/hosts', input);
return res.data;
+41
View File
@@ -0,0 +1,41 @@
import type {
CreatePartModelRequest,
UpdatePartModelRequest,
} from '@vector/shared';
import { api } from './client.js';
import { getList } from './paginated.js';
import type { PartModel } from './types.js';
export type PartModelListFilters = {
page?: number;
pageSize?: number;
manufacturerId?: string;
q?: string;
eolBefore?: string;
};
export function listPartModels(filters: PartModelListFilters = {}) {
return getList<PartModel>('/part-models', filters);
}
export async function getPartModel(id: string): Promise<PartModel> {
const res = await api.get<PartModel>(`/part-models/${id}`);
return res.data;
}
export async function createPartModel(input: CreatePartModelRequest): Promise<PartModel> {
const res = await api.post<PartModel>('/part-models', input);
return res.data;
}
export async function updatePartModel(
id: string,
input: UpdatePartModelRequest,
): Promise<PartModel> {
const res = await api.patch<PartModel>(`/part-models/${id}`, input);
return res.data;
}
export async function deletePartModel(id: string): Promise<void> {
await api.delete(`/part-models/${id}`);
}
+18 -7
View File
@@ -1,18 +1,19 @@
import type {
CreateRepairCommentRequest,
CreateRepairJobRequest,
RepairStatus,
UpdateRepairJobRequest,
} from '@vector/shared';
import { api } from './client.js';
import { getList } from './paginated.js';
import type { RepairJob } from './types.js';
import type { RepairComment, RepairJob } from './types.js';
export type RepairListFilters = {
page?: number;
pageSize?: number;
status?: RepairStatus;
partId?: string;
hostId?: string;
problemPartId?: string;
assigneeId?: string;
openOnly?: boolean;
};
@@ -26,11 +27,6 @@ export async function getRepair(id: string): Promise<RepairJob> {
return res.data;
}
export async function listRepairsForPart(partId: string): Promise<RepairJob[]> {
const res = await api.get<RepairJob[]>(`/parts/${partId}/repairs`);
return res.data;
}
export async function createRepair(input: CreateRepairJobRequest): Promise<RepairJob> {
const res = await api.post<RepairJob>('/repairs', input);
return res.data;
@@ -47,3 +43,18 @@ export async function updateRepair(
export async function deleteRepair(id: string): Promise<void> {
await api.delete(`/repairs/${id}`);
}
export function listRepairComments(
id: string,
filters: { page?: number; pageSize?: number } = {},
) {
return getList<RepairComment>(`/repairs/${id}/comments`, filters);
}
export async function addRepairComment(
id: string,
input: CreateRepairCommentRequest,
): Promise<RepairComment> {
const res = await api.post<RepairComment>(`/repairs/${id}/comments`, input);
return res.data;
}
+37 -7
View File
@@ -6,11 +6,22 @@ import type { PartEventType, PartState, RepairStatus, Role } from '@vector/share
export interface Manufacturer {
id: string;
name: string;
eolDate: string | null;
createdAt: string;
updatedAt: string;
}
export interface PartModel {
id: string;
manufacturerId: string;
mpn: string;
eolDate: string | null;
notes: string | null;
createdAt: string;
updatedAt: string;
manufacturer?: Manufacturer;
_count?: { parts: number };
}
export interface Site {
id: string;
name: string;
@@ -42,18 +53,20 @@ export interface BinWithPath extends Bin {
export interface Part {
id: string;
serialNumber: string;
mpn: string;
partModelId: string;
manufacturerId: string;
price: number | null;
state: PartState;
binId: string | null;
categoryId: string | null;
replacementPartId: string | null;
hostId: string | null;
notes: string | null;
createdAt: string;
updatedAt: string;
manufacturer: Manufacturer;
partModel: PartModel;
bin: BinWithPath | null;
host: Host | null;
}
export interface PartEvent {
@@ -79,6 +92,7 @@ export interface User {
export interface Host {
id: string;
assetId: string;
name: string;
location: string | null;
notes: string | null;
@@ -101,20 +115,36 @@ export interface Category {
updatedAt: string;
}
export interface RepairJobProblemPart {
repairJobId: string;
partId: string;
createdAt: string;
part: Part;
}
export interface RepairJob {
id: string;
partId: string;
hostId: string | null;
hostId: string;
assigneeId: string | null;
status: RepairStatus;
problem: string;
notes: string | null;
openedAt: string;
closedAt: string | null;
createdAt: string;
updatedAt: string;
part: Part;
host: Host | null;
host: Host;
assignee: Pick<User, 'id' | 'username' | 'email' | 'role'> | null;
problemParts: RepairJobProblemPart[];
}
export interface RepairComment {
id: string;
repairJobId: string;
userId: string | null;
content: string;
createdAt: string;
user: Pick<User, 'id' | 'username'> | null;
}
export interface SavedView {
+8
View File
@@ -47,12 +47,20 @@ export const queryKeys = {
list: (filters?: Record<string, unknown>) =>
[...queryKeys.hosts.all, 'list', filters ?? {}] as const,
detail: (id: string) => [...queryKeys.hosts.all, 'detail', id] as const,
deployedParts: (id: string) => [...queryKeys.hosts.all, 'deployed-parts', id] as const,
},
repairs: {
all: ['repairs'] as const,
list: (filters?: Record<string, unknown>) =>
[...queryKeys.repairs.all, 'list', filters ?? {}] as const,
detail: (id: string) => [...queryKeys.repairs.all, 'detail', id] as const,
comments: (id: string) => [...queryKeys.repairs.all, 'comments', id] as const,
},
partModels: {
all: ['part-models'] as const,
list: (filters?: Record<string, unknown>) =>
[...queryKeys.partModels.all, 'list', filters ?? {}] as const,
detail: (id: string) => [...queryKeys.partModels.all, 'detail', id] as const,
},
tags: {
all: ['tags'] as const,
+16 -7
View File
@@ -276,28 +276,37 @@ function KpiCard({
function PastEolBanner({
rows,
}: {
rows: { manufacturerId: string; name: string; eolDate: string | null; deployedCount: number }[];
rows: {
partModelId: string;
mpn: string;
manufacturerId: string;
manufacturerName: string;
eolDate: string;
deployedCount: number;
}[];
}) {
return (
<Card className="border-warning/50 bg-warning/5">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<AlertTriangle className="h-4 w-4 text-warning" />
Deployed past manufacturer EOL
Deployed past part-model EOL
</CardTitle>
<CardDescription>
These manufacturers have passed their end-of-life date plan replacements for any parts
still in production.
These MPNs have passed their end-of-life date plan replacements for any parts still in
production.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 pb-5">
{rows.map((row) => (
<div
key={row.manufacturerId}
key={row.partModelId}
className="flex items-center justify-between rounded-md border border-border bg-background px-3 py-2 text-sm"
>
<div className="min-w-0">
<div className="truncate font-medium">{row.name}</div>
<div className="truncate font-medium">
{row.manufacturerName} · <span className="font-mono">{row.mpn}</span>
</div>
{row.eolDate && (
<div className="text-xs text-muted-foreground">
EOL {new Date(row.eolDate).toLocaleDateString()}
@@ -309,7 +318,7 @@ function PastEolBanner({
{row.deployedCount} deployed
</span>
<Button asChild variant="outline" size="sm">
<Link to={`/parts?manufacturerId=${row.manufacturerId}&state=DEPLOYED`}>View</Link>
<Link to={`/parts?partModelId=${row.partModelId}&state=DEPLOYED`}>View</Link>
</Button>
</div>
</div>
+7
View File
@@ -43,6 +43,13 @@ export default function Hosts() {
const columns = useMemo<ColumnDef<Host>[]>(
() => [
{
accessorKey: 'assetId',
header: 'Asset ID',
cell: ({ row }) => (
<span className="font-mono text-xs font-medium">{row.original.assetId}</span>
),
},
{
accessorKey: 'name',
header: 'Name',
+1 -16
View File
@@ -4,7 +4,6 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Building, Edit, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import {
Badge,
Button,
DropdownMenu,
DropdownMenuContent,
@@ -49,20 +48,6 @@ export default function Manufacturers() {
header: 'Name',
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>,
},
{
accessorKey: 'eolDate',
header: 'EOL',
cell: ({ row }) => {
if (!row.original.eolDate) {
return <span className="text-xs text-muted-foreground"></span>;
}
const d = new Date(row.original.eolDate);
const past = d.getTime() < Date.now();
return (
<Badge variant={past ? 'warning' : 'outline'}>{d.toLocaleDateString()}</Badge>
);
},
},
{
accessorKey: 'createdAt',
header: 'Added',
@@ -109,7 +94,7 @@ export default function Manufacturers() {
<div className="space-y-5">
<PageHeader
title="Manufacturers"
description="Vendors and their end-of-life dates."
description="Vendors. EOL is tracked per part model (see Catalog → Part models)."
actions={
isAdmin && (
<Button onClick={() => setCreateOpen(true)}>
+4 -4
View File
@@ -89,7 +89,7 @@ export default function PartDetail() {
);
}
const eolDate = part.manufacturer.eolDate ? new Date(part.manufacturer.eolDate) : null;
const eolDate = part.partModel.eolDate ? new Date(part.partModel.eolDate) : null;
const pastEol = eolDate ? eolDate.getTime() < Date.now() : false;
return (
@@ -102,7 +102,7 @@ export default function PartDetail() {
<div>
<h1 className="font-mono text-lg font-semibold tracking-tight">{part.serialNumber}</h1>
<p className="text-xs text-muted-foreground">
{part.manufacturer.name} · {part.mpn}
{part.manufacturer.name} · {part.partModel.mpn}
</p>
</div>
</div>
@@ -132,7 +132,7 @@ export default function PartDetail() {
<AlertTriangle className="mt-0.5 h-4 w-4 text-warning" />
<div className="text-sm">
<span className="font-medium text-foreground">
{part.manufacturer.name} reached EOL {eolDate.toLocaleDateString()}.
{part.partModel.mpn} reached EOL {eolDate.toLocaleDateString()}.
</span>{' '}
<span className="text-muted-foreground">
Plan a replacement for this part.
@@ -150,7 +150,7 @@ export default function PartDetail() {
<CardContent>
<dl className="space-y-2">
<DetailRow label="Serial" value={<span className="font-mono text-xs">{part.serialNumber}</span>} />
<DetailRow label="MPN" value={part.mpn} />
<DetailRow label="MPN" value={part.partModel.mpn} />
<DetailRow
label="Manufacturer"
value={
+171
View File
@@ -0,0 +1,171 @@
import { useMemo, useState } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Edit, Layers, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import {
Badge,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@vector/ui';
import { PageHeader } from '../components/layout/PageHeader.js';
import { DataTable } from '../components/data-table/DataTable.js';
import { PartModelFormDialog } from '../components/part-models/PartModelFormDialog.js';
import { ConfirmDialog } from '../components/ConfirmDialog.js';
import { deletePartModel, listPartModels } from '../lib/api/part-models.js';
import { ApiRequestError } from '../lib/api/client.js';
import type { PartModel } from '../lib/api/types.js';
import { queryKeys } from '../lib/queryKeys.js';
import { useAuth } from '../contexts/AuthContext.js';
export default function PartModels() {
const { user } = useAuth();
const isAdmin = user?.role === 'ADMIN';
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<PartModel | null>(null);
const [deleting, setDeleting] = useState<PartModel | null>(null);
const deleteMutation = useMutation({
mutationFn: (id: string) => deletePartModel(id),
onSuccess: () => {
toast.success('Part model deleted');
queryClient.invalidateQueries({ queryKey: queryKeys.partModels.all });
setDeleting(null);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed'),
});
const columns = useMemo<ColumnDef<PartModel>[]>(
() => [
{
accessorKey: 'manufacturer',
header: 'Manufacturer',
cell: ({ row }) => (
<span className="text-sm">{row.original.manufacturer?.name ?? '—'}</span>
),
},
{
accessorKey: 'mpn',
header: 'MPN',
cell: ({ row }) => (
<span className="font-mono text-xs font-medium">{row.original.mpn}</span>
),
},
{
accessorKey: 'eolDate',
header: 'EOL',
cell: ({ row }) => {
const iso = row.original.eolDate;
if (!iso) return <span className="text-sm text-muted-foreground"></span>;
const pastEol = new Date(iso).getTime() <= Date.now();
return (
<div className="flex items-center gap-2">
<span className="text-sm">{new Date(iso).toLocaleDateString()}</span>
{pastEol && <Badge variant="destructive">Past EOL</Badge>}
</div>
);
},
},
{
id: 'deployedCount',
header: 'Deployed',
cell: ({ row }) => (
<span className="text-sm tabular-nums">{row.original._count?.parts ?? 0}</span>
),
},
{
id: 'actions',
header: () => <span className="sr-only">Actions</span>,
size: 40,
cell: ({ row }) =>
isAdmin ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem onSelect={() => setEditing(row.original)}>
<Edit className="h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => setDeleting(row.original)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null,
},
],
[isAdmin],
);
return (
<div className="space-y-5">
<PageHeader
title="Part models"
description="Catalog of MPNs. End-of-life is tracked per model and drives the past-EOL dashboard alert."
actions={
isAdmin && (
<Button onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" />
New part model
</Button>
)
}
/>
<DataTable<PartModel, Record<string, never>>
columns={columns}
getRowId={(m) => m.id}
queryKey={(params) =>
queryKeys.partModels.list({ page: params.page, pageSize: params.pageSize, q: params.q })
}
queryFn={(params) =>
listPartModels({ page: params.page, pageSize: params.pageSize, q: params.q })
}
searchPlaceholder="Search MPN..."
emptyState={
<div className="flex flex-col items-center gap-1 py-8 text-muted-foreground">
<Layers className="h-6 w-6" />
<span className="text-sm">No part models yet.</span>
</div>
}
/>
<PartModelFormDialog open={createOpen} onOpenChange={setCreateOpen} />
<PartModelFormDialog
open={Boolean(editing)}
onOpenChange={(o) => !o && setEditing(null)}
partModel={editing}
/>
<ConfirmDialog
open={Boolean(deleting)}
onOpenChange={(o) => !o && setDeleting(null)}
title="Delete part model?"
description={
deleting
? `Remove ${deleting.mpn}. Fails if any parts reference this model.`
: undefined
}
confirmLabel="Delete"
destructive
pending={deleteMutation.isPending}
onConfirm={() => deleting && deleteMutation.mutate(deleting.id)}
/>
</div>
);
}
+4 -2
View File
@@ -94,9 +94,11 @@ export default function Parts() {
),
},
{
accessorKey: 'mpn',
id: 'mpn',
header: 'MPN',
cell: ({ row }) => <span className="text-sm">{row.original.mpn}</span>,
cell: ({ row }) => (
<span className="text-sm font-mono">{row.original.partModel.mpn}</span>
),
},
{
id: 'manufacturer',
+473
View File
@@ -0,0 +1,473 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
ArrowLeft,
Check,
Loader2,
Pencil,
Plus,
Server,
Trash2,
X,
} from 'lucide-react';
import { toast } from 'sonner';
import type { RepairStatus } from '@vector/shared';
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Checkbox,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Separator,
Skeleton,
Textarea,
} from '@vector/ui';
import { getRepair, updateRepair } from '../lib/api/repairs.js';
import { listHostDeployedParts } from '../lib/api/hosts.js';
import { ApiRequestError } from '../lib/api/client.js';
import { queryKeys } from '../lib/queryKeys.js';
import type { RepairJob } from '../lib/api/types.js';
import { RepairStatusBadge, repairStatusOptions } from '../components/repairs/RepairStatusBadge.js';
import { PartStateBadge } from '../components/parts/PartStateBadge.js';
import { RepairCommentThread } from '../components/repairs/RepairCommentThread.js';
export default function RepairDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { data: repair, isPending, isError, error } = useQuery({
queryKey: queryKeys.repairs.detail(id!),
queryFn: () => getRepair(id!),
enabled: Boolean(id),
});
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.repairs.detail(id!) });
queryClient.invalidateQueries({ queryKey: queryKeys.repairs.list() });
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
};
const statusMutation = useMutation({
mutationFn: (status: RepairStatus) => updateRepair(id!, { status }),
onSuccess: () => {
toast.success('Status updated');
invalidate();
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Update failed'),
});
if (isPending) {
return (
<div className="space-y-4">
<Skeleton className="h-10 w-80" />
<Skeleton className="h-40 w-full" />
<Skeleton className="h-64 w-full" />
</div>
);
}
if (isError || !repair) {
const msg = error instanceof ApiRequestError ? error.body.message : 'Repair not found.';
return (
<Card>
<CardHeader>
<CardTitle>Repair unavailable</CardTitle>
<CardDescription>{msg}</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" onClick={() => navigate('/repairs')}>
<ArrowLeft className="h-4 w-4" />
Back to repairs
</Button>
</CardContent>
</Card>
);
}
const terminal = repair.status === 'COMPLETED' || repair.status === 'CANCELLED';
return (
<div className="space-y-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={() => navigate('/repairs')}
aria-label="Back"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs uppercase tracking-wide text-muted-foreground">
Asset
</span>
<span className="font-mono text-2xl font-semibold tracking-tight text-foreground">
{repair.host.assetId}
</span>
<RepairStatusBadge status={repair.status} />
</div>
<div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
<Server className="h-3 w-3" />
<span>{repair.host.name}</span>
{repair.host.location && <span>· {repair.host.location}</span>}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Select
value={repair.status}
onValueChange={(v) => statusMutation.mutate(v as RepairStatus)}
disabled={statusMutation.isPending}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{repairStatusOptions.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-4 md:grid-cols-[1.3fr_1fr]">
<div className="space-y-4">
<ProblemCard repair={repair} onSaved={invalidate} disabled={terminal} />
<ProblemPartsCard repair={repair} onSaved={invalidate} disabled={terminal} />
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Comments</CardTitle>
<CardDescription>
Discuss progress, record findings, tag handoffs.
</CardDescription>
</CardHeader>
<CardContent>
<RepairCommentThread repairId={repair.id} />
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Timeline</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-2 gap-2 text-sm md:grid-cols-4">
<Field label="Opened" value={new Date(repair.openedAt).toLocaleString()} />
<Field
label="Closed"
value={
repair.closedAt ? new Date(repair.closedAt).toLocaleString() : '—'
}
/>
<Field
label="Assignee"
value={repair.assignee?.username ?? '—'}
/>
<Field label="Updated" value={new Date(repair.updatedAt).toLocaleString()} />
</dl>
{repair.notes && (
<>
<Separator className="my-3" />
<div>
<p className="mb-1 text-xs font-medium text-muted-foreground">Notes</p>
<p className="whitespace-pre-wrap text-sm text-foreground">{repair.notes}</p>
</div>
</>
)}
</CardContent>
</Card>
</div>
);
}
function Field({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="space-y-0.5">
<dt className="text-xs text-muted-foreground">{label}</dt>
<dd className="text-sm text-foreground">{value}</dd>
</div>
);
}
function ProblemCard({
repair,
onSaved,
disabled,
}: {
repair: { id: string; problem: string };
onSaved: () => void;
disabled: boolean;
}) {
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(repair.problem);
useEffect(() => {
setValue(repair.problem);
}, [repair.problem]);
const mutation = useMutation({
mutationFn: (problem: string) => updateRepair(repair.id, { problem }),
onSuccess: () => {
toast.success('Problem updated');
setEditing(false);
onSaved();
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Update failed'),
});
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Problem</CardTitle>
{!editing && !disabled && (
<Button variant="ghost" size="sm" onClick={() => setEditing(true)}>
<Pencil className="h-3.5 w-3.5" />
Edit
</Button>
)}
</CardHeader>
<CardContent>
{editing ? (
<div className="space-y-2">
<Textarea
rows={4}
value={value}
onChange={(e) => setValue(e.target.value)}
disabled={mutation.isPending}
/>
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setValue(repair.problem);
setEditing(false);
}}
disabled={mutation.isPending}
>
<X className="h-3.5 w-3.5" />
Cancel
</Button>
<Button
size="sm"
onClick={() => mutation.mutate(value.trim())}
disabled={
mutation.isPending ||
value.trim().length === 0 ||
value.trim() === repair.problem
}
>
{mutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Check className="h-3.5 w-3.5" />
)}
Save
</Button>
</div>
</div>
) : (
<p className="whitespace-pre-wrap text-sm text-foreground">{repair.problem}</p>
)}
</CardContent>
</Card>
);
}
function ProblemPartsCard({
repair,
onSaved,
disabled,
}: {
repair: RepairJob;
onSaved: () => void;
disabled: boolean;
}) {
const [picking, setPicking] = useState(false);
const [draft, setDraft] = useState<string[]>([]);
const deployedQuery = useQuery({
queryKey: queryKeys.hosts.deployedParts(repair.hostId),
queryFn: () => listHostDeployedParts(repair.hostId),
enabled: picking,
});
useEffect(() => {
if (picking) {
setDraft(repair.problemParts.map((pp) => pp.partId));
}
}, [picking, repair.problemParts]);
const mutation = useMutation({
mutationFn: (problemPartIds: string[]) =>
updateRepair(repair.id, { problemPartIds }),
onSuccess: () => {
toast.success('Problem parts updated');
setPicking(false);
onSaved();
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Update failed'),
});
const removeMutation = useMutation({
mutationFn: (partId: string) => {
const next = repair.problemParts.map((pp) => pp.partId).filter((pid) => pid !== partId);
return updateRepair(repair.id, { problemPartIds: next });
},
onSuccess: () => {
toast.success('Part removed');
onSaved();
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Remove failed'),
});
function toggle(partId: string, checked: boolean) {
setDraft((prev) =>
checked ? [...new Set([...prev, partId])] : prev.filter((id) => id !== partId),
);
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div>
<CardTitle className="text-base">Problem parts</CardTitle>
<CardDescription>
Deployed parts on this host involved in the issue.
</CardDescription>
</div>
{!picking && !disabled && (
<Button variant="outline" size="sm" onClick={() => setPicking(true)}>
<Plus className="h-3.5 w-3.5" />
Manage
</Button>
)}
</CardHeader>
<CardContent>
{picking ? (
<div className="space-y-3">
<div className="max-h-60 overflow-y-auto rounded-md border border-border">
{deployedQuery.isPending ? (
<Skeleton className="m-2 h-12" />
) : !deployedQuery.data || deployedQuery.data.length === 0 ? (
<p className="p-3 text-xs text-muted-foreground">
No deployed parts on this host.
</p>
) : (
<ul className="divide-y divide-border">
{deployedQuery.data.map((part) => {
const checked = draft.includes(part.id);
return (
<li
key={part.id}
className="flex items-center gap-2 px-3 py-2 text-sm"
>
<Checkbox
id={`rd-pp-${part.id}`}
checked={checked}
onCheckedChange={(v) => toggle(part.id, v === true)}
/>
<label
htmlFor={`rd-pp-${part.id}`}
className="flex-1 cursor-pointer select-none"
>
<span className="font-mono text-xs">{part.serialNumber}</span>{' '}
<span className="text-xs text-muted-foreground">
{part.partModel.mpn}
</span>
</label>
</li>
);
})}
</ul>
)}
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPicking(false)}
disabled={mutation.isPending}
>
<X className="h-3.5 w-3.5" />
Cancel
</Button>
<Button
size="sm"
onClick={() => mutation.mutate(draft)}
disabled={mutation.isPending}
>
{mutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Check className="h-3.5 w-3.5" />
)}
Save
</Button>
</div>
</div>
) : repair.problemParts.length === 0 ? (
<p className="text-sm text-muted-foreground">
No specific parts tagged the repair is against the host itself.
</p>
) : (
<ul className="divide-y divide-border rounded-md border border-border">
{repair.problemParts.map((pp) => (
<li
key={pp.partId}
className="flex items-center justify-between gap-3 px-3 py-2"
>
<div className="min-w-0 flex-1">
<Link
to={`/parts/${pp.part.id}`}
className="font-mono text-xs font-medium text-foreground hover:underline"
>
{pp.part.serialNumber}
</Link>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{pp.part.partModel.mpn}</span>
<PartStateBadge state={pp.part.state} />
</div>
</div>
{!disabled && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => removeMutation.mutate(pp.partId)}
disabled={removeMutation.isPending}
aria-label="Remove part"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</li>
))}
</ul>
)}
</CardContent>
</Card>
);
}
+26 -30
View File
@@ -1,17 +1,16 @@
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import type { ColumnDef } from '@tanstack/react-table';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { parseAsString } from 'nuqs';
import { toast } from 'sonner';
import { Edit, MoreHorizontal, Plus, Trash2, Wrench } from 'lucide-react';
import { MoreHorizontal, Plus, Trash2, Wrench } from 'lucide-react';
import type { RepairStatus } from '@vector/shared';
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Select,
SelectContent,
@@ -44,8 +43,8 @@ const ALL = '__all__';
export default function Repairs() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<RepairJob | null>(null);
const [deleting, setDeleting] = useState<RepairJob | null>(null);
const deleteMutation = useMutation({
@@ -67,31 +66,34 @@ export default function Repairs() {
cell: ({ row }) => <RepairStatusBadge status={row.original.status} />,
},
{
id: 'part',
header: 'Part',
id: 'assetId',
header: 'Asset ID',
cell: ({ row }) => (
<Link
to={`/parts/${row.original.partId}`}
className="font-medium text-foreground hover:underline"
to={`/repairs/${row.original.id}`}
className="font-mono text-xs font-medium text-foreground hover:underline"
>
{row.original.part.serialNumber}
{row.original.host.assetId}
</Link>
),
},
{
id: 'mpn',
header: 'MPN',
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">{row.original.part.mpn}</span>
),
},
{
id: 'host',
header: 'Host',
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{row.original.host?.name ?? '—'}
</span>
<span className="text-sm">{row.original.host.name}</span>
),
},
{
id: 'problem',
header: 'Problem',
cell: ({ row }) => (
<Link
to={`/repairs/${row.original.id}`}
className="line-clamp-1 max-w-sm text-sm text-foreground hover:underline"
>
{row.original.problem}
</Link>
),
},
{
@@ -126,11 +128,6 @@ export default function Repairs() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem onSelect={() => setEditing(row.original)}>
<Edit className="h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => setDeleting(row.original)}
className="text-destructive focus:text-destructive"
@@ -150,7 +147,7 @@ export default function Repairs() {
<div className="space-y-5">
<PageHeader
title="Repairs"
description="Open RMAs and host-attached repair jobs."
description="Open work against hosts. Click a row to view and comment."
actions={
<Button onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" />
@@ -204,11 +201,10 @@ export default function Repairs() {
}
/>
<RepairFormDialog open={createOpen} onOpenChange={setCreateOpen} />
<RepairFormDialog
open={Boolean(editing)}
onOpenChange={(o) => !o && setEditing(null)}
repair={editing}
open={createOpen}
onOpenChange={setCreateOpen}
onCreated={(repair) => navigate(`/repairs/${repair.id}`)}
/>
<ConfirmDialog
open={Boolean(deleting)}
@@ -216,7 +212,7 @@ export default function Repairs() {
title="Delete repair?"
description={
deleting
? `Remove repair for ${deleting.part.serialNumber}. This cannot be undone.`
? `Remove repair "${deleting.problem.slice(0, 60)}" on ${deleting.host.name}. This cannot be undone.`
: undefined
}
confirmLabel="Delete"
@@ -0,0 +1,204 @@
-- Domain rework: EOL moves from Manufacturer to a new PartModel catalog (keyed by manufacturerId+mpn);
-- repairs move from Part-scoped to Host-scoped with an optional problem-parts join; Host gains a
-- required+unique assetId; RepairJob gains a `problem` field and loses its direct `partId` FK.
--
-- Data-preserving reshape for SQLite. Steps:
-- 1. Create new catalog + join + comment tables.
-- 2. Seed PartModel from DISTINCT (manufacturerId, mpn) on Part, carrying Manufacturer.eolDate forward.
-- 3. Snapshot existing RepairJob.partId into RepairJobPart so historic repairs still remember the part.
-- 4. Ensure every RepairJob has a hostId (synthesize "__Unassigned__" host if needed) before hostId NOT NULL.
-- 5. Rebuild Host (add assetId NOT NULL+unique, backfilled with "H-<short>"), Manufacturer (drop eolDate),
-- Part (add partModelId via lookup + hostId nullable, drop mpn + replacementPartId), and RepairJob
-- (drop partId, add problem with COALESCE(notes, fallback), hostId NOT NULL).
-- CreateTable: PartModel catalog
CREATE TABLE "PartModel" (
"id" TEXT NOT NULL PRIMARY KEY,
"manufacturerId" TEXT NOT NULL,
"mpn" TEXT NOT NULL,
"eolDate" DATETIME,
"notes" 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
);
-- Backfill PartModel from DISTINCT (manufacturerId, mpn) on existing Part rows, carrying the
-- current Manufacturer.eolDate into the new per-MPN eolDate. Admins re-tune per MPN afterward.
INSERT INTO "PartModel" ("id", "manufacturerId", "mpn", "eolDate", "createdAt", "updatedAt")
SELECT
lower(hex(randomblob(16))),
d."manufacturerId",
d."mpn",
m."eolDate",
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM (SELECT DISTINCT "manufacturerId", "mpn" FROM "Part") d
LEFT JOIN "Manufacturer" m ON m."id" = d."manufacturerId";
-- CreateTable: RepairJobPart join
CREATE TABLE "RepairJobPart" (
"repairJobId" TEXT NOT NULL,
"partId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY ("repairJobId", "partId"),
CONSTRAINT "RepairJobPart_repairJobId_fkey" FOREIGN KEY ("repairJobId") REFERENCES "RepairJob" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "RepairJobPart_partId_fkey" FOREIGN KEY ("partId") REFERENCES "Part" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- Snapshot old single-part repairs into the new join table so each historic repair still points at
-- its original problem part.
INSERT INTO "RepairJobPart" ("repairJobId", "partId", "createdAt")
SELECT "id", "partId", CURRENT_TIMESTAMP FROM "RepairJob";
-- CreateTable: RepairComment
CREATE TABLE "RepairComment" (
"id" TEXT NOT NULL PRIMARY KEY,
"repairJobId" TEXT NOT NULL,
"userId" TEXT,
"content" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RepairComment_repairJobId_fkey" FOREIGN KEY ("repairJobId") REFERENCES "RepairJob" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "RepairComment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- Ensure every RepairJob has a hostId before we tighten the column to NOT NULL. Repairs with a
-- NULL hostId get attached to a synthetic "__Unassigned__" host so no rows are lost; admins can
-- reassign them afterward.
INSERT INTO "Host" ("id", "name", "createdAt", "updatedAt")
SELECT lower(hex(randomblob(16))), '__Unassigned__', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE EXISTS (SELECT 1 FROM "RepairJob" WHERE "hostId" IS NULL)
AND NOT EXISTS (SELECT 1 FROM "Host" WHERE "name" = '__Unassigned__');
UPDATE "RepairJob"
SET "hostId" = (SELECT "id" FROM "Host" WHERE "name" = '__Unassigned__')
WHERE "hostId" IS NULL;
-- RedefineTables: destructive reshape of Host / Manufacturer / Part / RepairJob.
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
-- Host: add assetId NOT NULL + unique, backfilled with "H-<first-8-chars-of-id>" so existing rows
-- carry a deterministic placeholder admins can rename.
CREATE TABLE "new_Host" (
"id" TEXT NOT NULL PRIMARY KEY,
"assetId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"location" TEXT,
"notes" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Host" ("id", "assetId", "name", "location", "notes", "createdAt", "updatedAt")
SELECT "id", 'H-' || substr("id", 1, 8), "name", "location", "notes", "createdAt", "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");
-- Manufacturer: drop eolDate (EOL now lives on PartModel).
CREATE TABLE "new_Manufacturer" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Manufacturer" ("id", "name", "createdAt", "updatedAt")
SELECT "id", "name", "createdAt", "updatedAt" FROM "Manufacturer";
DROP TABLE "Manufacturer";
ALTER TABLE "new_Manufacturer" RENAME TO "Manufacturer";
CREATE UNIQUE INDEX "Manufacturer_name_key" ON "Manufacturer"("name");
-- Part: add partModelId (looked up via the backfilled PartModel rows), add hostId (nullable;
-- admins populate when deploying parts). Drop the free-text mpn column and the unused
-- replacementPartId self-relation.
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,
"categoryId" TEXT,
"hostId" 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_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category" ("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
);
INSERT INTO "new_Part" ("id", "serialNumber", "partModelId", "manufacturerId", "price", "state", "binId", "categoryId", "hostId", "notes", "createdAt", "updatedAt")
SELECT
p."id",
p."serialNumber",
(SELECT pm."id" FROM "PartModel" pm WHERE pm."manufacturerId" = p."manufacturerId" AND pm."mpn" = p."mpn" LIMIT 1),
p."manufacturerId",
p."price",
p."state",
p."binId",
p."categoryId",
NULL,
p."notes",
p."createdAt",
p."updatedAt"
FROM "Part" p;
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_categoryId_idx" ON "Part"("categoryId");
CREATE INDEX "Part_hostId_idx" ON "Part"("hostId");
-- RepairJob: drop partId (problem part now lives in RepairJobPart), require hostId, add problem
-- (carried over from notes as a best-effort fallback for historical rows).
CREATE TABLE "new_RepairJob" (
"id" TEXT NOT NULL PRIMARY KEY,
"hostId" TEXT NOT NULL,
"assigneeId" TEXT,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"problem" TEXT NOT NULL,
"openedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"closedAt" DATETIME,
"notes" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "RepairJob_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "RepairJob_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_RepairJob" ("id", "hostId", "assigneeId", "status", "problem", "openedAt", "closedAt", "notes", "createdAt", "updatedAt")
SELECT
r."id",
r."hostId",
r."assigneeId",
r."status",
COALESCE(r."notes", 'Imported repair — problem not recorded'),
r."openedAt",
r."closedAt",
r."notes",
r."createdAt",
r."updatedAt"
FROM "RepairJob" r;
DROP TABLE "RepairJob";
ALTER TABLE "new_RepairJob" RENAME TO "RepairJob";
CREATE INDEX "RepairJob_status_idx" ON "RepairJob"("status");
CREATE INDEX "RepairJob_hostId_idx" ON "RepairJob"("hostId");
CREATE INDEX "RepairJob_assigneeId_idx" ON "RepairJob"("assigneeId");
CREATE INDEX "RepairJob_status_openedAt_idx" ON "RepairJob"("status", "openedAt" DESC);
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- Indexes for the new catalog + join + comment tables.
CREATE INDEX "PartModel_manufacturerId_idx" ON "PartModel"("manufacturerId");
CREATE INDEX "PartModel_eolDate_idx" ON "PartModel"("eolDate");
CREATE UNIQUE INDEX "PartModel_manufacturerId_mpn_key" ON "PartModel"("manufacturerId", "mpn");
CREATE INDEX "RepairJobPart_partId_idx" ON "RepairJobPart"("partId");
CREATE INDEX "RepairComment_repairJobId_createdAt_idx" ON "RepairComment"("repairJobId", "createdAt");
+55 -13
View File
@@ -26,6 +26,7 @@ model User {
partEvents PartEvent[]
refreshTokens RefreshToken[]
repairAssignments RepairJob[] @relation("RepairAssignee")
repairComments RepairComment[]
savedViews SavedView[]
csvImportJobs CsvImportJob[]
}
@@ -47,10 +48,26 @@ model RefreshToken {
model Manufacturer {
id String @id @default(uuid())
name String @unique
eolDate DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
parts Part[]
partModels PartModel[]
}
model PartModel {
id String @id @default(uuid())
manufacturerId String
manufacturer Manufacturer @relation(fields: [manufacturerId], references: [id], onDelete: Restrict)
mpn String
eolDate DateTime?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
parts Part[]
@@unique([manufacturerId, mpn])
@@index([manufacturerId])
@@index([eolDate])
}
model Site {
@@ -99,7 +116,8 @@ model Category {
model Part {
id String @id @default(uuid())
serialNumber String @unique
mpn String
partModelId String
partModel PartModel @relation(fields: [partModelId], references: [id], onDelete: Restrict)
manufacturerId String
manufacturer Manufacturer @relation(fields: [manufacturerId], references: [id], onDelete: Restrict)
price Float?
@@ -108,22 +126,21 @@ model Part {
bin Bin? @relation(fields: [binId], references: [id], onDelete: SetNull)
categoryId String?
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
replacementPartId String?
replacement Part? @relation("PartReplacement", fields: [replacementPartId], references: [id], onDelete: SetNull)
replacedBy Part[] @relation("PartReplacement")
hostId String?
host Host? @relation(fields: [hostId], references: [id], onDelete: SetNull)
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
events PartEvent[]
tags PartTag[]
repairs RepairJob[]
problemInRepairs RepairJobPart[]
@@index([state])
@@index([binId])
@@index([manufacturerId])
@@index([mpn])
@@index([partModelId])
@@index([categoryId])
@@index([replacementPartId])
@@index([hostId])
}
model PartEvent {
@@ -164,36 +181,61 @@ model PartTag {
model Host {
id String @id @default(uuid())
assetId String @unique
name String @unique
location String?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
parts Part[]
repairs RepairJob[]
}
model RepairJob {
id String @id @default(uuid())
partId String
part Part @relation(fields: [partId], references: [id], onDelete: Cascade)
hostId String?
host Host? @relation(fields: [hostId], references: [id], onDelete: SetNull)
hostId String
host Host @relation(fields: [hostId], references: [id], onDelete: Restrict)
assigneeId String?
assignee User? @relation("RepairAssignee", fields: [assigneeId], references: [id], onDelete: SetNull)
status String @default("PENDING")
problem String
openedAt DateTime @default(now())
closedAt DateTime?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
problemParts RepairJobPart[]
comments RepairComment[]
@@index([partId])
@@index([status])
@@index([hostId])
@@index([assigneeId])
@@index([status, openedAt(sort: Desc)])
}
model RepairJobPart {
repairJobId String
partId String
repairJob RepairJob @relation(fields: [repairJobId], references: [id], onDelete: Cascade)
part Part @relation(fields: [partId], references: [id], onDelete: Restrict)
createdAt DateTime @default(now())
@@id([repairJobId, partId])
@@index([partId])
}
model RepairComment {
id String @id @default(uuid())
repairJobId String
repairJob RepairJob @relation(fields: [repairJobId], references: [id], onDelete: Cascade)
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
content String
createdAt DateTime @default(now())
@@index([repairJobId, createdAt])
}
model WebhookSubscription {
id String @id @default(uuid())
url String
+6 -4
View File
@@ -18,10 +18,12 @@ export interface BinCount {
count: number;
}
export interface ManufacturerEolSummary {
export interface PartModelEolSummary {
partModelId: string;
mpn: string;
manufacturerId: string;
name: string;
eolDate: string | null;
manufacturerName: string;
eolDate: string;
deployedCount: number;
}
@@ -30,6 +32,6 @@ export interface DashboardAnalytics {
byState: StateCount[];
ageBuckets: AgeBucket[];
topBins: BinCount[];
deployedPastEol: ManufacturerEolSummary[];
deployedPastEol: PartModelEolSummary[];
openRepairs: number;
}
+2
View File
@@ -13,6 +13,8 @@ export const PartEventType = z.enum([
'FIELD_UPDATED',
'REPAIR_STARTED',
'REPAIR_COMPLETED',
'REPAIR_CANCELLED',
'REPAIR_COMMENTED',
'TAG_ADDED',
'TAG_REMOVED',
]);
+8
View File
@@ -1,7 +1,14 @@
import { z } from 'zod';
import { PaginationQuery } from './pagination.js';
const AssetId = z
.string()
.trim()
.min(1, 'Asset ID is required')
.max(64, 'Asset ID must be 64 characters or fewer');
export const CreateHostRequest = z.object({
assetId: AssetId,
name: z.string().min(1).max(128),
location: z.string().max(256).optional().nullable(),
notes: z.string().max(4096).optional().nullable(),
@@ -10,6 +17,7 @@ export type CreateHostRequest = z.infer<typeof CreateHostRequest>;
export const UpdateHostRequest = z
.object({
assetId: AssetId.optional(),
name: z.string().min(1).max(128).optional(),
location: z.string().max(256).nullable().optional(),
notes: z.string().max(4096).nullable().optional(),
+1
View File
@@ -2,6 +2,7 @@ export * from './enums.js';
export * from './auth.js';
export * from './users.js';
export * from './manufacturers.js';
export * from './part-models.js';
export * from './locations.js';
export * from './parts.js';
export * from './env.js';
-6
View File
@@ -1,19 +1,13 @@
import { z } from 'zod';
// ISO datetime string (e.g. "2027-12-31T00:00:00.000Z"). Clients may send date-only "2027-12-31";
// API layer is expected to coerce to Date.
const IsoDate = z.string().datetime({ offset: true }).or(z.string().regex(/^\d{4}-\d{2}-\d{2}$/));
export const CreateManufacturerRequest = z.object({
name: z.string().min(1).max(128),
eolDate: IsoDate.optional().nullable(),
});
export type CreateManufacturerRequest = z.infer<typeof CreateManufacturerRequest>;
export const UpdateManufacturerRequest = z
.object({
name: z.string().min(1).max(128).optional(),
eolDate: IsoDate.nullable().optional(),
})
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
export type UpdateManufacturerRequest = z.infer<typeof UpdateManufacturerRequest>;
+29
View File
@@ -0,0 +1,29 @@
import { z } from 'zod';
import { PaginationQuery } from './pagination.js';
const IsoDate = z.string().datetime({ offset: true }).or(z.string().regex(/^\d{4}-\d{2}-\d{2}$/));
export const CreatePartModelRequest = z.object({
manufacturerId: z.string().uuid(),
mpn: z.string().min(1).max(128),
eolDate: IsoDate.nullable().optional(),
notes: z.string().max(4096).nullable().optional(),
});
export type CreatePartModelRequest = z.infer<typeof CreatePartModelRequest>;
export const UpdatePartModelRequest = z
.object({
manufacturerId: z.string().uuid().optional(),
mpn: z.string().min(1).max(128).optional(),
eolDate: IsoDate.nullable().optional(),
notes: z.string().max(4096).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(),
q: z.string().max(128).optional(),
eolBefore: IsoDate.optional(),
});
export type PartModelListQuery = z.infer<typeof PartModelListQuery>;
+31 -17
View File
@@ -3,50 +3,54 @@ import { BulkPartsRequest, CreatePartRequest, PartListQuery, UpdatePartRequest }
const mfgId = '11111111-1111-4111-8111-111111111111';
const binId = '22222222-2222-4222-8222-222222222222';
const modelId = '44444444-4444-4444-8444-444444444444';
describe('CreatePartRequest', () => {
it('accepts a minimal valid payload', () => {
it('accepts a partModelId-based payload', () => {
const r = CreatePartRequest.parse({
serialNumber: 'SN-1',
mpn: 'MPN-1',
manufacturerId: mfgId,
partModelId: modelId,
});
expect(r.serialNumber).toBe('SN-1');
});
it('rejects empty serial / mpn', () => {
it('accepts a manufacturerId + mpn payload (auto-upsert path)', () => {
const r = CreatePartRequest.parse({
serialNumber: 'SN-1',
manufacturerId: mfgId,
mpn: 'MPN-1',
});
expect(r.mpn).toBe('MPN-1');
});
it('rejects when neither partModelId nor (manufacturerId + mpn) is provided', () => {
expect(
CreatePartRequest.safeParse({ serialNumber: '', mpn: 'X', manufacturerId: mfgId }).success,
CreatePartRequest.safeParse({ serialNumber: 'SN-1', manufacturerId: mfgId }).success,
).toBe(false);
expect(CreatePartRequest.safeParse({ serialNumber: 'SN-1' }).success).toBe(false);
});
it('rejects empty serial', () => {
expect(
CreatePartRequest.safeParse({ serialNumber: 'X', mpn: '', manufacturerId: mfgId }).success,
CreatePartRequest.safeParse({ serialNumber: '', partModelId: modelId }).success,
).toBe(false);
});
it('rejects negative price', () => {
const res = CreatePartRequest.safeParse({
serialNumber: 'X',
mpn: 'Y',
manufacturerId: mfgId,
partModelId: modelId,
price: -1,
});
expect(res.success).toBe(false);
});
it('rejects non-uuid manufacturer id', () => {
expect(
CreatePartRequest.safeParse({ serialNumber: 'X', mpn: 'Y', manufacturerId: 'not-uuid' })
.success,
).toBe(false);
});
it('caps tagIds at 32', () => {
const tagIds = Array.from({ length: 33 }, () => '33333333-3333-4333-8333-333333333333');
expect(
CreatePartRequest.safeParse({
serialNumber: 'X',
mpn: 'Y',
manufacturerId: mfgId,
partModelId: modelId,
tagIds,
}).success,
).toBe(false);
@@ -66,6 +70,11 @@ describe('UpdatePartRequest', () => {
const r = UpdatePartRequest.parse({ binId: null });
expect(r.binId).toBeNull();
});
it('permits nullable hostId to clear host assignment', () => {
const r = UpdatePartRequest.parse({ hostId: null });
expect(r.hostId).toBeNull();
});
});
describe('PartListQuery', () => {
@@ -106,6 +115,11 @@ describe('BulkPartsRequest', () => {
expect(r.binId).toBeNull();
});
it('accepts hostId=null to unassign', () => {
const r = BulkPartsRequest.parse({ ids: [mfgId], hostId: null });
expect(r.hostId).toBeNull();
});
it('caps ids at 500', () => {
const ids = Array.from({ length: 501 }, () => binId);
expect(BulkPartsRequest.safeParse({ ids, state: 'SPARE' }).success).toBe(false);
+40 -8
View File
@@ -2,31 +2,59 @@ import { z } from 'zod';
import { PartState } from './enums.js';
import { PaginationQuery } from './pagination.js';
export const CreatePartRequest = z.object({
// 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' },
);
export const CreatePartRequest = z
.object({
serialNumber: z.string().min(1).max(128),
mpn: z.string().min(1).max(128),
manufacturerId: z.string().uuid(),
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(),
notes: z.string().max(4096).optional().nullable(),
categoryId: z.string().uuid().optional().nullable(),
replacementPartId: z.string().uuid().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'],
});
}
});
export type CreatePartRequest = z.infer<typeof CreatePartRequest>;
export const UpdatePartRequest = z
.object({
serialNumber: z.string().min(1).max(128).optional(),
mpn: z.string().min(1).max(128).optional(),
manufacturerId: z.string().uuid().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(),
notes: z.string().max(4096).nullable().optional(),
categoryId: z.string().uuid().nullable().optional(),
replacementPartId: 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' });
@@ -36,6 +64,8 @@ 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(),
mpn: z.string().max(128).optional(),
serialNumber: z.string().max(128).optional(),
q: z.string().max(128).optional(),
@@ -56,6 +86,7 @@ export const BulkPartsRequest = z
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(),
})
@@ -63,6 +94,7 @@ export const BulkPartsRequest = z
(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' },
+14 -4
View File
@@ -3,8 +3,9 @@ import { RepairStatus } from './enums.js';
import { PaginationQuery } from './pagination.js';
export const CreateRepairJobRequest = z.object({
partId: z.string().uuid(),
hostId: z.string().uuid().optional().nullable(),
hostId: z.string().uuid(),
problem: z.string().trim().min(1, 'Problem is required').max(2000),
problemPartIds: z.array(z.string().uuid()).max(100).optional(),
assigneeId: z.string().uuid().optional().nullable(),
notes: z.string().max(4096).optional().nullable(),
});
@@ -13,7 +14,8 @@ export type CreateRepairJobRequest = z.infer<typeof CreateRepairJobRequest>;
export const UpdateRepairJobRequest = z
.object({
status: RepairStatus.optional(),
hostId: z.string().uuid().nullable().optional(),
problem: z.string().trim().min(1).max(2000).optional(),
problemPartIds: z.array(z.string().uuid()).max(100).optional(),
assigneeId: z.string().uuid().nullable().optional(),
notes: z.string().max(4096).nullable().optional(),
})
@@ -22,8 +24,8 @@ export type UpdateRepairJobRequest = z.infer<typeof UpdateRepairJobRequest>;
export const RepairJobListQuery = PaginationQuery.extend({
status: RepairStatus.optional(),
partId: z.string().uuid().optional(),
hostId: z.string().uuid().optional(),
problemPartId: z.string().uuid().optional(),
assigneeId: z.string().uuid().optional(),
openOnly: z
.union([z.literal('true'), z.literal('false'), z.boolean()])
@@ -31,3 +33,11 @@ export const RepairJobListQuery = PaginationQuery.extend({
.optional(),
});
export type RepairJobListQuery = z.infer<typeof RepairJobListQuery>;
export const CreateRepairCommentRequest = z.object({
content: z.string().trim().min(1, 'Comment cannot be empty').max(4000),
});
export type CreateRepairCommentRequest = z.infer<typeof CreateRepairCommentRequest>;
export const RepairCommentListQuery = PaginationQuery;
export type RepairCommentListQuery = z.infer<typeof RepairCommentListQuery>;