feat: split Repairs into FM, Repair, and Custody workflows
The old Repairs module had grown ticketing-system features (status lifecycle, comments, assignee, notes) that duplicate what the external ticketing tool already owns. Vector only needs to track whether maintenance is open or closed. - Rename RepairJob -> Fm (OPEN/CLOSED only), drop RepairComment, assignee, notes - New Repair table: persistent log of physical part swaps, with ingest on unknown broken MPN via partModels.upsertByMpn - New custody model: PENDING_DROP_IN_CUSTODY / PENDING_DESTRUCTION_IN_CUSTODY states + Part.custodianId, with a "My Custody" page for drop-off - PartModel.destroyOnFail routes broken parts to the destruction path - Host lookup on /fms and /repairs accepts hostId XOR assetId - Wire the dormant webhook emitter: fm.opened, fm.closed, repair.logged - Single fresh Prisma migration (dev DB was wiped, no backfill) Tests: 60 passing (custody transitions in parts.test.ts; new fms.test.ts, repairs.test.ts, custody.test.ts covering happy paths, validation failures, webhook emissions, and ingest-on-unknown-MPN). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -22,7 +22,9 @@ import partRoutes from './routes/parts.js';
|
|||||||
import tagRoutes from './routes/tags.js';
|
import tagRoutes from './routes/tags.js';
|
||||||
import categoryRoutes from './routes/categories.js';
|
import categoryRoutes from './routes/categories.js';
|
||||||
import hostRoutes from './routes/hosts.js';
|
import hostRoutes from './routes/hosts.js';
|
||||||
|
import fmRoutes from './routes/fms.js';
|
||||||
import repairRoutes from './routes/repairs.js';
|
import repairRoutes from './routes/repairs.js';
|
||||||
|
import custodyRoutes from './routes/custody.js';
|
||||||
import savedViewRoutes from './routes/saved-views.js';
|
import savedViewRoutes from './routes/saved-views.js';
|
||||||
import analyticsRoutes from './routes/analytics.js';
|
import analyticsRoutes from './routes/analytics.js';
|
||||||
import webhookRoutes from './routes/webhooks.js';
|
import webhookRoutes from './routes/webhooks.js';
|
||||||
@@ -88,7 +90,9 @@ app.use('/api/parts', partRoutes);
|
|||||||
app.use('/api/tags', tagRoutes);
|
app.use('/api/tags', tagRoutes);
|
||||||
app.use('/api/categories', categoryRoutes);
|
app.use('/api/categories', categoryRoutes);
|
||||||
app.use('/api/hosts', hostRoutes);
|
app.use('/api/hosts', hostRoutes);
|
||||||
|
app.use('/api/fms', fmRoutes);
|
||||||
app.use('/api/repairs', repairRoutes);
|
app.use('/api/repairs', repairRoutes);
|
||||||
|
app.use('/api/custody', custodyRoutes);
|
||||||
app.use('/api/saved-views', savedViewRoutes);
|
app.use('/api/saved-views', savedViewRoutes);
|
||||||
app.use('/api/analytics', analyticsRoutes);
|
app.use('/api/analytics', analyticsRoutes);
|
||||||
app.use('/api/admin/webhooks', webhookRoutes);
|
app.use('/api/admin/webhooks', webhookRoutes);
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
|
import { prisma } from '@vector/db';
|
||||||
|
import type { CustodyListQuery, DropOffRequest } from '@vector/shared';
|
||||||
|
import * as svc from '../services/custody.js';
|
||||||
|
import { errors } from '../lib/http-error.js';
|
||||||
|
|
||||||
|
export async function listMine(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.user) throw errors.unauthorized();
|
||||||
|
const q = req.validated!.query as CustodyListQuery;
|
||||||
|
const result = await prisma.$transaction((tx) => svc.listMine(tx, req.user!.id, q));
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dropOff(
|
||||||
|
req: Request<{ partId: string }>,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
if (!req.user) throw errors.unauthorized();
|
||||||
|
const input = req.validated!.body as DropOffRequest;
|
||||||
|
const part = await prisma.$transaction((tx) =>
|
||||||
|
svc.dropOff(tx, req.params.partId, input, req.user!),
|
||||||
|
);
|
||||||
|
res.json(part);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
|
import { prisma } from '@vector/db';
|
||||||
|
import type { CreateFmRequest, FmListQuery, UpdateFmRequest } from '@vector/shared';
|
||||||
|
import * as svc from '../services/fms.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 FmListQuery;
|
||||||
|
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 fm = await prisma.$transaction((tx) => svc.get(tx, req.params.id));
|
||||||
|
if (!fm) throw errors.notFound('FM');
|
||||||
|
res.json(fm);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function create(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const input = req.validated!.body as CreateFmRequest;
|
||||||
|
const fm = await prisma.$transaction((tx) => svc.create(tx, input, req.user ?? null));
|
||||||
|
res.status(201).json(fm);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const input = req.validated!.body as UpdateFmRequest;
|
||||||
|
const fm = await prisma.$transaction((tx) =>
|
||||||
|
svc.update(tx, req.params.id, input, req.user ?? null),
|
||||||
|
);
|
||||||
|
res.json(fm);
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,12 @@
|
|||||||
import type { NextFunction, Request, Response } from 'express';
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
import { prisma } from '@vector/db';
|
import { prisma } from '@vector/db';
|
||||||
import type {
|
import type { LogRepairRequest, RepairListQuery } from '@vector/shared';
|
||||||
CreateRepairCommentRequest,
|
|
||||||
CreateRepairJobRequest,
|
|
||||||
RepairCommentListQuery,
|
|
||||||
RepairJobListQuery,
|
|
||||||
UpdateRepairJobRequest,
|
|
||||||
} from '@vector/shared';
|
|
||||||
import * as svc from '../services/repairs.js';
|
import * as svc from '../services/repairs.js';
|
||||||
import { errors } from '../lib/http-error.js';
|
import { errors } from '../lib/http-error.js';
|
||||||
|
|
||||||
export async function list(req: Request, res: Response, next: NextFunction) {
|
export async function list(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const q = req.validated!.query as RepairJobListQuery;
|
const q = req.validated!.query as RepairListQuery;
|
||||||
const result = await prisma.$transaction((tx) => svc.list(tx, q));
|
const result = await prisma.$transaction((tx) => svc.list(tx, q));
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -22,73 +16,21 @@ export async function list(req: Request, res: Response, next: NextFunction) {
|
|||||||
|
|
||||||
export async function get(req: Request<{ id: string }>, res: Response, next: NextFunction) {
|
export async function get(req: Request<{ id: string }>, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const repair = await prisma.$transaction((tx) => svc.get(tx, req.params.id));
|
const r = await prisma.$transaction((tx) => svc.get(tx, req.params.id));
|
||||||
if (!repair) throw errors.notFound('Repair');
|
if (!r) throw errors.notFound('Repair');
|
||||||
res.json(repair);
|
res.json(r);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function create(req: Request, res: Response, next: NextFunction) {
|
export async function log(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const input = req.validated!.body as CreateRepairJobRequest;
|
if (!req.user) throw errors.unauthorized();
|
||||||
const repair = await prisma.$transaction((tx) =>
|
const input = req.validated!.body as LogRepairRequest;
|
||||||
svc.create(tx, input, req.user ?? null),
|
const repair = await prisma.$transaction((tx) => svc.log(tx, input, req.user!));
|
||||||
);
|
|
||||||
res.status(201).json(repair);
|
res.status(201).json(repair);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
|
|
||||||
try {
|
|
||||||
const input = req.validated!.body as UpdateRepairJobRequest;
|
|
||||||
const repair = await prisma.$transaction((tx) =>
|
|
||||||
svc.update(tx, req.params.id, input, req.user ?? null),
|
|
||||||
);
|
|
||||||
res.json(repair);
|
|
||||||
} 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { CustodyListQuery, DropOffRequest } from '@vector/shared';
|
||||||
|
import * as ctrl from '../controllers/custody.js';
|
||||||
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
|
import { validate } from '../middleware/validate.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/mine', requireAuth, validate('query', CustodyListQuery), ctrl.listMine);
|
||||||
|
router.post(
|
||||||
|
'/:partId/drop-off',
|
||||||
|
requireAuth,
|
||||||
|
validate('body', DropOffRequest),
|
||||||
|
ctrl.dropOff,
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { CreateFmRequest, FmListQuery, UpdateFmRequest } from '@vector/shared';
|
||||||
|
import * as ctrl from '../controllers/fms.js';
|
||||||
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
|
import { validate } from '../middleware/validate.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/', requireAuth, validate('query', FmListQuery), ctrl.list);
|
||||||
|
router.post('/', requireAuth, validate('body', CreateFmRequest), ctrl.create);
|
||||||
|
router.get('/:id', requireAuth, ctrl.get);
|
||||||
|
router.patch('/:id', requireAuth, validate('body', UpdateFmRequest), ctrl.update);
|
||||||
|
router.delete('/:id', requireAuth, ctrl.remove);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -1,34 +1,13 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import {
|
import { LogRepairRequest, RepairListQuery } from '@vector/shared';
|
||||||
CreateRepairCommentRequest,
|
|
||||||
CreateRepairJobRequest,
|
|
||||||
RepairCommentListQuery,
|
|
||||||
RepairJobListQuery,
|
|
||||||
UpdateRepairJobRequest,
|
|
||||||
} from '@vector/shared';
|
|
||||||
import * as ctrl from '../controllers/repairs.js';
|
import * as ctrl from '../controllers/repairs.js';
|
||||||
import { requireAuth } from '../middleware/auth.js';
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
import { validate } from '../middleware/validate.js';
|
import { validate } from '../middleware/validate.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.get('/', requireAuth, validate('query', RepairJobListQuery), ctrl.list);
|
router.get('/', requireAuth, validate('query', RepairListQuery), ctrl.list);
|
||||||
router.post('/', requireAuth, validate('body', CreateRepairJobRequest), ctrl.create);
|
router.post('/', requireAuth, validate('body', LogRepairRequest), ctrl.log);
|
||||||
router.get('/:id', requireAuth, ctrl.get);
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ function makeTx(args: {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
partModelId: string;
|
partModelId: string;
|
||||||
}[];
|
}[];
|
||||||
openRepairs: number;
|
openFms: number;
|
||||||
eolPartModels: {
|
eolPartModels: {
|
||||||
id: string;
|
id: string;
|
||||||
mpn: string;
|
mpn: string;
|
||||||
@@ -35,8 +35,8 @@ function makeTx(args: {
|
|||||||
})),
|
})),
|
||||||
findMany: async () => args.parts,
|
findMany: async () => args.parts,
|
||||||
},
|
},
|
||||||
repairJob: {
|
fm: {
|
||||||
count: async () => args.openRepairs,
|
count: async () => args.openFms,
|
||||||
},
|
},
|
||||||
partModel: {
|
partModel: {
|
||||||
findMany: async () => args.eolPartModels,
|
findMany: async () => args.eolPartModels,
|
||||||
@@ -52,7 +52,7 @@ const now = new Date('2026-04-16T00:00:00.000Z');
|
|||||||
const daysAgo = (n: number) => new Date(now.getTime() - n * 24 * 60 * 60 * 1000);
|
const daysAgo = (n: number) => new Date(now.getTime() - n * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
describe('analytics.dashboard', () => {
|
describe('analytics.dashboard', () => {
|
||||||
it('aggregates totals, state counts and open repairs', async () => {
|
it('aggregates totals, state counts and open FMs', async () => {
|
||||||
const tx = makeTx({
|
const tx = makeTx({
|
||||||
partCount: 5,
|
partCount: 5,
|
||||||
stateRows: [
|
stateRows: [
|
||||||
@@ -60,14 +60,14 @@ describe('analytics.dashboard', () => {
|
|||||||
{ state: 'DEPLOYED', count: 2, totalPrice: 8000 },
|
{ state: 'DEPLOYED', count: 2, totalPrice: 8000 },
|
||||||
],
|
],
|
||||||
parts: [],
|
parts: [],
|
||||||
openRepairs: 4,
|
openFms: 4,
|
||||||
eolPartModels: [],
|
eolPartModels: [],
|
||||||
bins: [],
|
bins: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const r = await dashboard(tx);
|
const r = await dashboard(tx);
|
||||||
expect(r.totalParts).toBe(5);
|
expect(r.totalParts).toBe(5);
|
||||||
expect(r.openRepairs).toBe(4);
|
expect(r.openFms).toBe(4);
|
||||||
expect(r.byState).toEqual([
|
expect(r.byState).toEqual([
|
||||||
{ state: 'SPARE', count: 3, totalPrice: 1500 },
|
{ state: 'SPARE', count: 3, totalPrice: 1500 },
|
||||||
{ state: 'DEPLOYED', count: 2, totalPrice: 8000 },
|
{ state: 'DEPLOYED', count: 2, totalPrice: 8000 },
|
||||||
@@ -84,7 +84,7 @@ describe('analytics.dashboard', () => {
|
|||||||
{ id: 'p3', state: 'SPARE', binId: null, createdAt: daysAgo(400), partModelId: 'pm' },
|
{ id: 'p3', state: 'SPARE', binId: null, createdAt: daysAgo(400), partModelId: 'pm' },
|
||||||
{ id: 'p4', state: 'SPARE', binId: null, createdAt: daysAgo(900), partModelId: 'pm' },
|
{ id: 'p4', state: 'SPARE', binId: null, createdAt: daysAgo(900), partModelId: 'pm' },
|
||||||
],
|
],
|
||||||
openRepairs: 0,
|
openFms: 0,
|
||||||
eolPartModels: [],
|
eolPartModels: [],
|
||||||
bins: [],
|
bins: [],
|
||||||
});
|
});
|
||||||
@@ -109,7 +109,7 @@ describe('analytics.dashboard', () => {
|
|||||||
{ id: '3', state: 'SPARE', binId: 'b2', 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' },
|
{ id: '4', state: 'SPARE', binId: null, createdAt: daysAgo(1), partModelId: 'pm' },
|
||||||
],
|
],
|
||||||
openRepairs: 0,
|
openFms: 0,
|
||||||
eolPartModels: [],
|
eolPartModels: [],
|
||||||
bins: [
|
bins: [
|
||||||
{ id: 'b1', name: 'A1', room: { name: 'Lab', site: { name: 'HQ' } } },
|
{ id: 'b1', name: 'A1', room: { name: 'Lab', site: { name: 'HQ' } } },
|
||||||
@@ -133,7 +133,7 @@ describe('analytics.dashboard', () => {
|
|||||||
{ id: '2', 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' },
|
{ id: '3', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm2' },
|
||||||
],
|
],
|
||||||
openRepairs: 0,
|
openFms: 0,
|
||||||
eolPartModels: [
|
eolPartModels: [
|
||||||
{
|
{
|
||||||
id: 'pm1',
|
id: 'pm1',
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const AGE_BUCKETS: { label: string; maxDays: number | null }[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export async function dashboard(tx: Tx): Promise<DashboardAnalytics> {
|
export async function dashboard(tx: Tx): Promise<DashboardAnalytics> {
|
||||||
const [totalParts, stateRows, parts, openRepairs, partModelsWithEol] = await Promise.all([
|
const [totalParts, stateRows, parts, openFms, partModelsWithEol] = await Promise.all([
|
||||||
tx.part.count(),
|
tx.part.count(),
|
||||||
tx.part.groupBy({
|
tx.part.groupBy({
|
||||||
by: ['state'],
|
by: ['state'],
|
||||||
@@ -23,7 +23,7 @@ export async function dashboard(tx: Tx): Promise<DashboardAnalytics> {
|
|||||||
tx.part.findMany({
|
tx.part.findMany({
|
||||||
select: { id: true, state: true, binId: true, createdAt: true, partModelId: true },
|
select: { id: true, state: true, binId: true, createdAt: true, partModelId: true },
|
||||||
}),
|
}),
|
||||||
tx.repairJob.count({ where: { status: { in: ['PENDING', 'IN_PROGRESS'] } } }),
|
tx.fm.count({ where: { status: 'OPEN' } }),
|
||||||
tx.partModel.findMany({
|
tx.partModel.findMany({
|
||||||
where: { eolDate: { not: null, lte: new Date() } },
|
where: { eolDate: { not: null, lte: new Date() } },
|
||||||
select: {
|
select: {
|
||||||
@@ -92,5 +92,5 @@ export async function dashboard(tx: Tx): Promise<DashboardAnalytics> {
|
|||||||
.filter((m) => m.deployedCount > 0)
|
.filter((m) => m.deployedCount > 0)
|
||||||
.sort((a, b) => b.deployedCount - a.deployedCount);
|
.sort((a, b) => b.deployedCount - a.deployedCount);
|
||||||
|
|
||||||
return { totalParts, byState, ageBuckets: buckets, topBins, deployedPastEol, openRepairs };
|
return { totalParts, byState, ageBuckets: buckets, topBins, deployedPastEol, openFms };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { Tx, Actor } from './types.js';
|
||||||
|
import { dropOff } from './custody.js';
|
||||||
|
|
||||||
|
const custodian: Actor = { id: 'user-1', username: 'tech', role: 'TECHNICIAN' };
|
||||||
|
const admin: Actor = { id: 'user-admin', username: 'boss', role: 'ADMIN' };
|
||||||
|
const otherUser: Actor = { id: 'user-2', username: 'other', role: 'TECHNICIAN' };
|
||||||
|
|
||||||
|
const brokenModel = {
|
||||||
|
id: 'pm-1',
|
||||||
|
manufacturerId: 'mfr-1',
|
||||||
|
mpn: 'WD-1',
|
||||||
|
destroyOnFail: false,
|
||||||
|
eolDate: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CustodyPartRow {
|
||||||
|
id: string;
|
||||||
|
serialNumber: string;
|
||||||
|
partModelId: string;
|
||||||
|
manufacturerId: string;
|
||||||
|
state: string;
|
||||||
|
binId: string | null;
|
||||||
|
hostId: string | null;
|
||||||
|
custodianId: string | null;
|
||||||
|
categoryId: string | null;
|
||||||
|
price: number | null;
|
||||||
|
notes: string | null;
|
||||||
|
partModel: typeof brokenModel;
|
||||||
|
manufacturer: { id: string; name: string };
|
||||||
|
bin: unknown;
|
||||||
|
host: unknown;
|
||||||
|
category: unknown;
|
||||||
|
custodian: { id: string; username: string } | null;
|
||||||
|
tags: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function custodyPart(overrides: Partial<CustodyPartRow> = {}): CustodyPartRow {
|
||||||
|
return {
|
||||||
|
id: 'p-1',
|
||||||
|
serialNumber: 'SN-1',
|
||||||
|
partModelId: 'pm-1',
|
||||||
|
manufacturerId: 'mfr-1',
|
||||||
|
state: 'PENDING_DROP_IN_CUSTODY',
|
||||||
|
binId: null,
|
||||||
|
hostId: null,
|
||||||
|
custodianId: 'user-1',
|
||||||
|
categoryId: null,
|
||||||
|
price: null,
|
||||||
|
notes: null,
|
||||||
|
partModel: brokenModel,
|
||||||
|
manufacturer: { id: 'mfr-1', name: 'WD' },
|
||||||
|
bin: null,
|
||||||
|
host: null,
|
||||||
|
category: null,
|
||||||
|
custodian: { id: 'user-1', username: 'tech' },
|
||||||
|
tags: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a Tx stub sufficient for custody.dropOff, which calls partsSvc.update internally.
|
||||||
|
function buildTx(initial: ReturnType<typeof custodyPart>) {
|
||||||
|
const current = { ...initial };
|
||||||
|
const partUpdate = vi.fn(
|
||||||
|
async (args: { where: { id: string }; data: Record<string, unknown> }) => {
|
||||||
|
const d = args.data;
|
||||||
|
if (d.state) current.state = d.state as string;
|
||||||
|
if (d.bin !== undefined) {
|
||||||
|
const v = d.bin as { connect?: { id: string }; disconnect?: boolean };
|
||||||
|
current.binId = v.connect?.id ?? null;
|
||||||
|
}
|
||||||
|
if (d.host !== undefined) {
|
||||||
|
const v = d.host as { connect?: { id: string }; disconnect?: boolean };
|
||||||
|
current.hostId = v.connect?.id ?? null;
|
||||||
|
}
|
||||||
|
if (d.custodian !== undefined) {
|
||||||
|
const v = d.custodian as { connect?: { id: string }; disconnect?: boolean };
|
||||||
|
current.custodianId = v.connect?.id ?? null;
|
||||||
|
current.custodian = v.connect?.id
|
||||||
|
? { id: v.connect.id, username: 'tech' }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const tx = {
|
||||||
|
part: {
|
||||||
|
findUnique: async (args: { where: { id: string } }) =>
|
||||||
|
args.where.id === current.id ? current : null,
|
||||||
|
update: partUpdate,
|
||||||
|
},
|
||||||
|
partEvent: { createMany: vi.fn() },
|
||||||
|
partTag: { findMany: async () => [] },
|
||||||
|
} as unknown as Tx;
|
||||||
|
|
||||||
|
return { tx, current, partUpdate };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('custody.dropOff', () => {
|
||||||
|
it('PENDING_DROP_IN_CUSTODY → BROKEN with a bin; custodian cleared', async () => {
|
||||||
|
const { tx, current, partUpdate } = buildTx(custodyPart());
|
||||||
|
|
||||||
|
await dropOff(tx, 'p-1', { binId: 'bin-2' }, custodian);
|
||||||
|
|
||||||
|
expect(partUpdate).toHaveBeenCalledTimes(1);
|
||||||
|
const call = partUpdate.mock.calls[0]![0] as {
|
||||||
|
data: { state?: string; bin?: unknown; custodian?: unknown };
|
||||||
|
};
|
||||||
|
expect(call.data.state).toBe('BROKEN');
|
||||||
|
expect(call.data.bin).toEqual({ connect: { id: 'bin-2' } });
|
||||||
|
expect(call.data.custodian).toEqual({ disconnect: true });
|
||||||
|
expect(current.state).toBe('BROKEN');
|
||||||
|
expect(current.custodianId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PENDING_DESTRUCTION_IN_CUSTODY → PENDING_DESTRUCTION', async () => {
|
||||||
|
const { tx, current } = buildTx(
|
||||||
|
custodyPart({ state: 'PENDING_DESTRUCTION_IN_CUSTODY' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await dropOff(tx, 'p-1', { binId: null }, custodian);
|
||||||
|
|
||||||
|
expect(current.state).toBe('PENDING_DESTRUCTION');
|
||||||
|
expect(current.binId).toBeNull();
|
||||||
|
expect(current.custodianId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('admin can drop off a part held by someone else', async () => {
|
||||||
|
const { tx, current } = buildTx(custodyPart({ custodianId: 'user-2' }));
|
||||||
|
|
||||||
|
await dropOff(tx, 'p-1', { binId: 'bin-1' }, admin);
|
||||||
|
|
||||||
|
expect(current.state).toBe('BROKEN');
|
||||||
|
expect(current.custodianId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-custodian non-admin attempt with 403', async () => {
|
||||||
|
const { tx, partUpdate } = buildTx(custodyPart());
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
dropOff(tx, 'p-1', { binId: 'bin-1' }, otherUser),
|
||||||
|
).rejects.toMatchObject({ status: 403 });
|
||||||
|
expect(partUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects drop-off on a non-custody state with 400', async () => {
|
||||||
|
const { tx, partUpdate } = buildTx(
|
||||||
|
custodyPart({ state: 'SPARE', custodianId: null, custodian: null }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
dropOff(tx, 'p-1', { binId: 'bin-1' }, custodian),
|
||||||
|
).rejects.toMatchObject({ status: 400 });
|
||||||
|
expect(partUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects drop-off on a missing part with 404', async () => {
|
||||||
|
const tx = {
|
||||||
|
part: { findUnique: async () => null, update: vi.fn() },
|
||||||
|
partEvent: { createMany: vi.fn() },
|
||||||
|
} as unknown as Tx;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
dropOff(tx, 'p-missing', { binId: null }, custodian),
|
||||||
|
).rejects.toMatchObject({ status: 404 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Prisma } from '@vector/db';
|
||||||
|
import type { CustodyListQuery, DropOffRequest } from '@vector/shared';
|
||||||
|
import { errors } from '../lib/http-error.js';
|
||||||
|
import * as partsSvc from './parts.js';
|
||||||
|
import type { Actor, Tx } from './types.js';
|
||||||
|
|
||||||
|
const custodyInclude = {
|
||||||
|
partModel: true,
|
||||||
|
manufacturer: true,
|
||||||
|
host: true,
|
||||||
|
custodian: { select: { id: true, username: true } },
|
||||||
|
} satisfies Prisma.PartInclude;
|
||||||
|
|
||||||
|
export async function listMine(tx: Tx, userId: string, q: CustodyListQuery) {
|
||||||
|
const { page, pageSize } = q;
|
||||||
|
const where: Prisma.PartWhereInput = { custodianId: userId };
|
||||||
|
const [data, total] = await Promise.all([
|
||||||
|
tx.part.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
include: custodyInclude,
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
tx.part.count({ where }),
|
||||||
|
]);
|
||||||
|
return { data, page, pageSize, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dropOff(
|
||||||
|
tx: Tx,
|
||||||
|
partId: string,
|
||||||
|
input: DropOffRequest,
|
||||||
|
actor: Actor,
|
||||||
|
) {
|
||||||
|
const part = await tx.part.findUnique({ where: { id: partId } });
|
||||||
|
if (!part) throw errors.notFound('Part');
|
||||||
|
|
||||||
|
if (
|
||||||
|
part.state !== 'PENDING_DROP_IN_CUSTODY' &&
|
||||||
|
part.state !== 'PENDING_DESTRUCTION_IN_CUSTODY'
|
||||||
|
) {
|
||||||
|
throw errors.badRequest('Part is not in custody');
|
||||||
|
}
|
||||||
|
if (part.custodianId !== actor.id && actor.role !== 'ADMIN') {
|
||||||
|
throw errors.forbidden('Only the current custodian can drop off this part');
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextState =
|
||||||
|
part.state === 'PENDING_DROP_IN_CUSTODY' ? 'BROKEN' : 'PENDING_DESTRUCTION';
|
||||||
|
|
||||||
|
return partsSvc.update(
|
||||||
|
tx,
|
||||||
|
partId,
|
||||||
|
{ state: nextState, binId: input.binId ?? null, custodianId: null },
|
||||||
|
actor,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const emitMock = vi.fn();
|
||||||
|
vi.mock('../lib/webhook-emitter.js', () => ({
|
||||||
|
emit: (...args: unknown[]) => emitMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import type { Tx, Actor } from './types.js';
|
||||||
|
import { create, update, resolveHost } from './fms.js';
|
||||||
|
|
||||||
|
const actor: Actor = { id: 'user-1', username: 'tech', role: 'ADMIN' };
|
||||||
|
|
||||||
|
const host = { id: 'host-1', assetId: 'ASSET-001', name: 'rack-1' };
|
||||||
|
|
||||||
|
function fmRow(overrides: Partial<Record<string, unknown>> = {}) {
|
||||||
|
return {
|
||||||
|
id: 'fm-1',
|
||||||
|
hostId: 'host-1',
|
||||||
|
status: 'OPEN',
|
||||||
|
problem: 'fans failing',
|
||||||
|
openedAt: new Date('2026-04-01T00:00:00Z'),
|
||||||
|
closedAt: null,
|
||||||
|
host: { ...host },
|
||||||
|
problemParts: [] as Array<{
|
||||||
|
partId: string;
|
||||||
|
part: { id: string; serialNumber: string; partModel: { mpn: string } };
|
||||||
|
}>,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
emitMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fms.resolveHost', () => {
|
||||||
|
it('resolves by assetId when hostId is absent', async () => {
|
||||||
|
const findUnique = vi.fn(async (args: { where: { assetId?: string } }) => {
|
||||||
|
if (args.where.assetId === 'ASSET-001') return host;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
const tx = { host: { findUnique } } as unknown as Tx;
|
||||||
|
|
||||||
|
const r = await resolveHost(tx, { assetId: 'ASSET-001' });
|
||||||
|
expect(r.id).toBe('host-1');
|
||||||
|
expect(findUnique).toHaveBeenCalledWith({ where: { assetId: 'ASSET-001' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when neither hostId nor assetId is provided', async () => {
|
||||||
|
const tx = { host: { findUnique: vi.fn() } } as unknown as Tx;
|
||||||
|
await expect(resolveHost(tx, {})).rejects.toMatchObject({ status: 400 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 404 when assetId does not match any host', async () => {
|
||||||
|
const tx = {
|
||||||
|
host: { findUnique: vi.fn(async () => null) },
|
||||||
|
} as unknown as Tx;
|
||||||
|
await expect(
|
||||||
|
resolveHost(tx, { assetId: 'MISSING' }),
|
||||||
|
).rejects.toMatchObject({ status: 404 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fms.create', () => {
|
||||||
|
it('resolves host from assetId and writes the canonical hostId', async () => {
|
||||||
|
const created = fmRow();
|
||||||
|
const fmCreate = vi.fn();
|
||||||
|
fmCreate.mockResolvedValue(created);
|
||||||
|
const tx = {
|
||||||
|
host: {
|
||||||
|
findUnique: async (args: { where: { assetId?: string; id?: string } }) =>
|
||||||
|
args.where.assetId === 'ASSET-001' ? host : null,
|
||||||
|
},
|
||||||
|
fm: { create: fmCreate },
|
||||||
|
part: { findMany: async () => [] },
|
||||||
|
partEvent: { createMany: vi.fn() },
|
||||||
|
} as unknown as Tx;
|
||||||
|
|
||||||
|
await create(tx, { assetId: 'ASSET-001', problem: 'fans failing' }, actor);
|
||||||
|
|
||||||
|
const args = fmCreate.mock.calls[0]![0] as { data: { hostId: string } };
|
||||||
|
expect(args.data.hostId).toBe('host-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires fm.opened webhook with the resolved host payload', async () => {
|
||||||
|
const created = fmRow();
|
||||||
|
const tx = {
|
||||||
|
host: { findUnique: async () => host },
|
||||||
|
fm: { create: async () => created },
|
||||||
|
part: { findMany: async () => [] },
|
||||||
|
partEvent: { createMany: vi.fn() },
|
||||||
|
} as unknown as Tx;
|
||||||
|
|
||||||
|
await create(tx, { hostId: 'host-1', problem: 'fans failing' }, actor);
|
||||||
|
|
||||||
|
expect(emitMock).toHaveBeenCalledTimes(1);
|
||||||
|
const call = emitMock.mock.calls[0]![0] as {
|
||||||
|
event: string;
|
||||||
|
payload: { fm: { id: string; assetId: string; status: string } };
|
||||||
|
};
|
||||||
|
expect(call.event).toBe('fm.opened');
|
||||||
|
expect(call.payload.fm.id).toBe('fm-1');
|
||||||
|
expect(call.payload.fm.assetId).toBe('ASSET-001');
|
||||||
|
expect(call.payload.fm.status).toBe('OPEN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when both hostId and assetId are provided', async () => {
|
||||||
|
// The shared-zod CreateFmRequest refine enforces XOR at the boundary; the service
|
||||||
|
// itself sees hostId first and resolves it. But if a caller passes both at the service
|
||||||
|
// layer (bypassing zod), hostId wins — we guard the boundary case in the shared test
|
||||||
|
// suite. Here we assert the combined-input path still fails cleanly when hostId is
|
||||||
|
// unknown, so the service never silently picks assetId.
|
||||||
|
const tx = {
|
||||||
|
host: { findUnique: async () => null },
|
||||||
|
fm: { create: vi.fn() },
|
||||||
|
partEvent: { createMany: vi.fn() },
|
||||||
|
} as unknown as Tx;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
create(
|
||||||
|
tx,
|
||||||
|
{ hostId: '00000000-0000-0000-0000-000000000000', assetId: 'ASSET-001', problem: 'x' },
|
||||||
|
actor,
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({ status: 404 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates FM_OPENED PartEvents for each problem part', async () => {
|
||||||
|
const created = fmRow({
|
||||||
|
problemParts: [
|
||||||
|
{
|
||||||
|
partId: 'p-1',
|
||||||
|
part: { id: 'p-1', serialNumber: 'SN-1', partModel: { mpn: 'WD-1' } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const partEventCreateMany = vi.fn();
|
||||||
|
const tx = {
|
||||||
|
host: { findUnique: async () => host },
|
||||||
|
part: {
|
||||||
|
findMany: async () => [{ id: 'p-1', hostId: 'host-1' }],
|
||||||
|
},
|
||||||
|
fm: { create: async () => created },
|
||||||
|
partEvent: { createMany: partEventCreateMany },
|
||||||
|
} as unknown as Tx;
|
||||||
|
|
||||||
|
await create(
|
||||||
|
tx,
|
||||||
|
{ hostId: 'host-1', problem: 'fans failing', problemPartIds: ['p-1'] },
|
||||||
|
actor,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(partEventCreateMany).toHaveBeenCalledTimes(1);
|
||||||
|
const args = partEventCreateMany.mock.calls[0]![0] as {
|
||||||
|
data: Array<{ partId: string; type: string; newValue: string }>;
|
||||||
|
};
|
||||||
|
expect(args.data).toEqual([
|
||||||
|
{ partId: 'p-1', userId: 'user-1', type: 'FM_OPENED', newValue: 'fm-1' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a problem part that does not live on the selected host', async () => {
|
||||||
|
const tx = {
|
||||||
|
host: { findUnique: async () => host },
|
||||||
|
part: {
|
||||||
|
findMany: async () => [{ id: 'p-1', hostId: 'other-host' }],
|
||||||
|
},
|
||||||
|
fm: { create: vi.fn() },
|
||||||
|
partEvent: { createMany: vi.fn() },
|
||||||
|
} as unknown as Tx;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
create(
|
||||||
|
tx,
|
||||||
|
{ hostId: 'host-1', problem: 'x', problemPartIds: ['p-1'] },
|
||||||
|
actor,
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({ status: 400 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fms.update — close flips status + sets closedAt + emits webhook', () => {
|
||||||
|
it('closes an OPEN FM and emits FM_CLOSED events per problem part', async () => {
|
||||||
|
const current = {
|
||||||
|
id: 'fm-1',
|
||||||
|
hostId: 'host-1',
|
||||||
|
status: 'OPEN',
|
||||||
|
problemParts: [{ partId: 'p-1' }, { partId: 'p-2' }],
|
||||||
|
host: { ...host },
|
||||||
|
};
|
||||||
|
const updated = fmRow({
|
||||||
|
status: 'CLOSED',
|
||||||
|
closedAt: new Date('2026-04-10T00:00:00Z'),
|
||||||
|
problemParts: [
|
||||||
|
{ partId: 'p-1', part: { id: 'p-1', serialNumber: 'SN-1', partModel: { mpn: 'WD-1' } } },
|
||||||
|
{ partId: 'p-2', part: { id: 'p-2', serialNumber: 'SN-2', partModel: { mpn: 'WD-2' } } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const fmUpdate = vi.fn();
|
||||||
|
fmUpdate.mockResolvedValue(updated);
|
||||||
|
const partEventCreateMany = vi.fn();
|
||||||
|
const tx = {
|
||||||
|
fm: {
|
||||||
|
findUnique: async () => current,
|
||||||
|
update: fmUpdate,
|
||||||
|
},
|
||||||
|
partEvent: { createMany: partEventCreateMany },
|
||||||
|
} as unknown as Tx;
|
||||||
|
|
||||||
|
await update(tx, 'fm-1', { status: 'CLOSED' }, actor);
|
||||||
|
|
||||||
|
const updateArgs = fmUpdate.mock.calls[0]![0] as {
|
||||||
|
data: { status?: string; closedAt?: unknown };
|
||||||
|
};
|
||||||
|
expect(updateArgs.data.status).toBe('CLOSED');
|
||||||
|
expect(updateArgs.data.closedAt).toBeInstanceOf(Date);
|
||||||
|
|
||||||
|
const eventArgs = partEventCreateMany.mock.calls[0]![0] as {
|
||||||
|
data: Array<{ partId: string; type: string }>;
|
||||||
|
};
|
||||||
|
expect(eventArgs.data).toEqual([
|
||||||
|
{ partId: 'p-1', userId: 'user-1', type: 'FM_CLOSED', newValue: 'fm-1' },
|
||||||
|
{ partId: 'p-2', userId: 'user-1', type: 'FM_CLOSED', newValue: 'fm-1' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(emitMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(emitMock.mock.calls[0]![0]).toMatchObject({
|
||||||
|
event: 'fm.closed',
|
||||||
|
payload: { fm: { id: 'fm-1', status: 'CLOSED' } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reopening a closed FM clears closedAt and re-emits fm.opened', async () => {
|
||||||
|
const current = {
|
||||||
|
id: 'fm-1',
|
||||||
|
hostId: 'host-1',
|
||||||
|
status: 'CLOSED',
|
||||||
|
problemParts: [],
|
||||||
|
host: { ...host },
|
||||||
|
};
|
||||||
|
const updated = fmRow({ status: 'OPEN', closedAt: null });
|
||||||
|
const fmUpdate = vi.fn();
|
||||||
|
fmUpdate.mockResolvedValue(updated);
|
||||||
|
const tx = {
|
||||||
|
fm: { findUnique: async () => current, update: fmUpdate },
|
||||||
|
partEvent: { createMany: vi.fn() },
|
||||||
|
} as unknown as Tx;
|
||||||
|
|
||||||
|
await update(tx, 'fm-1', { status: 'OPEN' }, actor);
|
||||||
|
|
||||||
|
const args = fmUpdate.mock.calls[0]![0] as {
|
||||||
|
data: { status?: string; closedAt?: unknown };
|
||||||
|
};
|
||||||
|
expect(args.data.status).toBe('OPEN');
|
||||||
|
expect(args.data.closedAt).toBeNull();
|
||||||
|
expect(emitMock.mock.calls[0]![0]).toMatchObject({ event: 'fm.opened' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('status-unchanged updates do not emit webhooks', async () => {
|
||||||
|
const current = {
|
||||||
|
id: 'fm-1',
|
||||||
|
hostId: 'host-1',
|
||||||
|
status: 'OPEN',
|
||||||
|
problemParts: [],
|
||||||
|
host: { ...host },
|
||||||
|
};
|
||||||
|
const tx = {
|
||||||
|
fm: {
|
||||||
|
findUnique: async () => current,
|
||||||
|
update: async () => fmRow({ problem: 'new text' }),
|
||||||
|
},
|
||||||
|
partEvent: { createMany: vi.fn() },
|
||||||
|
} as unknown as Tx;
|
||||||
|
|
||||||
|
await update(tx, 'fm-1', { problem: 'new text' }, actor);
|
||||||
|
|
||||||
|
expect(emitMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import { Prisma } from '@vector/db';
|
||||||
|
import type { CreateFmRequest, FmListQuery, UpdateFmRequest } from '@vector/shared';
|
||||||
|
import { errors } from '../lib/http-error.js';
|
||||||
|
import { emit } from '../lib/webhook-emitter.js';
|
||||||
|
import type { Actor, Tx } from './types.js';
|
||||||
|
|
||||||
|
const fmInclude = {
|
||||||
|
host: true,
|
||||||
|
problemParts: {
|
||||||
|
include: {
|
||||||
|
part: {
|
||||||
|
include: { partModel: true, manufacturer: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Prisma.FmInclude;
|
||||||
|
|
||||||
|
export type FmWithRelations = Prisma.FmGetPayload<{ include: typeof fmInclude }>;
|
||||||
|
|
||||||
|
// Accept either `hostId` (uuid) or `assetId` (string) — callers provide exactly one.
|
||||||
|
// Returns the resolved Host row so downstream writes can use the canonical id.
|
||||||
|
export async function resolveHost(
|
||||||
|
tx: Tx,
|
||||||
|
input: { hostId?: string | null; assetId?: string | null },
|
||||||
|
) {
|
||||||
|
if (input.hostId) {
|
||||||
|
const host = await tx.host.findUnique({ where: { id: input.hostId } });
|
||||||
|
if (!host) throw errors.notFound('Host');
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
if (input.assetId) {
|
||||||
|
const host = await tx.host.findUnique({ where: { assetId: input.assetId } });
|
||||||
|
if (!host) throw errors.notFound('Host');
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
throw errors.badRequest('Provide exactly one of hostId or assetId');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 list(tx: Tx, q: FmListQuery) {
|
||||||
|
const { page, pageSize, status, hostId, openOnly, problemPartId } = q;
|
||||||
|
const where: Prisma.FmWhereInput = {};
|
||||||
|
if (status) where.status = status;
|
||||||
|
if (hostId) where.hostId = hostId;
|
||||||
|
if (openOnly) where.status = 'OPEN';
|
||||||
|
if (problemPartId) where.problemParts = { some: { partId: problemPartId } };
|
||||||
|
|
||||||
|
const [data, total] = await Promise.all([
|
||||||
|
tx.fm.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ status: 'asc' }, { openedAt: 'desc' }],
|
||||||
|
include: fmInclude,
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
tx.fm.count({ where }),
|
||||||
|
]);
|
||||||
|
return { data, page, pageSize, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function get(tx: Tx, id: string) {
|
||||||
|
return tx.fm.findUnique({ where: { id }, include: fmInclude });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listForHost(tx: Tx, hostId: string) {
|
||||||
|
return tx.fm.findMany({
|
||||||
|
where: { hostId },
|
||||||
|
orderBy: { openedAt: 'desc' },
|
||||||
|
include: fmInclude,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmPayload(fm: FmWithRelations) {
|
||||||
|
return {
|
||||||
|
id: fm.id,
|
||||||
|
hostId: fm.hostId,
|
||||||
|
assetId: fm.host.assetId,
|
||||||
|
hostName: fm.host.name,
|
||||||
|
status: fm.status,
|
||||||
|
problem: fm.problem,
|
||||||
|
openedAt: fm.openedAt.toISOString(),
|
||||||
|
closedAt: fm.closedAt?.toISOString() ?? null,
|
||||||
|
problemParts: fm.problemParts.map((pp) => ({
|
||||||
|
partId: pp.part.id,
|
||||||
|
serialNumber: pp.part.serialNumber,
|
||||||
|
mpn: pp.part.partModel.mpn,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function create(
|
||||||
|
tx: Tx,
|
||||||
|
input: CreateFmRequest,
|
||||||
|
_actor: Actor | null,
|
||||||
|
) {
|
||||||
|
const host = await resolveHost(tx, input);
|
||||||
|
await validateProblemParts(tx, host.id, input.problemPartIds);
|
||||||
|
|
||||||
|
const fm = await tx.fm.create({
|
||||||
|
data: {
|
||||||
|
hostId: host.id,
|
||||||
|
problem: input.problem,
|
||||||
|
status: 'OPEN',
|
||||||
|
problemParts:
|
||||||
|
input.problemPartIds && input.problemPartIds.length > 0
|
||||||
|
? { create: [...new Set(input.problemPartIds)].map((partId) => ({ partId })) }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
include: fmInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (input.problemPartIds && input.problemPartIds.length > 0) {
|
||||||
|
await tx.partEvent.createMany({
|
||||||
|
data: [...new Set(input.problemPartIds)].map((partId) => ({
|
||||||
|
partId,
|
||||||
|
userId: _actor?.id ?? null,
|
||||||
|
type: 'FM_OPENED',
|
||||||
|
newValue: fm.id,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire-and-forget. Emitter owns retries + signing.
|
||||||
|
void emit({ event: 'fm.opened', payload: { fm: fmPayload(fm) } });
|
||||||
|
return fm;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function update(
|
||||||
|
tx: Tx,
|
||||||
|
id: string,
|
||||||
|
input: UpdateFmRequest,
|
||||||
|
actor: Actor | null,
|
||||||
|
) {
|
||||||
|
const current = await tx.fm.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { problemParts: { select: { partId: true } }, host: true },
|
||||||
|
});
|
||||||
|
if (!current) throw errors.notFound('FM');
|
||||||
|
|
||||||
|
const data: Prisma.FmUpdateInput = {};
|
||||||
|
let closing = false;
|
||||||
|
let reopening = false;
|
||||||
|
if (input.status !== undefined && input.status !== current.status) {
|
||||||
|
data.status = input.status;
|
||||||
|
if (input.status === 'CLOSED') {
|
||||||
|
data.closedAt = new Date();
|
||||||
|
closing = true;
|
||||||
|
} else {
|
||||||
|
data.closedAt = null;
|
||||||
|
reopening = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (input.problem !== undefined) data.problem = input.problem;
|
||||||
|
|
||||||
|
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.fmPart.deleteMany({ where: { fmId: id, partId: { in: removed } } });
|
||||||
|
}
|
||||||
|
if (addedPartIds.length > 0) {
|
||||||
|
await tx.fmPart.createMany({
|
||||||
|
data: addedPartIds.map((partId) => ({ fmId: id, partId })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fm = await tx.fm.update({ where: { id }, data, include: fmInclude });
|
||||||
|
|
||||||
|
const userId = actor?.id ?? null;
|
||||||
|
if (addedPartIds.length > 0) {
|
||||||
|
await tx.partEvent.createMany({
|
||||||
|
data: addedPartIds.map((partId) => ({
|
||||||
|
partId,
|
||||||
|
userId,
|
||||||
|
type: 'FM_OPENED',
|
||||||
|
newValue: fm.id,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (closing) {
|
||||||
|
const partIds = fm.problemParts.map((p) => p.partId);
|
||||||
|
if (partIds.length > 0) {
|
||||||
|
await tx.partEvent.createMany({
|
||||||
|
data: partIds.map((partId) => ({
|
||||||
|
partId,
|
||||||
|
userId,
|
||||||
|
type: 'FM_CLOSED',
|
||||||
|
newValue: fm.id,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
void emit({ event: 'fm.closed', payload: { fm: fmPayload(fm) } });
|
||||||
|
}
|
||||||
|
if (reopening) {
|
||||||
|
void emit({ event: 'fm.opened', payload: { fm: fmPayload(fm) } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return fm;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function remove(tx: Tx, id: string) {
|
||||||
|
try {
|
||||||
|
await tx.fm.delete({ where: { id } });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
|
||||||
|
throw errors.notFound('FM');
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ export async function create(tx: Tx, input: CreatePartModelRequest) {
|
|||||||
manufacturerId: input.manufacturerId,
|
manufacturerId: input.manufacturerId,
|
||||||
mpn: input.mpn,
|
mpn: input.mpn,
|
||||||
eolDate: input.eolDate ? new Date(input.eolDate) : null,
|
eolDate: input.eolDate ? new Date(input.eolDate) : null,
|
||||||
|
destroyOnFail: input.destroyOnFail ?? false,
|
||||||
notes: input.notes ?? null,
|
notes: input.notes ?? null,
|
||||||
},
|
},
|
||||||
include: partModelInclude,
|
include: partModelInclude,
|
||||||
@@ -65,6 +66,7 @@ export async function update(tx: Tx, id: string, input: UpdatePartModelRequest)
|
|||||||
if (input.eolDate !== undefined) {
|
if (input.eolDate !== undefined) {
|
||||||
data.eolDate = input.eolDate ? new Date(input.eolDate) : null;
|
data.eolDate = input.eolDate ? new Date(input.eolDate) : null;
|
||||||
}
|
}
|
||||||
|
if (input.destroyOnFail !== undefined) data.destroyOnFail = input.destroyOnFail;
|
||||||
if (input.notes !== undefined) data.notes = input.notes;
|
if (input.notes !== undefined) data.notes = input.notes;
|
||||||
try {
|
try {
|
||||||
return await tx.partModel.update({ where: { id }, data, include: partModelInclude });
|
return await tx.partModel.update({ where: { id }, data, include: partModelInclude });
|
||||||
|
|||||||
@@ -45,6 +45,19 @@ function deployedPart(overrides: Partial<Record<string, unknown>> = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function custodyPart(overrides: Partial<Record<string, unknown>> = {}) {
|
||||||
|
return sparePart({
|
||||||
|
state: 'PENDING_DROP_IN_CUSTODY',
|
||||||
|
binId: null,
|
||||||
|
hostId: null,
|
||||||
|
custodianId: 'user-1',
|
||||||
|
custodian: { id: 'user-1', username: 'tech' },
|
||||||
|
bin: null,
|
||||||
|
host: null,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe('parts.create — state/location coupling', () => {
|
describe('parts.create — state/location coupling', () => {
|
||||||
it('rejects DEPLOYED without a hostId', async () => {
|
it('rejects DEPLOYED without a hostId', async () => {
|
||||||
const partCreate = vi.fn();
|
const partCreate = vi.fn();
|
||||||
@@ -238,3 +251,92 @@ describe('parts.update — state/location coupling', () => {
|
|||||||
expect(call.data.host).toBeUndefined();
|
expect(call.data.host).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('parts.update — custody state/location coupling', () => {
|
||||||
|
it('DEPLOYED → PENDING_DROP_IN_CUSTODY requires custodianId; clears host', async () => {
|
||||||
|
const current = deployedPart();
|
||||||
|
const partUpdate = vi.fn();
|
||||||
|
partUpdate.mockResolvedValue(
|
||||||
|
custodyPart({ state: 'PENDING_DROP_IN_CUSTODY', custodianId: 'user-1' }),
|
||||||
|
);
|
||||||
|
const tx = {
|
||||||
|
part: { findUnique: async () => current, update: partUpdate },
|
||||||
|
partEvent: { createMany: vi.fn() },
|
||||||
|
partTag: { findMany: async () => [] },
|
||||||
|
} as unknown as Tx;
|
||||||
|
|
||||||
|
await update(
|
||||||
|
tx,
|
||||||
|
'p-1',
|
||||||
|
{ state: 'PENDING_DROP_IN_CUSTODY', custodianId: 'user-1' },
|
||||||
|
actor,
|
||||||
|
);
|
||||||
|
|
||||||
|
const call = partUpdate.mock.calls[0]![0] as {
|
||||||
|
data: { host?: unknown; bin?: unknown; custodian?: unknown };
|
||||||
|
};
|
||||||
|
expect(call.data.host).toEqual({ disconnect: true });
|
||||||
|
expect(call.data.bin).toEqual({ disconnect: true });
|
||||||
|
expect(call.data.custodian).toEqual({ connect: { id: 'user-1' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects transition into a custody state without a custodianId', async () => {
|
||||||
|
const current = deployedPart();
|
||||||
|
const partUpdate = vi.fn();
|
||||||
|
const tx = {
|
||||||
|
part: { findUnique: async () => current, update: partUpdate },
|
||||||
|
partEvent: { createMany: vi.fn() },
|
||||||
|
} as unknown as Tx;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
update(tx, 'p-1', { state: 'PENDING_DROP_IN_CUSTODY' }, actor),
|
||||||
|
).rejects.toMatchObject({ status: 400 });
|
||||||
|
expect(partUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects DEPLOYED with a custodianId', async () => {
|
||||||
|
const current = sparePart({ binId: 'bin-1', hostId: null });
|
||||||
|
const partUpdate = vi.fn();
|
||||||
|
const tx = {
|
||||||
|
part: { findUnique: async () => current, update: partUpdate },
|
||||||
|
partEvent: { createMany: vi.fn() },
|
||||||
|
} as unknown as Tx;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
update(
|
||||||
|
tx,
|
||||||
|
'p-1',
|
||||||
|
{ state: 'DEPLOYED', hostId: 'host-1', custodianId: 'user-1' },
|
||||||
|
actor,
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({ status: 400 });
|
||||||
|
expect(partUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('custody → BROKEN with a binId clears custodianId', async () => {
|
||||||
|
const current = custodyPart();
|
||||||
|
const partUpdate = vi.fn();
|
||||||
|
partUpdate.mockResolvedValue(
|
||||||
|
sparePart({ state: 'BROKEN', binId: 'bin-2', hostId: null, custodianId: null }),
|
||||||
|
);
|
||||||
|
const tx = {
|
||||||
|
part: { findUnique: async () => current, update: partUpdate },
|
||||||
|
partEvent: { createMany: vi.fn() },
|
||||||
|
partTag: { findMany: async () => [] },
|
||||||
|
} as unknown as Tx;
|
||||||
|
|
||||||
|
await update(
|
||||||
|
tx,
|
||||||
|
'p-1',
|
||||||
|
{ state: 'BROKEN', binId: 'bin-2', custodianId: null },
|
||||||
|
actor,
|
||||||
|
);
|
||||||
|
|
||||||
|
const call = partUpdate.mock.calls[0]![0] as {
|
||||||
|
data: { host?: unknown; bin?: unknown; custodian?: unknown };
|
||||||
|
};
|
||||||
|
expect(call.data.bin).toEqual({ connect: { id: 'bin-2' } });
|
||||||
|
expect(call.data.host).toEqual({ disconnect: true });
|
||||||
|
expect(call.data.custodian).toEqual({ disconnect: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,27 +11,56 @@ import * as partModelsSvc from './part-models.js';
|
|||||||
import * as tagsSvc from './tags.js';
|
import * as tagsSvc from './tags.js';
|
||||||
import type { Actor, Tx } from './types.js';
|
import type { Actor, Tx } from './types.js';
|
||||||
|
|
||||||
// DEPLOYED parts live on a host; every other state lives in a bin (or is unassigned).
|
// Enforces the Part state/location invariant and auto-clears stale fields on state transitions.
|
||||||
// This helper enforces the invariant on create/update and auto-clears the stale field on a
|
// The matrix is:
|
||||||
// state transition, so callers don't have to remember to null the opposite relation.
|
// DEPLOYED — hostId required, binId forbidden, custodianId forbidden
|
||||||
|
// SPARE / BROKEN / PENDING_DESTRUCTION — binId optional, hostId + custodian forbidden
|
||||||
|
// PENDING_DROP_IN_CUSTODY / PENDING_DESTRUCTION_IN_CUSTODY
|
||||||
|
// — custodianId required, host + bin forbidden
|
||||||
|
// Callers only need to pass what's changing; anything omitted is inherited from `current`.
|
||||||
function resolveLocation(
|
function resolveLocation(
|
||||||
state: PartStateValue,
|
state: PartStateValue,
|
||||||
input: { binId?: string | null; hostId?: string | null },
|
input: {
|
||||||
current: { binId: string | null; hostId: string | null } = { binId: null, hostId: null },
|
binId?: string | null;
|
||||||
): { binId: string | null; hostId: string | null } {
|
hostId?: string | null;
|
||||||
|
custodianId?: string | null;
|
||||||
|
},
|
||||||
|
current: {
|
||||||
|
binId: string | null;
|
||||||
|
hostId: string | null;
|
||||||
|
custodianId: string | null;
|
||||||
|
} = { binId: null, hostId: null, custodianId: null },
|
||||||
|
): { binId: string | null; hostId: string | null; custodianId: string | null } {
|
||||||
if (state === 'DEPLOYED') {
|
if (state === 'DEPLOYED') {
|
||||||
const hostId = input.hostId !== undefined ? input.hostId : current.hostId;
|
const hostId = input.hostId !== undefined ? input.hostId : current.hostId;
|
||||||
if (!hostId) throw errors.badRequest('A deployed part must be assigned to a host');
|
if (!hostId) throw errors.badRequest('A deployed part must be assigned to a host');
|
||||||
if (input.binId) {
|
if (input.binId) {
|
||||||
throw errors.badRequest('A deployed part cannot also be in a storage bin');
|
throw errors.badRequest('A deployed part cannot also be in a storage bin');
|
||||||
}
|
}
|
||||||
return { binId: null, hostId };
|
if (input.custodianId) {
|
||||||
|
throw errors.badRequest('A deployed part cannot be in custody');
|
||||||
|
}
|
||||||
|
return { binId: null, hostId, custodianId: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state === 'PENDING_DROP_IN_CUSTODY' || state === 'PENDING_DESTRUCTION_IN_CUSTODY') {
|
||||||
|
const custodianId =
|
||||||
|
input.custodianId !== undefined ? input.custodianId : current.custodianId;
|
||||||
|
if (!custodianId) throw errors.badRequest('A part in custody must name a custodian');
|
||||||
|
if (input.hostId) throw errors.badRequest('A part in custody cannot be on a host');
|
||||||
|
if (input.binId) throw errors.badRequest('A part in custody cannot be in a bin');
|
||||||
|
return { binId: null, hostId: null, custodianId };
|
||||||
|
}
|
||||||
|
|
||||||
|
// SPARE / BROKEN / PENDING_DESTRUCTION
|
||||||
if (input.hostId) {
|
if (input.hostId) {
|
||||||
throw errors.badRequest('Only deployed parts can be assigned to a host');
|
throw errors.badRequest('Only deployed parts can be assigned to a host');
|
||||||
}
|
}
|
||||||
|
if (input.custodianId) {
|
||||||
|
throw errors.badRequest('Only custody states can have a custodian');
|
||||||
|
}
|
||||||
const binId = input.binId !== undefined ? input.binId : current.binId;
|
const binId = input.binId !== undefined ? input.binId : current.binId;
|
||||||
return { binId, hostId: null };
|
return { binId, hostId: null, custodianId: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const partInclude = {
|
const partInclude = {
|
||||||
@@ -40,6 +69,7 @@ const partInclude = {
|
|||||||
bin: { include: { room: { include: { site: true } } } },
|
bin: { include: { room: { include: { site: true } } } },
|
||||||
category: true,
|
category: true,
|
||||||
host: true,
|
host: true,
|
||||||
|
custodian: { select: { id: true, username: true } },
|
||||||
tags: { include: { tag: true } },
|
tags: { include: { tag: true } },
|
||||||
} satisfies Prisma.PartInclude;
|
} satisfies Prisma.PartInclude;
|
||||||
|
|
||||||
@@ -94,6 +124,7 @@ function buildWhere(q: PartListQuery): Prisma.PartWhereInput {
|
|||||||
if (q.state) where.state = q.state;
|
if (q.state) where.state = q.state;
|
||||||
if (q.binId) where.binId = q.binId;
|
if (q.binId) where.binId = q.binId;
|
||||||
if (q.hostId) where.hostId = q.hostId;
|
if (q.hostId) where.hostId = q.hostId;
|
||||||
|
if (q.custodianId) where.custodianId = q.custodianId;
|
||||||
if (q.manufacturerId) where.manufacturerId = q.manufacturerId;
|
if (q.manufacturerId) where.manufacturerId = q.manufacturerId;
|
||||||
if (q.partModelId) where.partModelId = q.partModelId;
|
if (q.partModelId) where.partModelId = q.partModelId;
|
||||||
if (q.categoryId) where.categoryId = q.categoryId;
|
if (q.categoryId) where.categoryId = q.categoryId;
|
||||||
@@ -148,7 +179,11 @@ export async function create(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const state = input.state ?? 'SPARE';
|
const state = input.state ?? 'SPARE';
|
||||||
const location = resolveLocation(state, { binId: input.binId, hostId: input.hostId });
|
const location = resolveLocation(state, {
|
||||||
|
binId: input.binId,
|
||||||
|
hostId: input.hostId,
|
||||||
|
custodianId: input.custodianId,
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const p = await tx.part.create({
|
const p = await tx.part.create({
|
||||||
@@ -160,6 +195,7 @@ export async function create(
|
|||||||
state,
|
state,
|
||||||
binId: location.binId,
|
binId: location.binId,
|
||||||
hostId: location.hostId,
|
hostId: location.hostId,
|
||||||
|
custodianId: location.custodianId,
|
||||||
categoryId: input.categoryId ?? null,
|
categoryId: input.categoryId ?? null,
|
||||||
notes: input.notes ?? null,
|
notes: input.notes ?? null,
|
||||||
},
|
},
|
||||||
@@ -209,19 +245,35 @@ export async function update(
|
|||||||
|
|
||||||
let nextBinId: string | null = current.binId;
|
let nextBinId: string | null = current.binId;
|
||||||
let nextHostId: string | null = current.hostId;
|
let nextHostId: string | null = current.hostId;
|
||||||
|
let nextCustodianId: string | null = current.custodianId;
|
||||||
const locationTouched =
|
const locationTouched =
|
||||||
input.state !== undefined || input.binId !== undefined || input.hostId !== undefined;
|
input.state !== undefined ||
|
||||||
|
input.binId !== undefined ||
|
||||||
|
input.hostId !== undefined ||
|
||||||
|
input.custodianId !== undefined;
|
||||||
if (locationTouched) {
|
if (locationTouched) {
|
||||||
const nextState = input.state ?? (current.state as PartStateValue);
|
const nextState = input.state ?? (current.state as PartStateValue);
|
||||||
const resolved = resolveLocation(
|
const resolved = resolveLocation(
|
||||||
nextState,
|
nextState,
|
||||||
{ binId: input.binId, hostId: input.hostId },
|
{
|
||||||
{ binId: current.binId, hostId: current.hostId },
|
binId: input.binId,
|
||||||
|
hostId: input.hostId,
|
||||||
|
custodianId: input.custodianId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
binId: current.binId,
|
||||||
|
hostId: current.hostId,
|
||||||
|
custodianId: current.custodianId,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
nextBinId = resolved.binId;
|
nextBinId = resolved.binId;
|
||||||
nextHostId = resolved.hostId;
|
nextHostId = resolved.hostId;
|
||||||
|
nextCustodianId = resolved.custodianId;
|
||||||
data.bin = resolved.binId ? { connect: { id: resolved.binId } } : { disconnect: true };
|
data.bin = resolved.binId ? { connect: { id: resolved.binId } } : { disconnect: true };
|
||||||
data.host = resolved.hostId ? { connect: { id: resolved.hostId } } : { disconnect: true };
|
data.host = resolved.hostId ? { connect: { id: resolved.hostId } } : { disconnect: true };
|
||||||
|
data.custodian = resolved.custodianId
|
||||||
|
? { connect: { id: resolved.custodianId } }
|
||||||
|
: { disconnect: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.categoryId !== undefined) {
|
if (input.categoryId !== undefined) {
|
||||||
@@ -275,6 +327,16 @@ export async function update(
|
|||||||
newValue: part.host?.name ?? null,
|
newValue: part.host?.name ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (nextCustodianId !== current.custodianId) {
|
||||||
|
events.push({
|
||||||
|
partId: part.id,
|
||||||
|
userId,
|
||||||
|
type: 'LOCATION_CHANGED',
|
||||||
|
field: 'custodian',
|
||||||
|
oldValue: current.custodian?.username ?? null,
|
||||||
|
newValue: part.custodian?.username ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (input.partModelId !== undefined && input.partModelId !== current.partModelId) {
|
if (input.partModelId !== undefined && input.partModelId !== current.partModelId) {
|
||||||
events.push({
|
events.push({
|
||||||
partId: part.id,
|
partId: part.id,
|
||||||
@@ -344,7 +406,7 @@ export async function remove(tx: Tx, id: string) {
|
|||||||
if (err instanceof Prisma.PrismaClientKnownRequestError) {
|
if (err instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
if (err.code === 'P2025') throw errors.notFound('Part');
|
if (err.code === 'P2025') throw errors.notFound('Part');
|
||||||
if (err.code === 'P2003') {
|
if (err.code === 'P2003') {
|
||||||
throw errors.conflict('Cannot delete: part is referenced by a repair');
|
throw errors.conflict('Cannot delete: part is referenced by an FM or repair');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
@@ -1,225 +1,550 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { Prisma } from '@vector/db';
|
|
||||||
|
const emitMock = vi.fn();
|
||||||
|
vi.mock('../lib/webhook-emitter.js', () => ({
|
||||||
|
emit: (...args: unknown[]) => emitMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
import type { Tx, Actor } from './types.js';
|
import type { Tx, Actor } from './types.js';
|
||||||
import { create, addComment, listComments } from './repairs.js';
|
import { log } 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' };
|
const actor: Actor = { id: 'user-1', username: 'tech', role: 'ADMIN' };
|
||||||
|
|
||||||
// Fabricate a minimal Prisma.PrismaClientKnownRequestError without requiring a live client.
|
const host1 = { id: 'host-1', assetId: 'ASSET-001', name: 'rack-1' };
|
||||||
function prismaError(code: string, meta?: Record<string, unknown>) {
|
const host2 = { id: 'host-2', assetId: 'ASSET-002', name: 'rack-2' };
|
||||||
return new Prisma.PrismaClientKnownRequestError('simulated', {
|
|
||||||
code,
|
const brokenModel = {
|
||||||
clientVersion: 'test',
|
id: 'pm-broken',
|
||||||
meta,
|
manufacturerId: 'mfr-1',
|
||||||
});
|
mpn: 'WD-BROKEN',
|
||||||
|
destroyOnFail: false,
|
||||||
|
eolDate: null,
|
||||||
|
};
|
||||||
|
const destroyModel = {
|
||||||
|
id: 'pm-destroy',
|
||||||
|
manufacturerId: 'mfr-1',
|
||||||
|
mpn: 'WD-DESTROY',
|
||||||
|
destroyOnFail: true,
|
||||||
|
eolDate: null,
|
||||||
|
};
|
||||||
|
const replacementModel = {
|
||||||
|
id: 'pm-replacement',
|
||||||
|
manufacturerId: 'mfr-1',
|
||||||
|
mpn: 'WD-REPLACE',
|
||||||
|
destroyOnFail: false,
|
||||||
|
eolDate: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function partRow(overrides: Partial<Record<string, unknown>>) {
|
||||||
|
return {
|
||||||
|
id: overrides.id ?? 'p-x',
|
||||||
|
serialNumber: overrides.serialNumber ?? 'SN-X',
|
||||||
|
partModelId: overrides.partModelId ?? 'pm-x',
|
||||||
|
manufacturerId: 'mfr-1',
|
||||||
|
state: overrides.state ?? 'SPARE',
|
||||||
|
binId: overrides.binId ?? null,
|
||||||
|
hostId: overrides.hostId ?? null,
|
||||||
|
custodianId: overrides.custodianId ?? null,
|
||||||
|
categoryId: null,
|
||||||
|
price: null,
|
||||||
|
notes: null,
|
||||||
|
partModel: overrides.partModel ?? brokenModel,
|
||||||
|
manufacturer: { id: 'mfr-1', name: 'WD' },
|
||||||
|
bin: null,
|
||||||
|
host: overrides.host ?? null,
|
||||||
|
category: null,
|
||||||
|
custodian: overrides.custodian ?? null,
|
||||||
|
tags: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('repairs.create — host membership', () => {
|
// Builds a Tx stub whose `tx.part.findUnique` resolves from an internal registry.
|
||||||
it('rejects a problem-part that is not on the chosen host', async () => {
|
// `tx.part.update` mutates the registry in place so the second parts.update call
|
||||||
const partEventCreateMany = vi.fn();
|
// (for the replacement) sees the fallout from the first (broken) update.
|
||||||
const repairCreate = vi.fn();
|
function buildTx(options: {
|
||||||
|
parts: Array<ReturnType<typeof partRow>>;
|
||||||
|
hosts: Array<{ id: string; assetId: string; name: string }>;
|
||||||
|
fm?: { id: string; hostId: string } | null;
|
||||||
|
existingPartModel?: { id: string; manufacturerId: string; mpn: string } | null;
|
||||||
|
}) {
|
||||||
|
const registry = new Map(options.parts.map((p) => [p.id, p]));
|
||||||
|
|
||||||
const tx = {
|
const tx = {
|
||||||
host: { findUnique: async () => ({ id: 'host-1' }) },
|
host: {
|
||||||
part: {
|
findUnique: async (args: { where: { id?: string; assetId?: string } }) => {
|
||||||
findMany: async () => [{ id: 'part-a', hostId: 'host-2' }],
|
if (args.where.id) return options.hosts.find((h) => h.id === args.where.id) ?? null;
|
||||||
|
if (args.where.assetId)
|
||||||
|
return options.hosts.find((h) => h.assetId === args.where.assetId) ?? null;
|
||||||
|
return null;
|
||||||
},
|
},
|
||||||
repairJob: { create: repairCreate },
|
},
|
||||||
partEvent: { createMany: partEventCreateMany },
|
part: {
|
||||||
} as unknown as Tx;
|
findUnique: async (args: {
|
||||||
|
where: { id?: string; serialNumber?: string };
|
||||||
|
}) => {
|
||||||
|
const found = [...registry.values()].find(
|
||||||
|
(p) =>
|
||||||
|
(args.where.id && p.id === args.where.id) ||
|
||||||
|
(args.where.serialNumber && p.serialNumber === args.where.serialNumber),
|
||||||
|
);
|
||||||
|
return found ?? null;
|
||||||
|
},
|
||||||
|
create: vi.fn(async (args: { data: Record<string, unknown> }) => {
|
||||||
|
const data = args.data;
|
||||||
|
const pm =
|
||||||
|
data.partModelId === brokenModel.id
|
||||||
|
? brokenModel
|
||||||
|
: data.partModelId === destroyModel.id
|
||||||
|
? destroyModel
|
||||||
|
: replacementModel;
|
||||||
|
const created = partRow({
|
||||||
|
id: `p-ingested-${data.serialNumber}`,
|
||||||
|
serialNumber: data.serialNumber as string,
|
||||||
|
partModelId: data.partModelId as string,
|
||||||
|
state: data.state as string,
|
||||||
|
hostId: data.hostId as string | null,
|
||||||
|
partModel: pm,
|
||||||
|
});
|
||||||
|
registry.set(created.id, created);
|
||||||
|
return created;
|
||||||
|
}),
|
||||||
|
update: vi.fn(async (args: { where: { id: string }; data: Record<string, unknown> }) => {
|
||||||
|
const current = registry.get(args.where.id);
|
||||||
|
if (!current) throw new Error(`No part ${args.where.id} in stub registry`);
|
||||||
|
const data = args.data;
|
||||||
|
if (data.state) current.state = data.state as string;
|
||||||
|
if (data.bin !== undefined) {
|
||||||
|
const v = data.bin as { connect?: { id: string }; disconnect?: boolean };
|
||||||
|
current.binId = v.connect?.id ?? null;
|
||||||
|
}
|
||||||
|
if (data.host !== undefined) {
|
||||||
|
const v = data.host as { connect?: { id: string }; disconnect?: boolean };
|
||||||
|
current.hostId = v.connect?.id ?? null;
|
||||||
|
current.host = v.connect?.id
|
||||||
|
? options.hosts.find((h) => h.id === v.connect!.id) ?? null
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
if (data.custodian !== undefined) {
|
||||||
|
const v = data.custodian as { connect?: { id: string }; disconnect?: boolean };
|
||||||
|
current.custodianId = v.connect?.id ?? null;
|
||||||
|
current.custodian = v.connect?.id
|
||||||
|
? { id: v.connect.id, username: 'tech' }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
partModel: {
|
||||||
|
findUnique: async (args: {
|
||||||
|
where: {
|
||||||
|
id?: string;
|
||||||
|
manufacturerId_mpn?: { manufacturerId: string; mpn: string };
|
||||||
|
};
|
||||||
|
}) => {
|
||||||
|
if (args.where.id) {
|
||||||
|
if (args.where.id === brokenModel.id) return brokenModel;
|
||||||
|
if (args.where.id === destroyModel.id) return destroyModel;
|
||||||
|
if (args.where.id === replacementModel.id) return replacementModel;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (options.existingPartModel && args.where.manufacturerId_mpn) {
|
||||||
|
const k = args.where.manufacturerId_mpn;
|
||||||
|
if (
|
||||||
|
options.existingPartModel.manufacturerId === k.manufacturerId &&
|
||||||
|
options.existingPartModel.mpn === k.mpn
|
||||||
|
) {
|
||||||
|
return options.existingPartModel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
create: vi.fn(async (args: { data: { manufacturerId: string; mpn: string } }) => ({
|
||||||
|
id: `pm-new-${args.data.mpn}`,
|
||||||
|
manufacturerId: args.data.manufacturerId,
|
||||||
|
mpn: args.data.mpn,
|
||||||
|
destroyOnFail: false,
|
||||||
|
eolDate: null,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
fm: {
|
||||||
|
findUnique: async (args: { where: { id: string } }) => {
|
||||||
|
if (options.fm && options.fm.id === args.where.id) return options.fm;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
repair: {
|
||||||
|
create: vi.fn(async (args: { data: Record<string, unknown> }) => ({
|
||||||
|
id: 'repair-1',
|
||||||
|
hostId: args.data.hostId,
|
||||||
|
brokenPartId: args.data.brokenPartId,
|
||||||
|
replacementPartId: args.data.replacementPartId,
|
||||||
|
performedById: args.data.performedById,
|
||||||
|
fmId: args.data.fmId ?? null,
|
||||||
|
performedAt: new Date('2026-04-15T00:00:00Z'),
|
||||||
|
host: options.hosts.find((h) => h.id === args.data.hostId) ?? host1,
|
||||||
|
brokenPart: registry.get(args.data.brokenPartId as string),
|
||||||
|
replacement: registry.get(args.data.replacementPartId as string),
|
||||||
|
performedBy: { id: actor.id, username: actor.username },
|
||||||
|
fm: options.fm ? { id: options.fm.id, status: 'OPEN' } : null,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
partEvent: {
|
||||||
|
create: vi.fn(),
|
||||||
|
createMany: vi.fn(),
|
||||||
|
},
|
||||||
|
partTag: {
|
||||||
|
findMany: async () => [],
|
||||||
|
},
|
||||||
|
} as unknown as Tx;
|
||||||
|
|
||||||
|
return { tx, registry };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
emitMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('repairs.log — happy paths', () => {
|
||||||
|
it('swaps a known broken deployed part with a SPARE replacement', async () => {
|
||||||
|
const broken = partRow({
|
||||||
|
id: 'p-broken',
|
||||||
|
serialNumber: 'SN-BROKEN',
|
||||||
|
partModelId: brokenModel.id,
|
||||||
|
state: 'DEPLOYED',
|
||||||
|
hostId: 'host-1',
|
||||||
|
host: host1,
|
||||||
|
partModel: brokenModel,
|
||||||
|
});
|
||||||
|
const replacement = partRow({
|
||||||
|
id: 'p-replacement',
|
||||||
|
serialNumber: 'SN-REPLACE',
|
||||||
|
partModelId: replacementModel.id,
|
||||||
|
state: 'SPARE',
|
||||||
|
binId: 'bin-1',
|
||||||
|
partModel: replacementModel,
|
||||||
|
});
|
||||||
|
const { tx, registry } = buildTx({
|
||||||
|
parts: [broken, replacement],
|
||||||
|
hosts: [host1],
|
||||||
|
});
|
||||||
|
|
||||||
|
const r = await log(
|
||||||
|
tx,
|
||||||
|
{
|
||||||
|
hostId: 'host-1',
|
||||||
|
brokenSerial: 'SN-BROKEN',
|
||||||
|
brokenMpn: 'WD-BROKEN',
|
||||||
|
brokenManufacturerId: 'mfr-1',
|
||||||
|
replacementSerial: 'SN-REPLACE',
|
||||||
|
},
|
||||||
|
actor,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(r.id).toBe('repair-1');
|
||||||
|
|
||||||
|
// After swap: broken is in custody, replacement is DEPLOYED on the host.
|
||||||
|
const updatedBroken = registry.get('p-broken')!;
|
||||||
|
expect(updatedBroken.state).toBe('PENDING_DROP_IN_CUSTODY');
|
||||||
|
expect(updatedBroken.custodianId).toBe('user-1');
|
||||||
|
expect(updatedBroken.hostId).toBeNull();
|
||||||
|
|
||||||
|
const updatedReplacement = registry.get('p-replacement')!;
|
||||||
|
expect(updatedReplacement.state).toBe('DEPLOYED');
|
||||||
|
expect(updatedReplacement.hostId).toBe('host-1');
|
||||||
|
expect(updatedReplacement.binId).toBeNull();
|
||||||
|
|
||||||
|
expect(emitMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
event: 'repair.logged',
|
||||||
|
payload: expect.objectContaining({
|
||||||
|
repair: expect.objectContaining({ id: 'repair-1' }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PART_SWAPPED events are emitted for both parts', async () => {
|
||||||
|
const broken = partRow({
|
||||||
|
id: 'p-broken',
|
||||||
|
serialNumber: 'SN-BROKEN',
|
||||||
|
partModelId: brokenModel.id,
|
||||||
|
state: 'DEPLOYED',
|
||||||
|
hostId: 'host-1',
|
||||||
|
host: host1,
|
||||||
|
partModel: brokenModel,
|
||||||
|
});
|
||||||
|
const replacement = partRow({
|
||||||
|
id: 'p-replacement',
|
||||||
|
serialNumber: 'SN-REPLACE',
|
||||||
|
partModelId: replacementModel.id,
|
||||||
|
state: 'SPARE',
|
||||||
|
partModel: replacementModel,
|
||||||
|
});
|
||||||
|
const { tx } = buildTx({ parts: [broken, replacement], hosts: [host1] });
|
||||||
|
|
||||||
|
await log(
|
||||||
|
tx,
|
||||||
|
{
|
||||||
|
hostId: 'host-1',
|
||||||
|
brokenSerial: 'SN-BROKEN',
|
||||||
|
brokenMpn: 'WD-BROKEN',
|
||||||
|
brokenManufacturerId: 'mfr-1',
|
||||||
|
replacementSerial: 'SN-REPLACE',
|
||||||
|
},
|
||||||
|
actor,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find the createMany call with PART_SWAPPED entries.
|
||||||
|
const createManyMock = tx.partEvent.createMany as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
const swapCall = createManyMock.mock.calls.find((c) => {
|
||||||
|
const data = (c[0] as { data: Array<{ type: string }> }).data;
|
||||||
|
return data.some((d) => d.type === 'PART_SWAPPED');
|
||||||
|
});
|
||||||
|
expect(swapCall).toBeDefined();
|
||||||
|
const swapData = (swapCall![0] as { data: Array<{ partId: string; type: string }> }).data;
|
||||||
|
const swapped = swapData.filter((d) => d.type === 'PART_SWAPPED').map((d) => d.partId);
|
||||||
|
expect(swapped).toEqual(expect.arrayContaining(['p-broken', 'p-replacement']));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('repairs.log — ingest on unknown MPN', () => {
|
||||||
|
it('ingests a brand-new broken part when the serial is absent', async () => {
|
||||||
|
const replacement = partRow({
|
||||||
|
id: 'p-replacement',
|
||||||
|
serialNumber: 'SN-REPLACE',
|
||||||
|
partModelId: replacementModel.id,
|
||||||
|
state: 'SPARE',
|
||||||
|
partModel: replacementModel,
|
||||||
|
});
|
||||||
|
const { tx, registry } = buildTx({
|
||||||
|
parts: [replacement],
|
||||||
|
hosts: [host1],
|
||||||
|
existingPartModel: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await log(
|
||||||
|
tx,
|
||||||
|
{
|
||||||
|
hostId: 'host-1',
|
||||||
|
brokenSerial: 'NEW-SN',
|
||||||
|
brokenMpn: 'NEW-MPN',
|
||||||
|
brokenManufacturerId: 'mfr-1',
|
||||||
|
replacementSerial: 'SN-REPLACE',
|
||||||
|
},
|
||||||
|
actor,
|
||||||
|
);
|
||||||
|
|
||||||
|
const partModelCreate = tx.partModel.create as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
expect(partModelCreate).toHaveBeenCalledWith({
|
||||||
|
data: { manufacturerId: 'mfr-1', mpn: 'NEW-MPN' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const partCreate = tx.part.create as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
expect(partCreate).toHaveBeenCalledTimes(1);
|
||||||
|
const createdArgs = partCreate.mock.calls[0]![0] as {
|
||||||
|
data: { serialNumber: string; state: string; hostId: string };
|
||||||
|
};
|
||||||
|
expect(createdArgs.data.serialNumber).toBe('NEW-SN');
|
||||||
|
expect(createdArgs.data.state).toBe('DEPLOYED');
|
||||||
|
expect(createdArgs.data.hostId).toBe('host-1');
|
||||||
|
|
||||||
|
// The ingested part must end up in custody just like the known-broken path.
|
||||||
|
const ingested = registry.get('p-ingested-NEW-SN')!;
|
||||||
|
expect(ingested.state).toBe('PENDING_DROP_IN_CUSTODY');
|
||||||
|
expect(ingested.custodianId).toBe('user-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('destroyOnFail=true routes the broken part to PENDING_DESTRUCTION_IN_CUSTODY', async () => {
|
||||||
|
const broken = partRow({
|
||||||
|
id: 'p-broken',
|
||||||
|
serialNumber: 'SN-BROKEN',
|
||||||
|
partModelId: destroyModel.id,
|
||||||
|
state: 'DEPLOYED',
|
||||||
|
hostId: 'host-1',
|
||||||
|
host: host1,
|
||||||
|
partModel: destroyModel,
|
||||||
|
});
|
||||||
|
const replacement = partRow({
|
||||||
|
id: 'p-replacement',
|
||||||
|
serialNumber: 'SN-REPLACE',
|
||||||
|
partModelId: replacementModel.id,
|
||||||
|
state: 'SPARE',
|
||||||
|
partModel: replacementModel,
|
||||||
|
});
|
||||||
|
const { tx, registry } = buildTx({ parts: [broken, replacement], hosts: [host1] });
|
||||||
|
|
||||||
|
await log(
|
||||||
|
tx,
|
||||||
|
{
|
||||||
|
hostId: 'host-1',
|
||||||
|
brokenSerial: 'SN-BROKEN',
|
||||||
|
brokenMpn: 'WD-DESTROY',
|
||||||
|
brokenManufacturerId: 'mfr-1',
|
||||||
|
replacementSerial: 'SN-REPLACE',
|
||||||
|
},
|
||||||
|
actor,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(registry.get('p-broken')!.state).toBe('PENDING_DESTRUCTION_IN_CUSTODY');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('repairs.log — validation failures', () => {
|
||||||
|
it('rejects when replacement is missing', async () => {
|
||||||
|
const { tx } = buildTx({ parts: [], hosts: [host1] });
|
||||||
await expect(
|
await expect(
|
||||||
create(
|
log(
|
||||||
tx,
|
tx,
|
||||||
{ hostId: 'host-1', problem: 'fan noise', problemPartIds: ['part-a'] },
|
{
|
||||||
|
hostId: 'host-1',
|
||||||
|
brokenSerial: 'SN-BROKEN',
|
||||||
|
brokenMpn: 'WD-BROKEN',
|
||||||
|
brokenManufacturerId: 'mfr-1',
|
||||||
|
replacementSerial: 'DOES-NOT-EXIST',
|
||||||
|
},
|
||||||
actor,
|
actor,
|
||||||
),
|
),
|
||||||
).rejects.toMatchObject({ status: 400 });
|
).rejects.toMatchObject({ status: 400 });
|
||||||
|
|
||||||
expect(repairCreate).not.toHaveBeenCalled();
|
|
||||||
expect(partEventCreateMany).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('succeeds with empty problemPartIds and emits no REPAIR_STARTED events', async () => {
|
it('rejects when replacement is not SPARE', async () => {
|
||||||
const partEventCreateMany = vi.fn();
|
const replacement = partRow({
|
||||||
const repairCreate = vi.fn(async () => ({
|
id: 'p-replacement',
|
||||||
id: 'repair-1',
|
serialNumber: 'SN-REPLACE',
|
||||||
|
partModelId: replacementModel.id,
|
||||||
|
state: 'DEPLOYED',
|
||||||
hostId: 'host-1',
|
hostId: 'host-1',
|
||||||
problem: 'power drops',
|
partModel: replacementModel,
|
||||||
status: 'PENDING',
|
});
|
||||||
problemParts: [],
|
const { tx } = buildTx({ parts: [replacement], hosts: [host1] });
|
||||||
}));
|
await expect(
|
||||||
|
log(
|
||||||
const tx = {
|
tx,
|
||||||
host: { findUnique: async () => ({ id: 'host-1' }) },
|
{
|
||||||
part: { findMany: async () => [] },
|
hostId: 'host-1',
|
||||||
repairJob: { create: repairCreate },
|
brokenSerial: 'SN-BROKEN',
|
||||||
partEvent: { createMany: partEventCreateMany },
|
brokenMpn: 'WD-BROKEN',
|
||||||
} as unknown as Tx;
|
brokenManufacturerId: 'mfr-1',
|
||||||
|
replacementSerial: 'SN-REPLACE',
|
||||||
const r = await create(tx, { hostId: 'host-1', problem: 'power drops' }, actor);
|
},
|
||||||
expect(r.id).toBe('repair-1');
|
actor,
|
||||||
expect(repairCreate).toHaveBeenCalledOnce();
|
),
|
||||||
expect(partEventCreateMany).not.toHaveBeenCalled();
|
).rejects.toMatchObject({ status: 400 });
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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 () => {
|
it('rejects when fmId belongs to a different host', async () => {
|
||||||
const winner = { id: 'pm-9', manufacturerId: 'mfr-1', mpn: 'WD-1000', eolDate: null };
|
const broken = partRow({
|
||||||
let findCall = 0;
|
id: 'p-broken',
|
||||||
const tx = {
|
serialNumber: 'SN-BROKEN',
|
||||||
partModel: {
|
partModelId: brokenModel.id,
|
||||||
findUnique: async () => {
|
state: 'DEPLOYED',
|
||||||
findCall += 1;
|
hostId: 'host-1',
|
||||||
if (findCall === 1) return null;
|
host: host1,
|
||||||
return winner;
|
partModel: brokenModel,
|
||||||
},
|
});
|
||||||
create: async () => {
|
const replacement = partRow({
|
||||||
throw prismaError('P2002');
|
id: 'p-replacement',
|
||||||
},
|
serialNumber: 'SN-REPLACE',
|
||||||
},
|
partModelId: replacementModel.id,
|
||||||
} as unknown as Tx;
|
state: 'SPARE',
|
||||||
|
partModel: replacementModel,
|
||||||
const r = await partModels.upsertByMpn(tx, { manufacturerId: 'mfr-1', mpn: 'WD-1000' });
|
});
|
||||||
expect(r).toBe(winner);
|
const { tx } = buildTx({
|
||||||
});
|
parts: [broken, replacement],
|
||||||
});
|
hosts: [host1, host2],
|
||||||
|
fm: { id: 'fm-other', hostId: 'host-2' },
|
||||||
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(
|
await expect(
|
||||||
hosts.create(tx, { assetId: 'ASSET-001', name: 'rack-1' }),
|
log(
|
||||||
).rejects.toMatchObject({ status: 409, message: 'Asset ID already in use' });
|
tx,
|
||||||
|
{
|
||||||
|
hostId: 'host-1',
|
||||||
|
brokenSerial: 'SN-BROKEN',
|
||||||
|
brokenMpn: 'WD-BROKEN',
|
||||||
|
brokenManufacturerId: 'mfr-1',
|
||||||
|
replacementSerial: 'SN-REPLACE',
|
||||||
|
fmId: 'fm-other',
|
||||||
|
},
|
||||||
|
actor,
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({ status: 400 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls through to the name-uniqueness message for other unique targets', async () => {
|
it('accepts a matching fmId and does NOT auto-close the FM', async () => {
|
||||||
const tx = {
|
const broken = partRow({
|
||||||
host: {
|
id: 'p-broken',
|
||||||
create: async () => {
|
serialNumber: 'SN-BROKEN',
|
||||||
throw prismaError('P2002', { target: ['name'] });
|
partModelId: brokenModel.id,
|
||||||
},
|
state: 'DEPLOYED',
|
||||||
|
hostId: 'host-1',
|
||||||
|
host: host1,
|
||||||
|
partModel: brokenModel,
|
||||||
|
});
|
||||||
|
const replacement = partRow({
|
||||||
|
id: 'p-replacement',
|
||||||
|
serialNumber: 'SN-REPLACE',
|
||||||
|
partModelId: replacementModel.id,
|
||||||
|
state: 'SPARE',
|
||||||
|
partModel: replacementModel,
|
||||||
|
});
|
||||||
|
const { tx } = buildTx({
|
||||||
|
parts: [broken, replacement],
|
||||||
|
hosts: [host1],
|
||||||
|
fm: { id: 'fm-1', hostId: 'host-1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const r = await log(
|
||||||
|
tx,
|
||||||
|
{
|
||||||
|
hostId: 'host-1',
|
||||||
|
brokenSerial: 'SN-BROKEN',
|
||||||
|
brokenMpn: 'WD-BROKEN',
|
||||||
|
brokenManufacturerId: 'mfr-1',
|
||||||
|
replacementSerial: 'SN-REPLACE',
|
||||||
|
fmId: 'fm-1',
|
||||||
},
|
},
|
||||||
} as unknown as Tx;
|
actor,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(r.fmId).toBe('fm-1');
|
||||||
|
// Only the repair.logged webhook fires — no fm.closed.
|
||||||
|
const events = emitMock.mock.calls.map((c) => (c[0] as { event: string }).event);
|
||||||
|
expect(events).toEqual(['repair.logged']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when broken part is on a different host than the repair', async () => {
|
||||||
|
const broken = partRow({
|
||||||
|
id: 'p-broken',
|
||||||
|
serialNumber: 'SN-BROKEN',
|
||||||
|
partModelId: brokenModel.id,
|
||||||
|
state: 'DEPLOYED',
|
||||||
|
hostId: 'host-2',
|
||||||
|
host: host2,
|
||||||
|
partModel: brokenModel,
|
||||||
|
});
|
||||||
|
const replacement = partRow({
|
||||||
|
id: 'p-replacement',
|
||||||
|
serialNumber: 'SN-REPLACE',
|
||||||
|
partModelId: replacementModel.id,
|
||||||
|
state: 'SPARE',
|
||||||
|
partModel: replacementModel,
|
||||||
|
});
|
||||||
|
const { tx } = buildTx({ parts: [broken, replacement], hosts: [host1, host2] });
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
hosts.create(tx, { assetId: 'ASSET-002', name: 'rack-1' }),
|
log(
|
||||||
).rejects.toMatchObject({ status: 409, message: 'Host name already exists' });
|
tx,
|
||||||
});
|
{
|
||||||
});
|
hostId: 'host-1',
|
||||||
|
brokenSerial: 'SN-BROKEN',
|
||||||
describe('repairs.addComment / listComments', () => {
|
brokenMpn: 'WD-BROKEN',
|
||||||
it('stamps userId from the actor and returns it via listComments', async () => {
|
brokenManufacturerId: 'mfr-1',
|
||||||
const stored: {
|
replacementSerial: 'SN-REPLACE',
|
||||||
id: string;
|
},
|
||||||
repairJobId: string;
|
actor,
|
||||||
userId: string | null;
|
),
|
||||||
content: string;
|
).rejects.toMatchObject({ status: 400 });
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+156
-233
@@ -1,275 +1,198 @@
|
|||||||
import { Prisma } from '@vector/db';
|
import { Prisma } from '@vector/db';
|
||||||
import type {
|
import type { LogRepairRequest, RepairListQuery } from '@vector/shared';
|
||||||
CreateRepairCommentRequest,
|
|
||||||
CreateRepairJobRequest,
|
|
||||||
RepairCommentListQuery,
|
|
||||||
RepairJobListQuery,
|
|
||||||
UpdateRepairJobRequest,
|
|
||||||
} from '@vector/shared';
|
|
||||||
import { errors } from '../lib/http-error.js';
|
import { errors } from '../lib/http-error.js';
|
||||||
|
import { emit } from '../lib/webhook-emitter.js';
|
||||||
|
import * as partsSvc from './parts.js';
|
||||||
|
import * as partModelsSvc from './part-models.js';
|
||||||
|
import { resolveHost } from './fms.js';
|
||||||
import type { Actor, Tx } from './types.js';
|
import type { Actor, Tx } from './types.js';
|
||||||
|
|
||||||
|
// A Repair is the persistent log of a physical part swap on a host. The tech enters the broken
|
||||||
|
// serial + mpn + replacement serial; if the broken part isn't in the catalog we ingest it. The
|
||||||
|
// broken part is placed into the tech's custody (dropped in a bin later via the custody flow).
|
||||||
const repairInclude = {
|
const repairInclude = {
|
||||||
host: true,
|
host: true,
|
||||||
assignee: { select: { id: true, username: true, email: true, role: true } },
|
brokenPart: { include: { partModel: true, manufacturer: true } },
|
||||||
problemParts: {
|
replacement: { include: { partModel: true, manufacturer: true } },
|
||||||
include: {
|
performedBy: { select: { id: true, username: true } },
|
||||||
part: {
|
fm: { select: { id: true, status: true } },
|
||||||
include: { partModel: true, manufacturer: true },
|
} satisfies Prisma.RepairInclude;
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} satisfies Prisma.RepairJobInclude;
|
|
||||||
|
|
||||||
const commentInclude = {
|
export type RepairWithRelations = Prisma.RepairGetPayload<{ include: typeof repairInclude }>;
|
||||||
user: { select: { id: true, username: true } },
|
|
||||||
} satisfies Prisma.RepairCommentInclude;
|
|
||||||
|
|
||||||
export async function list(tx: Tx, q: RepairJobListQuery) {
|
function buildWhere(q: RepairListQuery): Prisma.RepairWhereInput {
|
||||||
const { page, pageSize, status, hostId, assigneeId, openOnly, problemPartId } = q;
|
const where: Prisma.RepairWhereInput = {};
|
||||||
const where: Prisma.RepairJobWhereInput = {};
|
if (q.hostId) where.hostId = q.hostId;
|
||||||
if (status) where.status = status;
|
if (q.performedById) where.performedById = q.performedById;
|
||||||
if (hostId) where.hostId = hostId;
|
if (q.fmId) where.fmId = q.fmId;
|
||||||
if (assigneeId) where.assigneeId = assigneeId;
|
if (q.since) where.performedAt = { gte: new Date(q.since) };
|
||||||
if (openOnly) where.status = { in: ['PENDING', 'IN_PROGRESS'] };
|
return where;
|
||||||
if (problemPartId) where.problemParts = { some: { partId: problemPartId } };
|
}
|
||||||
|
|
||||||
|
export async function list(tx: Tx, q: RepairListQuery) {
|
||||||
|
const { page, pageSize } = q;
|
||||||
|
const where = buildWhere(q);
|
||||||
const [data, total] = await Promise.all([
|
const [data, total] = await Promise.all([
|
||||||
tx.repairJob.findMany({
|
tx.repair.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy: [{ status: 'asc' }, { openedAt: 'desc' }],
|
orderBy: { performedAt: 'desc' },
|
||||||
include: repairInclude,
|
include: repairInclude,
|
||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
}),
|
}),
|
||||||
tx.repairJob.count({ where }),
|
tx.repair.count({ where }),
|
||||||
]);
|
]);
|
||||||
return { data, page, pageSize, total };
|
return { data, page, pageSize, total };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function get(tx: Tx, id: string) {
|
export function get(tx: Tx, id: string) {
|
||||||
return tx.repairJob.findUnique({ where: { id }, include: repairInclude });
|
return tx.repair.findUnique({ where: { id }, include: repairInclude });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listForHost(tx: Tx, hostId: string) {
|
function repairPayload(r: RepairWithRelations) {
|
||||||
return tx.repairJob.findMany({
|
return {
|
||||||
where: { hostId },
|
id: r.id,
|
||||||
orderBy: { openedAt: 'desc' },
|
host: { id: r.host.id, assetId: r.host.assetId, name: r.host.name },
|
||||||
include: repairInclude,
|
brokenPart: {
|
||||||
});
|
id: r.brokenPart.id,
|
||||||
|
serialNumber: r.brokenPart.serialNumber,
|
||||||
|
mpn: r.brokenPart.partModel.mpn,
|
||||||
|
state: r.brokenPart.state,
|
||||||
|
},
|
||||||
|
replacement: {
|
||||||
|
id: r.replacement.id,
|
||||||
|
serialNumber: r.replacement.serialNumber,
|
||||||
|
mpn: r.replacement.partModel.mpn,
|
||||||
|
state: r.replacement.state,
|
||||||
|
},
|
||||||
|
performedBy: r.performedBy,
|
||||||
|
performedAt: r.performedAt.toISOString(),
|
||||||
|
fmId: r.fmId,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validates that the submitted problem-part ids are all attached to the named host.
|
export async function log(
|
||||||
// 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,
|
tx: Tx,
|
||||||
input: CreateRepairJobRequest,
|
input: LogRepairRequest,
|
||||||
actor: Actor | null,
|
actor: Actor,
|
||||||
) {
|
): Promise<RepairWithRelations> {
|
||||||
const host = await tx.host.findUnique({ where: { id: input.hostId } });
|
const host = await resolveHost(tx, input);
|
||||||
if (!host) throw errors.notFound('Host');
|
|
||||||
|
|
||||||
await validateProblemParts(tx, input.hostId, input.problemPartIds);
|
// 1. Resolve replacement — must exist, must be SPARE.
|
||||||
|
const replacement = await tx.part.findUnique({
|
||||||
|
where: { serialNumber: input.replacementSerial },
|
||||||
|
include: { partModel: true },
|
||||||
|
});
|
||||||
|
if (!replacement) {
|
||||||
|
throw errors.badRequest(`Replacement part ${input.replacementSerial} not found`);
|
||||||
|
}
|
||||||
|
if (replacement.state !== 'SPARE') {
|
||||||
|
throw errors.badRequest(
|
||||||
|
`Replacement part ${input.replacementSerial} is ${replacement.state}, must be SPARE`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
// 2. Resolve broken — reuse if found, else ingest.
|
||||||
const repair = await tx.repairJob.create({
|
let broken = await tx.part.findUnique({
|
||||||
|
where: { serialNumber: input.brokenSerial },
|
||||||
|
include: { partModel: true },
|
||||||
|
});
|
||||||
|
if (broken) {
|
||||||
|
if (broken.hostId && broken.hostId !== host.id) {
|
||||||
|
throw errors.badRequest(
|
||||||
|
`Broken part ${input.brokenSerial} is currently on a different host`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const pm = await partModelsSvc.upsertByMpn(tx, {
|
||||||
|
manufacturerId: input.brokenManufacturerId,
|
||||||
|
mpn: input.brokenMpn,
|
||||||
|
});
|
||||||
|
const created = await tx.part.create({
|
||||||
data: {
|
data: {
|
||||||
hostId: input.hostId,
|
serialNumber: input.brokenSerial,
|
||||||
assigneeId: input.assigneeId ?? null,
|
partModelId: pm.id,
|
||||||
notes: input.notes ?? null,
|
manufacturerId: pm.manufacturerId,
|
||||||
problem: input.problem,
|
state: 'DEPLOYED',
|
||||||
status: 'PENDING',
|
hostId: host.id,
|
||||||
problemParts: input.problemPartIds && input.problemPartIds.length > 0
|
|
||||||
? { create: [...new Set(input.problemPartIds)].map((partId) => ({ partId })) }
|
|
||||||
: undefined,
|
|
||||||
},
|
},
|
||||||
include: repairInclude,
|
include: { partModel: true },
|
||||||
});
|
});
|
||||||
if (input.problemPartIds && input.problemPartIds.length > 0) {
|
await tx.partEvent.create({
|
||||||
await tx.partEvent.createMany({
|
data: {
|
||||||
data: [...new Set(input.problemPartIds)].map((partId) => ({
|
partId: created.id,
|
||||||
partId,
|
userId: actor.id,
|
||||||
userId: actor?.id ?? null,
|
type: 'CREATED',
|
||||||
type: 'REPAIR_STARTED',
|
newValue: created.serialNumber,
|
||||||
newValue: repair.id,
|
},
|
||||||
})),
|
});
|
||||||
});
|
broken = created;
|
||||||
}
|
|
||||||
return repair;
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2003') {
|
|
||||||
throw errors.badRequest('Invalid host, assignee, or part id');
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export async function update(
|
// 3. Optional FM link — must belong to the same host; we do NOT auto-close it.
|
||||||
tx: Tx,
|
if (input.fmId) {
|
||||||
id: string,
|
const fm = await tx.fm.findUnique({ where: { id: input.fmId } });
|
||||||
input: UpdateRepairJobRequest,
|
if (!fm) throw errors.badRequest('FM does not exist');
|
||||||
actor: Actor | null,
|
if (fm.hostId !== host.id) {
|
||||||
) {
|
throw errors.badRequest('FM is on a different host than the repair');
|
||||||
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;
|
|
||||||
const nowTerminal = input.status === 'COMPLETED' || input.status === 'CANCELLED';
|
|
||||||
const wasTerminal = current.status === 'COMPLETED' || current.status === 'CANCELLED';
|
|
||||||
if (nowTerminal && !wasTerminal) {
|
|
||||||
data.closedAt = new Date();
|
|
||||||
terminalTransition = input.status as 'COMPLETED' | 'CANCELLED';
|
|
||||||
}
|
|
||||||
if (!nowTerminal && wasTerminal) data.closedAt = null;
|
|
||||||
}
|
|
||||||
if (input.problem !== undefined) data.problem = input.problem;
|
|
||||||
if (input.assigneeId !== undefined) {
|
|
||||||
data.assignee = input.assigneeId
|
|
||||||
? { connect: { id: input.assigneeId } }
|
|
||||||
: { disconnect: true };
|
|
||||||
}
|
|
||||||
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({
|
// 4. Custody state is driven by the broken model's destroyOnFail flag.
|
||||||
where: { id },
|
const custodyState = broken.partModel.destroyOnFail
|
||||||
data,
|
? 'PENDING_DESTRUCTION_IN_CUSTODY'
|
||||||
|
: 'PENDING_DROP_IN_CUSTODY';
|
||||||
|
|
||||||
|
// 5. Transition both parts through the standard parts.update machinery so every state
|
||||||
|
// and location change emits the usual PartEvents. The resolver clears host/bin
|
||||||
|
// automatically when entering custody / DEPLOYED.
|
||||||
|
await partsSvc.update(
|
||||||
|
tx,
|
||||||
|
broken.id,
|
||||||
|
{ state: custodyState, custodianId: actor.id },
|
||||||
|
actor,
|
||||||
|
);
|
||||||
|
await partsSvc.update(
|
||||||
|
tx,
|
||||||
|
replacement.id,
|
||||||
|
{ state: 'DEPLOYED', hostId: host.id },
|
||||||
|
actor,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 6. Persist the Repair row.
|
||||||
|
const repair = await tx.repair.create({
|
||||||
|
data: {
|
||||||
|
hostId: host.id,
|
||||||
|
brokenPartId: broken.id,
|
||||||
|
replacementPartId: replacement.id,
|
||||||
|
performedById: actor.id,
|
||||||
|
fmId: input.fmId ?? null,
|
||||||
|
},
|
||||||
include: repairInclude,
|
include: repairInclude,
|
||||||
});
|
});
|
||||||
|
|
||||||
const userId = actor?.id ?? null;
|
// 7. Swap event on each part — so the part timeline shows the repair link.
|
||||||
if (addedPartIds.length > 0) {
|
await tx.partEvent.createMany({
|
||||||
await tx.partEvent.createMany({
|
data: [
|
||||||
data: addedPartIds.map((partId) => ({
|
{
|
||||||
partId,
|
partId: broken.id,
|
||||||
userId,
|
userId: actor.id,
|
||||||
type: 'REPAIR_STARTED',
|
type: 'PART_SWAPPED',
|
||||||
|
field: 'role',
|
||||||
|
oldValue: 'DEPLOYED',
|
||||||
newValue: repair.id,
|
newValue: repair.id,
|
||||||
})),
|
},
|
||||||
});
|
{
|
||||||
}
|
partId: replacement.id,
|
||||||
if (terminalTransition !== null) {
|
userId: actor.id,
|
||||||
const partIds = repair.problemParts.map((p) => p.partId);
|
type: 'PART_SWAPPED',
|
||||||
if (partIds.length > 0) {
|
field: 'role',
|
||||||
await tx.partEvent.createMany({
|
oldValue: 'SPARE',
|
||||||
data: partIds.map((partId) => ({
|
newValue: repair.id,
|
||||||
partId,
|
},
|
||||||
userId,
|
],
|
||||||
type: terminalTransition === 'COMPLETED' ? 'REPAIR_COMPLETED' : 'REPAIR_CANCELLED',
|
});
|
||||||
newValue: repair.id,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
void emit({ event: 'repair.logged', payload: { repair: repairPayload(repair) } });
|
||||||
return repair;
|
return repair;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function remove(tx: Tx, id: string) {
|
|
||||||
try {
|
|
||||||
await tx.repairJob.delete({ where: { id } });
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
|
|
||||||
throw errors.notFound('Repair');
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ import PartDetail from './pages/PartDetail.js';
|
|||||||
import Locations from './pages/Locations.js';
|
import Locations from './pages/Locations.js';
|
||||||
import Manufacturers from './pages/Manufacturers.js';
|
import Manufacturers from './pages/Manufacturers.js';
|
||||||
import PartModels from './pages/PartModels.js';
|
import PartModels from './pages/PartModels.js';
|
||||||
|
import Fms from './pages/Fms.js';
|
||||||
|
import FmDetail from './pages/FmDetail.js';
|
||||||
import Repairs from './pages/Repairs.js';
|
import Repairs from './pages/Repairs.js';
|
||||||
import RepairDetail from './pages/RepairDetail.js';
|
import MyCustody from './pages/MyCustody.js';
|
||||||
import Hosts from './pages/Hosts.js';
|
import Hosts from './pages/Hosts.js';
|
||||||
import Users from './pages/admin/Users.js';
|
import Users from './pages/admin/Users.js';
|
||||||
import Webhooks from './pages/admin/Webhooks.js';
|
import Webhooks from './pages/admin/Webhooks.js';
|
||||||
@@ -57,8 +59,10 @@ export default function App() {
|
|||||||
<Route path="/locations" element={<Locations />} />
|
<Route path="/locations" element={<Locations />} />
|
||||||
<Route path="/manufacturers" element={<Manufacturers />} />
|
<Route path="/manufacturers" element={<Manufacturers />} />
|
||||||
<Route path="/part-models" element={<PartModels />} />
|
<Route path="/part-models" element={<PartModels />} />
|
||||||
|
<Route path="/fms" element={<Fms />} />
|
||||||
|
<Route path="/fms/:id" element={<FmDetail />} />
|
||||||
<Route path="/repairs" element={<Repairs />} />
|
<Route path="/repairs" element={<Repairs />} />
|
||||||
<Route path="/repairs/:id" element={<RepairDetail />} />
|
<Route path="/custody" element={<MyCustody />} />
|
||||||
<Route path="/hosts" element={<Hosts />} />
|
<Route path="/hosts" element={<Hosts />} />
|
||||||
<Route
|
<Route
|
||||||
path="/admin/users"
|
path="/admin/users"
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@vector/ui';
|
||||||
|
import { listBins } from '../../lib/api/bins.js';
|
||||||
|
import { queryKeys } from '../../lib/queryKeys.js';
|
||||||
|
import type { Part } from '../../lib/api/types.js';
|
||||||
|
|
||||||
|
const UNASSIGNED = '__none__';
|
||||||
|
|
||||||
|
interface DropOffDialogProps {
|
||||||
|
part: Part | null;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onConfirm: (binId: string | null) => void;
|
||||||
|
pending: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropOffDialog({ part, onOpenChange, onConfirm, pending }: DropOffDialogProps) {
|
||||||
|
const open = Boolean(part);
|
||||||
|
const [binId, setBinId] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) setBinId('');
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const bins = useQuery({
|
||||||
|
queryKey: queryKeys.bins.list({ pageSize: 100 }),
|
||||||
|
queryFn: () => listBins({ pageSize: 100 }),
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
|
const destruction = part?.state === 'PENDING_DESTRUCTION_IN_CUSTODY';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Drop in bin</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{destruction
|
||||||
|
? 'This part is flagged for destruction. It will move to pending-destruction — optionally place it in a destruction bin.'
|
||||||
|
: `Dropping ${part?.serialNumber ?? ''} into a bin marks it broken.`}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<Select
|
||||||
|
value={binId ? binId : UNASSIGNED}
|
||||||
|
onValueChange={(v) => setBinId(v === UNASSIGNED ? '' : v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Unassigned" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={UNASSIGNED}>Unassigned</SelectItem>
|
||||||
|
{bins.data?.data.map((b) => (
|
||||||
|
<SelectItem key={b.id} value={b.id}>
|
||||||
|
{b.fullPath ?? b.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={pending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => onConfirm(binId || null)} disabled={pending}>
|
||||||
|
{pending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
Drop off
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
+17
-43
@@ -29,35 +29,34 @@ import {
|
|||||||
Skeleton,
|
Skeleton,
|
||||||
Textarea,
|
Textarea,
|
||||||
} from '@vector/ui';
|
} from '@vector/ui';
|
||||||
import { createRepair } from '../../lib/api/repairs.js';
|
import { createFm } from '../../lib/api/fms.js';
|
||||||
import { listHosts, listHostDeployedParts } from '../../lib/api/hosts.js';
|
import { listHosts, listHostDeployedParts } from '../../lib/api/hosts.js';
|
||||||
import { ApiRequestError } from '../../lib/api/client.js';
|
import { ApiRequestError } from '../../lib/api/client.js';
|
||||||
import { queryKeys } from '../../lib/queryKeys.js';
|
import { queryKeys } from '../../lib/queryKeys.js';
|
||||||
import type { RepairJob } from '../../lib/api/types.js';
|
import type { Fm } from '../../lib/api/types.js';
|
||||||
|
|
||||||
const CreateSchema = z.object({
|
const CreateSchema = z.object({
|
||||||
hostId: z.string().uuid('Pick a host'),
|
hostId: z.string().uuid('Pick a host'),
|
||||||
problem: z.string().trim().min(1, 'Describe the problem').max(2000),
|
problem: z.string().trim().min(1, 'Describe the problem').max(2000),
|
||||||
problemPartIds: z.array(z.string().uuid()).max(100),
|
problemPartIds: z.array(z.string().uuid()).max(100),
|
||||||
notes: z.string().max(4096).optional(),
|
|
||||||
});
|
});
|
||||||
type CreateValues = z.infer<typeof CreateSchema>;
|
type CreateValues = z.infer<typeof CreateSchema>;
|
||||||
|
|
||||||
interface RepairFormDialogProps {
|
interface FmFormDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
defaultHostId?: string;
|
defaultHostId?: string;
|
||||||
defaultProblemPartIds?: string[];
|
defaultProblemPartIds?: string[];
|
||||||
onCreated?: (repair: RepairJob) => void;
|
onCreated?: (fm: Fm) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RepairFormDialog({
|
export function FmFormDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
defaultHostId,
|
defaultHostId,
|
||||||
defaultProblemPartIds,
|
defaultProblemPartIds,
|
||||||
onCreated,
|
onCreated,
|
||||||
}: RepairFormDialogProps) {
|
}: FmFormDialogProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const hostsQuery = useQuery({
|
const hostsQuery = useQuery({
|
||||||
@@ -68,12 +67,7 @@ export function RepairFormDialog({
|
|||||||
|
|
||||||
const form = useForm<CreateValues>({
|
const form = useForm<CreateValues>({
|
||||||
resolver: zodResolver(CreateSchema),
|
resolver: zodResolver(CreateSchema),
|
||||||
defaultValues: {
|
defaultValues: { hostId: '', problem: '', problemPartIds: [] },
|
||||||
hostId: '',
|
|
||||||
problem: '',
|
|
||||||
problemPartIds: [],
|
|
||||||
notes: '',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -82,7 +76,6 @@ export function RepairFormDialog({
|
|||||||
hostId: defaultHostId ?? '',
|
hostId: defaultHostId ?? '',
|
||||||
problem: '',
|
problem: '',
|
||||||
problemPartIds: defaultProblemPartIds ?? [],
|
problemPartIds: defaultProblemPartIds ?? [],
|
||||||
notes: '',
|
|
||||||
});
|
});
|
||||||
}, [open, defaultHostId, defaultProblemPartIds, form]);
|
}, [open, defaultHostId, defaultProblemPartIds, form]);
|
||||||
|
|
||||||
@@ -96,19 +89,18 @@ export function RepairFormDialog({
|
|||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: async (values: CreateValues) =>
|
mutationFn: async (values: CreateValues) =>
|
||||||
createRepair({
|
createFm({
|
||||||
hostId: values.hostId,
|
hostId: values.hostId,
|
||||||
problem: values.problem,
|
problem: values.problem,
|
||||||
problemPartIds:
|
problemPartIds:
|
||||||
values.problemPartIds.length > 0 ? values.problemPartIds : undefined,
|
values.problemPartIds.length > 0 ? values.problemPartIds : undefined,
|
||||||
notes: values.notes ? values.notes : null,
|
|
||||||
}),
|
}),
|
||||||
onSuccess: (repair) => {
|
onSuccess: (fm) => {
|
||||||
toast.success('Repair opened');
|
toast.success('FM opened');
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.repairs.all });
|
queryClient.invalidateQueries({ queryKey: queryKeys.fms.all });
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
|
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
onCreated?.(repair);
|
onCreated?.(fm);
|
||||||
},
|
},
|
||||||
onError: (err) =>
|
onError: (err) =>
|
||||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Save failed'),
|
toast.error(err instanceof ApiRequestError ? err.body.message : 'Save failed'),
|
||||||
@@ -127,9 +119,9 @@ export function RepairFormDialog({
|
|||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-lg">
|
<DialogContent className="max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Open repair</DialogTitle>
|
<DialogTitle>Open FM</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Create a repair against a host. Select the deployed parts involved (optional).
|
Open a Future Maintenance item against a host. Select deployed parts involved (optional).
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -147,9 +139,7 @@ export function RepairFormDialog({
|
|||||||
<Select
|
<Select
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
field.onChange(v);
|
field.onChange(v);
|
||||||
form.setValue('problemPartIds', [], {
|
form.setValue('problemPartIds', [], { shouldValidate: false });
|
||||||
shouldValidate: false,
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
value={field.value}
|
value={field.value}
|
||||||
>
|
>
|
||||||
@@ -224,9 +214,7 @@ export function RepairFormDialog({
|
|||||||
htmlFor={`pp-${part.id}`}
|
htmlFor={`pp-${part.id}`}
|
||||||
className="flex-1 cursor-pointer select-none"
|
className="flex-1 cursor-pointer select-none"
|
||||||
>
|
>
|
||||||
<span className="font-mono text-xs">
|
<span className="font-mono text-xs">{part.serialNumber}</span>{' '}
|
||||||
{part.serialNumber}
|
|
||||||
</span>{' '}
|
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{part.partModel.mpn}
|
{part.partModel.mpn}
|
||||||
</span>
|
</span>
|
||||||
@@ -243,20 +231,6 @@ export function RepairFormDialog({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="notes"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Notes (optional)</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Textarea rows={2} {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -268,7 +242,7 @@ export function RepairFormDialog({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={pending}>
|
<Button type="submit" disabled={pending}>
|
||||||
{pending && <Loader2 className="h-4 w-4 animate-spin" />}
|
{pending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
Open repair
|
Open FM
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
|
ArrowRightLeft,
|
||||||
Boxes,
|
Boxes,
|
||||||
ChevronsLeft,
|
ChevronsLeft,
|
||||||
ChevronsRight,
|
ChevronsRight,
|
||||||
|
Hand,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Layers,
|
Layers,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
@@ -29,7 +31,9 @@ const NAV_ITEMS: NavItem[] = [
|
|||||||
{ to: '/part-models', label: 'Part models', icon: Layers },
|
{ to: '/part-models', label: 'Part models', icon: Layers },
|
||||||
{ to: '/locations', label: 'Locations', icon: MapPinned },
|
{ to: '/locations', label: 'Locations', icon: MapPinned },
|
||||||
{ to: '/manufacturers', label: 'Manufacturers', icon: Boxes },
|
{ to: '/manufacturers', label: 'Manufacturers', icon: Boxes },
|
||||||
{ to: '/repairs', label: 'Repairs', icon: Wrench },
|
{ to: '/fms', label: 'FMs', icon: Wrench },
|
||||||
|
{ to: '/repairs', label: 'Repairs', icon: ArrowRightLeft },
|
||||||
|
{ to: '/custody', label: 'My Custody', icon: Hand },
|
||||||
{ to: '/hosts', label: 'Hosts', icon: Server },
|
{ to: '/hosts', label: 'Hosts', icon: Server },
|
||||||
{ to: '/admin/users', label: 'Users', icon: UsersIcon, adminOnly: true },
|
{ to: '/admin/users', label: 'Users', icon: UsersIcon, adminOnly: true },
|
||||||
{ to: '/admin/webhooks', label: 'Webhooks', icon: Webhook, adminOnly: true },
|
{ to: '/admin/webhooks', label: 'Webhooks', icon: Webhook, adminOnly: true },
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Loader2 } from 'lucide-react';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
Checkbox,
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
@@ -42,6 +43,7 @@ const Schema = z.object({
|
|||||||
.regex(/^\d{4}-\d{2}-\d{2}$/, 'YYYY-MM-DD')
|
.regex(/^\d{4}-\d{2}-\d{2}$/, 'YYYY-MM-DD')
|
||||||
.or(z.literal(''))
|
.or(z.literal(''))
|
||||||
.optional(),
|
.optional(),
|
||||||
|
destroyOnFail: z.boolean(),
|
||||||
notes: z.string().max(4096).optional(),
|
notes: z.string().max(4096).optional(),
|
||||||
});
|
});
|
||||||
type Values = z.infer<typeof Schema>;
|
type Values = z.infer<typeof Schema>;
|
||||||
@@ -67,7 +69,13 @@ export function PartModelFormDialog({
|
|||||||
|
|
||||||
const form = useForm<Values>({
|
const form = useForm<Values>({
|
||||||
resolver: zodResolver(Schema),
|
resolver: zodResolver(Schema),
|
||||||
defaultValues: { manufacturerId: '', mpn: '', eolDate: '', notes: '' },
|
defaultValues: {
|
||||||
|
manufacturerId: '',
|
||||||
|
mpn: '',
|
||||||
|
eolDate: '',
|
||||||
|
destroyOnFail: false,
|
||||||
|
notes: '',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -76,6 +84,7 @@ export function PartModelFormDialog({
|
|||||||
manufacturerId: partModel?.manufacturerId ?? '',
|
manufacturerId: partModel?.manufacturerId ?? '',
|
||||||
mpn: partModel?.mpn ?? '',
|
mpn: partModel?.mpn ?? '',
|
||||||
eolDate: isoToDateInput(partModel?.eolDate ?? null),
|
eolDate: isoToDateInput(partModel?.eolDate ?? null),
|
||||||
|
destroyOnFail: partModel?.destroyOnFail ?? false,
|
||||||
notes: partModel?.notes ?? '',
|
notes: partModel?.notes ?? '',
|
||||||
});
|
});
|
||||||
}, [open, partModel, form]);
|
}, [open, partModel, form]);
|
||||||
@@ -92,6 +101,7 @@ export function PartModelFormDialog({
|
|||||||
manufacturerId: values.manufacturerId,
|
manufacturerId: values.manufacturerId,
|
||||||
mpn: values.mpn,
|
mpn: values.mpn,
|
||||||
eolDate: values.eolDate ? values.eolDate : null,
|
eolDate: values.eolDate ? values.eolDate : null,
|
||||||
|
destroyOnFail: values.destroyOnFail,
|
||||||
notes: values.notes ? values.notes : null,
|
notes: values.notes ? values.notes : null,
|
||||||
};
|
};
|
||||||
return editing && partModel
|
return editing && partModel
|
||||||
@@ -172,6 +182,29 @@ export function PartModelFormDialog({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="destroyOnFail"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-start gap-2 space-y-0">
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
id="destroyOnFail"
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={(v) => field.onChange(v === true)}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel htmlFor="destroyOnFail">Destroy on fail</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
When this model fails, its broken part goes to the destruction path instead
|
||||||
|
of being held for return/repair.
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="notes"
|
name="notes"
|
||||||
|
|||||||
@@ -2,14 +2,13 @@ import { useState } from 'react';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
ArrowRightLeft,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
MapPin,
|
MapPin,
|
||||||
MessageSquare,
|
|
||||||
Package,
|
Package,
|
||||||
Pencil,
|
Pencil,
|
||||||
Tag,
|
Tag,
|
||||||
Wrench,
|
Wrench,
|
||||||
XCircle,
|
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { PartEventType } from '@vector/shared';
|
import type { PartEventType } from '@vector/shared';
|
||||||
@@ -22,10 +21,9 @@ const EVENT_ICON: Record<PartEventType, LucideIcon> = {
|
|||||||
STATE_CHANGED: CheckCircle2,
|
STATE_CHANGED: CheckCircle2,
|
||||||
LOCATION_CHANGED: MapPin,
|
LOCATION_CHANGED: MapPin,
|
||||||
FIELD_UPDATED: Pencil,
|
FIELD_UPDATED: Pencil,
|
||||||
REPAIR_STARTED: Wrench,
|
FM_OPENED: Wrench,
|
||||||
REPAIR_COMPLETED: Wrench,
|
FM_CLOSED: Wrench,
|
||||||
REPAIR_CANCELLED: XCircle,
|
PART_SWAPPED: ArrowRightLeft,
|
||||||
REPAIR_COMMENTED: MessageSquare,
|
|
||||||
TAG_ADDED: Tag,
|
TAG_ADDED: Tag,
|
||||||
TAG_REMOVED: Tag,
|
TAG_REMOVED: Tag,
|
||||||
};
|
};
|
||||||
@@ -35,10 +33,9 @@ const EVENT_TITLE: Record<PartEventType, string> = {
|
|||||||
STATE_CHANGED: 'State changed',
|
STATE_CHANGED: 'State changed',
|
||||||
LOCATION_CHANGED: 'Location changed',
|
LOCATION_CHANGED: 'Location changed',
|
||||||
FIELD_UPDATED: 'Field updated',
|
FIELD_UPDATED: 'Field updated',
|
||||||
REPAIR_STARTED: 'Repair started',
|
FM_OPENED: 'FM opened',
|
||||||
REPAIR_COMPLETED: 'Repair completed',
|
FM_CLOSED: 'FM closed',
|
||||||
REPAIR_CANCELLED: 'Repair cancelled',
|
PART_SWAPPED: 'Part swapped',
|
||||||
REPAIR_COMMENTED: 'Repair comment',
|
|
||||||
TAG_ADDED: 'Tag added',
|
TAG_ADDED: 'Tag added',
|
||||||
TAG_REMOVED: 'Tag removed',
|
TAG_REMOVED: 'Tag removed',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -294,7 +294,18 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{watchedState === 'DEPLOYED' ? (
|
{watchedState === 'PENDING_DROP_IN_CUSTODY' ||
|
||||||
|
watchedState === 'PENDING_DESTRUCTION_IN_CUSTODY' ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm font-medium">Location</div>
|
||||||
|
<div className="inline-flex items-center rounded-md border border-border bg-muted/50 px-2.5 py-1 text-xs">
|
||||||
|
In custody: {part?.custodian?.username ?? '—'}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Drop-off happens through the My Custody page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : watchedState === 'DEPLOYED' ? (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="hostId"
|
name="hostId"
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
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';
|
|
||||||
|
|
||||||
interface PartRepairSectionProps {
|
|
||||||
partId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PartRepairSection({ partId }: PartRepairSectionProps) {
|
|
||||||
const query = useQuery({
|
|
||||||
queryKey: queryKeys.repairs.list({ problemPartId: partId, pageSize: 50 }),
|
|
||||||
queryFn: () => listRepairs({ problemPartId: partId, pageSize: 50 }),
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm font-medium">Repairs touching this part</p>
|
|
||||||
{query.isPending ? (
|
|
||||||
<Skeleton className="h-16 w-full" />
|
|
||||||
) : !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.data.map((repair) => (
|
|
||||||
<li
|
|
||||||
key={repair.id}
|
|
||||||
className="flex items-center justify-between gap-3 px-3 py-2"
|
|
||||||
>
|
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
|
||||||
<RepairStatusBadge status={repair.status} />
|
|
||||||
<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>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{new Date(repair.openedAt).toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -6,6 +6,8 @@ const STATE_LABEL: Record<PartState, string> = {
|
|||||||
DEPLOYED: 'Deployed',
|
DEPLOYED: 'Deployed',
|
||||||
BROKEN: 'Broken',
|
BROKEN: 'Broken',
|
||||||
PENDING_DESTRUCTION: 'Pending destruction',
|
PENDING_DESTRUCTION: 'Pending destruction',
|
||||||
|
PENDING_DROP_IN_CUSTODY: 'In custody',
|
||||||
|
PENDING_DESTRUCTION_IN_CUSTODY: 'In custody (destroy)',
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATE_VARIANT: Record<PartState, BadgeProps['variant']> = {
|
const STATE_VARIANT: Record<PartState, BadgeProps['variant']> = {
|
||||||
@@ -13,12 +15,16 @@ const STATE_VARIANT: Record<PartState, BadgeProps['variant']> = {
|
|||||||
DEPLOYED: 'success',
|
DEPLOYED: 'success',
|
||||||
BROKEN: 'warning',
|
BROKEN: 'warning',
|
||||||
PENDING_DESTRUCTION: 'destructive',
|
PENDING_DESTRUCTION: 'destructive',
|
||||||
|
PENDING_DROP_IN_CUSTODY: 'outline',
|
||||||
|
PENDING_DESTRUCTION_IN_CUSTODY: 'outline',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PartStateBadge({ state }: { state: PartState }) {
|
export function PartStateBadge({ state }: { state: PartState }) {
|
||||||
return <Badge variant={STATE_VARIANT[state]}>{STATE_LABEL[state]}</Badge>;
|
return <Badge variant={STATE_VARIANT[state]}>{STATE_LABEL[state]}</Badge>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Options users can set via the Part form. Custody states are intentionally excluded —
|
||||||
|
// they're only reached via the Repair flow, then unwound via the Custody drop-off page.
|
||||||
export const partStateOptions: { value: PartState; label: string }[] = [
|
export const partStateOptions: { value: PartState; label: string }[] = [
|
||||||
{ value: 'SPARE', label: 'Spare' },
|
{ value: 'SPARE', label: 'Spare' },
|
||||||
{ value: 'DEPLOYED', label: 'Deployed' },
|
{ value: 'DEPLOYED', label: 'Deployed' },
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
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,
|
||||||
|
} from '@vector/ui';
|
||||||
|
import { logRepair } from '../../lib/api/repairs.js';
|
||||||
|
import { listHosts } from '../../lib/api/hosts.js';
|
||||||
|
import { listManufacturers } from '../../lib/api/manufacturers.js';
|
||||||
|
import { listFms } from '../../lib/api/fms.js';
|
||||||
|
import { listParts } from '../../lib/api/parts.js';
|
||||||
|
import { ApiRequestError } from '../../lib/api/client.js';
|
||||||
|
import { queryKeys } from '../../lib/queryKeys.js';
|
||||||
|
import type { Repair } from '../../lib/api/types.js';
|
||||||
|
|
||||||
|
const Schema = z.object({
|
||||||
|
hostId: z.string().uuid('Pick a host'),
|
||||||
|
brokenSerial: z.string().trim().min(1, 'Required').max(128),
|
||||||
|
brokenMpn: z.string().trim().min(1, 'Required').max(128),
|
||||||
|
brokenManufacturerId: z.string().uuid('Select a manufacturer'),
|
||||||
|
replacementSerial: z.string().trim().min(1, 'Required').max(128),
|
||||||
|
fmId: z.string().optional(),
|
||||||
|
});
|
||||||
|
type Values = z.infer<typeof Schema>;
|
||||||
|
|
||||||
|
const NO_FM = '__none__';
|
||||||
|
|
||||||
|
interface LogRepairDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onLogged?: (repair: Repair) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogRepairDialog({ open, onOpenChange, onLogged }: LogRepairDialogProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const form = useForm<Values>({
|
||||||
|
resolver: zodResolver(Schema),
|
||||||
|
defaultValues: {
|
||||||
|
hostId: '',
|
||||||
|
brokenSerial: '',
|
||||||
|
brokenMpn: '',
|
||||||
|
brokenManufacturerId: '',
|
||||||
|
replacementSerial: '',
|
||||||
|
fmId: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
form.reset({
|
||||||
|
hostId: '',
|
||||||
|
brokenSerial: '',
|
||||||
|
brokenMpn: '',
|
||||||
|
brokenManufacturerId: '',
|
||||||
|
replacementSerial: '',
|
||||||
|
fmId: '',
|
||||||
|
});
|
||||||
|
}, [open, form]);
|
||||||
|
|
||||||
|
const hostId = form.watch('hostId');
|
||||||
|
const brokenSerial = form.watch('brokenSerial').trim();
|
||||||
|
|
||||||
|
const hosts = useQuery({
|
||||||
|
queryKey: queryKeys.hosts.list({ pageSize: 100 }),
|
||||||
|
queryFn: () => listHosts({ pageSize: 100 }),
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
|
const manufacturers = useQuery({
|
||||||
|
queryKey: queryKeys.manufacturers.list({ pageSize: 100 }),
|
||||||
|
queryFn: () => listManufacturers({ pageSize: 100 }),
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open FMs on the chosen host, so the optional linker only shows relevant items.
|
||||||
|
const openFms = useQuery({
|
||||||
|
queryKey: queryKeys.fms.list({ hostId, status: 'OPEN', pageSize: 50 }),
|
||||||
|
queryFn: () => listFms({ hostId, status: 'OPEN', pageSize: 50 }),
|
||||||
|
enabled: open && Boolean(hostId),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Debounced live lookup — as the tech types a broken serial, hint whether Vector
|
||||||
|
// already knows that part (existing) or will auto-ingest it (new).
|
||||||
|
const brokenLookup = useQuery({
|
||||||
|
queryKey: queryKeys.parts.list({ serialNumber: brokenSerial, pageSize: 1 }),
|
||||||
|
queryFn: () => listParts({ serialNumber: brokenSerial, pageSize: 1 }),
|
||||||
|
enabled: open && brokenSerial.length >= 3,
|
||||||
|
staleTime: 5_000,
|
||||||
|
});
|
||||||
|
const existingBroken = brokenLookup.data?.data.find(
|
||||||
|
(p) => p.serialNumber === brokenSerial,
|
||||||
|
);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (v: Values) =>
|
||||||
|
logRepair({
|
||||||
|
hostId: v.hostId,
|
||||||
|
brokenSerial: v.brokenSerial.trim(),
|
||||||
|
brokenMpn: v.brokenMpn.trim(),
|
||||||
|
brokenManufacturerId: v.brokenManufacturerId,
|
||||||
|
replacementSerial: v.replacementSerial.trim(),
|
||||||
|
fmId: v.fmId ? v.fmId : undefined,
|
||||||
|
}),
|
||||||
|
onSuccess: (repair) => {
|
||||||
|
toast.success('Repair logged');
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.repairs.all });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.custody.all });
|
||||||
|
onOpenChange(false);
|
||||||
|
onLogged?.(repair);
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast.error(err instanceof ApiRequestError ? err.body.message : 'Could not log repair'),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Log a repair</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Record a physical part swap. The broken part goes into your custody until you drop it
|
||||||
|
in a bin from the My Custody page.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit((v) => mutation.mutate(v))}
|
||||||
|
className="space-y-3"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="hostId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Host</FormLabel>
|
||||||
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select host" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{hosts.data?.data.map((h) => (
|
||||||
|
<SelectItem key={h.id} value={h.id}>
|
||||||
|
{h.assetId} — {h.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="brokenSerial"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Broken serial</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input autoFocus placeholder="SN-…" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
{brokenSerial.length >= 3 && (
|
||||||
|
<FormDescription>
|
||||||
|
{brokenLookup.isFetching
|
||||||
|
? 'Looking up…'
|
||||||
|
: existingBroken
|
||||||
|
? `Found: ${existingBroken.partModel.mpn}`
|
||||||
|
: 'Will be ingested as a new part.'}
|
||||||
|
</FormDescription>
|
||||||
|
)}
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="replacementSerial"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Replacement serial</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="SN-…" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Must be an existing SPARE.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="brokenMpn"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Broken MPN</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="brokenManufacturerId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Broken manufacturer</FormLabel>
|
||||||
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{manufacturers.data?.data.map((m) => (
|
||||||
|
<SelectItem key={m.id} value={m.id}>
|
||||||
|
{m.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="fmId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Link to open FM (optional)</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={field.value ? field.value : NO_FM}
|
||||||
|
onValueChange={(v) => field.onChange(v === NO_FM ? '' : v)}
|
||||||
|
disabled={!hostId}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={hostId ? 'No linked FM' : 'Pick a host first'} />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NO_FM}>No linked FM</SelectItem>
|
||||||
|
{openFms.data?.data.map((f) => (
|
||||||
|
<SelectItem key={f.id} value={f.id}>
|
||||||
|
{f.problem.slice(0, 80)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>
|
||||||
|
Linking doesn't auto-close the FM — n8n handles that.
|
||||||
|
</FormDescription>
|
||||||
|
<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" />}
|
||||||
|
Log repair
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import type { RepairStatus } from '@vector/shared';
|
|
||||||
import { Badge } from '@vector/ui';
|
|
||||||
|
|
||||||
const LABELS: Record<RepairStatus, string> = {
|
|
||||||
PENDING: 'Pending',
|
|
||||||
IN_PROGRESS: 'In progress',
|
|
||||||
COMPLETED: 'Completed',
|
|
||||||
CANCELLED: 'Cancelled',
|
|
||||||
};
|
|
||||||
|
|
||||||
const VARIANTS: Record<RepairStatus, 'outline' | 'warning' | 'success' | 'secondary'> = {
|
|
||||||
PENDING: 'outline',
|
|
||||||
IN_PROGRESS: 'warning',
|
|
||||||
COMPLETED: 'success',
|
|
||||||
CANCELLED: 'secondary',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const repairStatusOptions: { value: RepairStatus; label: string }[] = (
|
|
||||||
Object.keys(LABELS) as RepairStatus[]
|
|
||||||
).map((value) => ({ value, label: LABELS[value] }));
|
|
||||||
|
|
||||||
export function RepairStatusBadge({ status }: { status: RepairStatus }) {
|
|
||||||
return <Badge variant={VARIANTS[status]}>{LABELS[status]}</Badge>;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { DropOffRequest } from '@vector/shared';
|
||||||
|
import { api } from './client.js';
|
||||||
|
import { getList } from './paginated.js';
|
||||||
|
import type { Part } from './types.js';
|
||||||
|
|
||||||
|
export function listMyCustody(filters: { page?: number; pageSize?: number } = {}) {
|
||||||
|
return getList<Part>('/custody/mine', filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dropOff(partId: string, input: DropOffRequest): Promise<Part> {
|
||||||
|
const res = await api.post<Part>(`/custody/${partId}/drop-off`, input);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { CreateFmRequest, FmStatus, UpdateFmRequest } from '@vector/shared';
|
||||||
|
import { api } from './client.js';
|
||||||
|
import { getList } from './paginated.js';
|
||||||
|
import type { Fm } from './types.js';
|
||||||
|
|
||||||
|
export type FmListFilters = {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
status?: FmStatus;
|
||||||
|
hostId?: string;
|
||||||
|
problemPartId?: string;
|
||||||
|
openOnly?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function listFms(filters: FmListFilters = {}) {
|
||||||
|
return getList<Fm>('/fms', filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFm(id: string): Promise<Fm> {
|
||||||
|
const res = await api.get<Fm>(`/fms/${id}`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createFm(input: CreateFmRequest): Promise<Fm> {
|
||||||
|
const res = await api.post<Fm>('/fms', input);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateFm(id: string, input: UpdateFmRequest): Promise<Fm> {
|
||||||
|
const res = await api.patch<Fm>(`/fms/${id}`, input);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteFm(id: string): Promise<void> {
|
||||||
|
await api.delete(`/fms/${id}`);
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ export type PartListFilters = {
|
|||||||
binId?: string;
|
binId?: string;
|
||||||
tagId?: string;
|
tagId?: string;
|
||||||
eolOnly?: boolean;
|
eolOnly?: boolean;
|
||||||
|
serialNumber?: string;
|
||||||
|
custodianId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function listParts(filters: PartListFilters) {
|
export function listParts(filters: PartListFilters) {
|
||||||
|
|||||||
@@ -1,60 +1,27 @@
|
|||||||
import type {
|
import type { LogRepairRequest } from '@vector/shared';
|
||||||
CreateRepairCommentRequest,
|
|
||||||
CreateRepairJobRequest,
|
|
||||||
RepairStatus,
|
|
||||||
UpdateRepairJobRequest,
|
|
||||||
} from '@vector/shared';
|
|
||||||
import { api } from './client.js';
|
import { api } from './client.js';
|
||||||
import { getList } from './paginated.js';
|
import { getList } from './paginated.js';
|
||||||
import type { RepairComment, RepairJob } from './types.js';
|
import type { Repair } from './types.js';
|
||||||
|
|
||||||
export type RepairListFilters = {
|
export type RepairListFilters = {
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
status?: RepairStatus;
|
|
||||||
hostId?: string;
|
hostId?: string;
|
||||||
problemPartId?: string;
|
performedById?: string;
|
||||||
assigneeId?: string;
|
fmId?: string;
|
||||||
openOnly?: boolean;
|
since?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function listRepairs(filters: RepairListFilters = {}) {
|
export function listRepairs(filters: RepairListFilters = {}) {
|
||||||
return getList<RepairJob>('/repairs', filters);
|
return getList<Repair>('/repairs', filters);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRepair(id: string): Promise<RepairJob> {
|
export async function getRepair(id: string): Promise<Repair> {
|
||||||
const res = await api.get<RepairJob>(`/repairs/${id}`);
|
const res = await api.get<Repair>(`/repairs/${id}`);
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createRepair(input: CreateRepairJobRequest): Promise<RepairJob> {
|
export async function logRepair(input: LogRepairRequest): Promise<Repair> {
|
||||||
const res = await api.post<RepairJob>('/repairs', input);
|
const res = await api.post<Repair>('/repairs', input);
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateRepair(
|
|
||||||
id: string,
|
|
||||||
input: UpdateRepairJobRequest,
|
|
||||||
): Promise<RepairJob> {
|
|
||||||
const res = await api.patch<RepairJob>(`/repairs/${id}`, input);
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { PartEventType, PartState, RepairStatus, Role } from '@vector/shared';
|
import type { FmStatus, PartEventType, PartState, Role } from '@vector/shared';
|
||||||
|
|
||||||
// Shapes mirror Prisma rows the API returns (dates serialized as ISO strings).
|
// Shapes mirror Prisma rows the API returns (dates serialized as ISO strings).
|
||||||
// Keep these in sync with apps/api/src/services responses.
|
// Keep these in sync with apps/api/src/services responses.
|
||||||
@@ -15,6 +15,7 @@ export interface PartModel {
|
|||||||
manufacturerId: string;
|
manufacturerId: string;
|
||||||
mpn: string;
|
mpn: string;
|
||||||
eolDate: string | null;
|
eolDate: string | null;
|
||||||
|
destroyOnFail: boolean;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -60,6 +61,7 @@ export interface Part {
|
|||||||
binId: string | null;
|
binId: string | null;
|
||||||
categoryId: string | null;
|
categoryId: string | null;
|
||||||
hostId: string | null;
|
hostId: string | null;
|
||||||
|
custodianId: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -67,6 +69,7 @@ export interface Part {
|
|||||||
partModel: PartModel;
|
partModel: PartModel;
|
||||||
bin: BinWithPath | null;
|
bin: BinWithPath | null;
|
||||||
host: Host | null;
|
host: Host | null;
|
||||||
|
custodian: Pick<User, 'id' | 'username'> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PartEvent {
|
export interface PartEvent {
|
||||||
@@ -115,42 +118,47 @@ export interface Category {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RepairJobProblemPart {
|
export interface FmProblemPart {
|
||||||
repairJobId: string;
|
fmId: string;
|
||||||
partId: string;
|
partId: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
part: Part;
|
part: Part;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RepairJob {
|
export interface Fm {
|
||||||
id: string;
|
id: string;
|
||||||
hostId: string;
|
hostId: string;
|
||||||
assigneeId: string | null;
|
status: FmStatus;
|
||||||
status: RepairStatus;
|
|
||||||
problem: string;
|
problem: string;
|
||||||
notes: string | null;
|
|
||||||
openedAt: string;
|
openedAt: string;
|
||||||
closedAt: string | null;
|
closedAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
host: Host;
|
host: Host;
|
||||||
assignee: Pick<User, 'id' | 'username' | 'email' | 'role'> | null;
|
problemParts: FmProblemPart[];
|
||||||
problemParts: RepairJobProblemPart[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RepairComment {
|
export interface Repair {
|
||||||
id: string;
|
id: string;
|
||||||
repairJobId: string;
|
hostId: string;
|
||||||
userId: string | null;
|
brokenPartId: string;
|
||||||
content: string;
|
replacementPartId: string;
|
||||||
|
performedById: string;
|
||||||
|
performedAt: string;
|
||||||
|
fmId: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
user: Pick<User, 'id' | 'username'> | null;
|
updatedAt: string;
|
||||||
|
host: Host;
|
||||||
|
brokenPart: Part;
|
||||||
|
replacement: Part;
|
||||||
|
performedBy: Pick<User, 'id' | 'username'>;
|
||||||
|
fm: { id: string; status: FmStatus } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SavedView {
|
export interface SavedView {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
resource: 'parts' | 'repairs' | 'hosts' | 'manufacturers';
|
resource: 'parts' | 'fms' | 'repairs' | 'hosts' | 'manufacturers';
|
||||||
name: string;
|
name: string;
|
||||||
filterJson: unknown;
|
filterJson: unknown;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|||||||
@@ -49,12 +49,22 @@ export const queryKeys = {
|
|||||||
detail: (id: string) => [...queryKeys.hosts.all, 'detail', id] as const,
|
detail: (id: string) => [...queryKeys.hosts.all, 'detail', id] as const,
|
||||||
deployedParts: (id: string) => [...queryKeys.hosts.all, 'deployed-parts', id] as const,
|
deployedParts: (id: string) => [...queryKeys.hosts.all, 'deployed-parts', id] as const,
|
||||||
},
|
},
|
||||||
|
fms: {
|
||||||
|
all: ['fms'] as const,
|
||||||
|
list: (filters?: Record<string, unknown>) =>
|
||||||
|
[...queryKeys.fms.all, 'list', filters ?? {}] as const,
|
||||||
|
detail: (id: string) => [...queryKeys.fms.all, 'detail', id] as const,
|
||||||
|
},
|
||||||
repairs: {
|
repairs: {
|
||||||
all: ['repairs'] as const,
|
all: ['repairs'] as const,
|
||||||
list: (filters?: Record<string, unknown>) =>
|
list: (filters?: Record<string, unknown>) =>
|
||||||
[...queryKeys.repairs.all, 'list', filters ?? {}] as const,
|
[...queryKeys.repairs.all, 'list', filters ?? {}] as const,
|
||||||
detail: (id: string) => [...queryKeys.repairs.all, 'detail', id] as const,
|
detail: (id: string) => [...queryKeys.repairs.all, 'detail', id] as const,
|
||||||
comments: (id: string) => [...queryKeys.repairs.all, 'comments', id] as const,
|
},
|
||||||
|
custody: {
|
||||||
|
all: ['custody'] as const,
|
||||||
|
mine: (filters?: Record<string, unknown>) =>
|
||||||
|
[...queryKeys.custody.all, 'mine', filters ?? {}] as const,
|
||||||
},
|
},
|
||||||
partModels: {
|
partModels: {
|
||||||
all: ['part-models'] as const,
|
all: ['part-models'] as const,
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ const STATE_LABELS: Record<PartState, string> = {
|
|||||||
DEPLOYED: 'Deployed',
|
DEPLOYED: 'Deployed',
|
||||||
BROKEN: 'Broken',
|
BROKEN: 'Broken',
|
||||||
PENDING_DESTRUCTION: 'Pending destruction',
|
PENDING_DESTRUCTION: 'Pending destruction',
|
||||||
|
PENDING_DROP_IN_CUSTODY: 'In custody',
|
||||||
|
PENDING_DESTRUCTION_IN_CUSTODY: 'In custody (destroy)',
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATE_COLORS: Record<PartState, string> = {
|
const STATE_COLORS: Record<PartState, string> = {
|
||||||
@@ -40,6 +42,8 @@ const STATE_COLORS: Record<PartState, string> = {
|
|||||||
DEPLOYED: 'hsl(142 71% 45%)',
|
DEPLOYED: 'hsl(142 71% 45%)',
|
||||||
BROKEN: 'hsl(0 84% 60%)',
|
BROKEN: 'hsl(0 84% 60%)',
|
||||||
PENDING_DESTRUCTION: 'hsl(38 92% 50%)',
|
PENDING_DESTRUCTION: 'hsl(38 92% 50%)',
|
||||||
|
PENDING_DROP_IN_CUSTODY: 'hsl(262 83% 58%)',
|
||||||
|
PENDING_DESTRUCTION_IN_CUSTODY: 'hsl(340 82% 52%)',
|
||||||
};
|
};
|
||||||
|
|
||||||
function currency(cents: number): string {
|
function currency(cents: number): string {
|
||||||
@@ -90,9 +94,9 @@ export default function Dashboard() {
|
|||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
icon={<Wrench className="h-4 w-4" />}
|
icon={<Wrench className="h-4 w-4" />}
|
||||||
label="Open repairs"
|
label="Open FMs"
|
||||||
value={data.openRepairs.toLocaleString()}
|
value={data.openFms.toLocaleString()}
|
||||||
href="/repairs"
|
href="/fms"
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label="Deployed value"
|
label="Deployed value"
|
||||||
|
|||||||
@@ -1,19 +1,10 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import { ArrowLeft, Check, Loader2, Pencil, Plus, Server, Trash2, X } from 'lucide-react';
|
||||||
ArrowLeft,
|
|
||||||
Check,
|
|
||||||
Loader2,
|
|
||||||
Pencil,
|
|
||||||
Plus,
|
|
||||||
Server,
|
|
||||||
Trash2,
|
|
||||||
X,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import type { RepairStatus } from '@vector/shared';
|
|
||||||
import {
|
import {
|
||||||
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -21,45 +12,38 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
Separator,
|
Separator,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
Textarea,
|
Textarea,
|
||||||
} from '@vector/ui';
|
} from '@vector/ui';
|
||||||
import { getRepair, updateRepair } from '../lib/api/repairs.js';
|
import { getFm, updateFm } from '../lib/api/fms.js';
|
||||||
import { listHostDeployedParts } from '../lib/api/hosts.js';
|
import { listHostDeployedParts } from '../lib/api/hosts.js';
|
||||||
import { ApiRequestError } from '../lib/api/client.js';
|
import { ApiRequestError } from '../lib/api/client.js';
|
||||||
import { queryKeys } from '../lib/queryKeys.js';
|
import { queryKeys } from '../lib/queryKeys.js';
|
||||||
import type { RepairJob } from '../lib/api/types.js';
|
import type { Fm } from '../lib/api/types.js';
|
||||||
import { RepairStatusBadge, repairStatusOptions } from '../components/repairs/RepairStatusBadge.js';
|
|
||||||
import { PartStateBadge } from '../components/parts/PartStateBadge.js';
|
import { PartStateBadge } from '../components/parts/PartStateBadge.js';
|
||||||
import { RepairCommentThread } from '../components/repairs/RepairCommentThread.js';
|
|
||||||
|
|
||||||
export default function RepairDetail() {
|
export default function FmDetail() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: repair, isPending, isError, error } = useQuery({
|
const { data: fm, isPending, isError, error } = useQuery({
|
||||||
queryKey: queryKeys.repairs.detail(id!),
|
queryKey: queryKeys.fms.detail(id!),
|
||||||
queryFn: () => getRepair(id!),
|
queryFn: () => getFm(id!),
|
||||||
enabled: Boolean(id),
|
enabled: Boolean(id),
|
||||||
});
|
});
|
||||||
|
|
||||||
const invalidate = () => {
|
const invalidate = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.repairs.detail(id!) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.fms.detail(id!) });
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.repairs.list() });
|
queryClient.invalidateQueries({ queryKey: queryKeys.fms.list() });
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
|
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusMutation = useMutation({
|
const toggleMutation = useMutation({
|
||||||
mutationFn: (status: RepairStatus) => updateRepair(id!, { status }),
|
mutationFn: () => updateFm(id!, { status: fm?.status === 'OPEN' ? 'CLOSED' : 'OPEN' }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Status updated');
|
toast.success(fm?.status === 'OPEN' ? 'FM closed' : 'FM reopened');
|
||||||
invalidate();
|
invalidate();
|
||||||
},
|
},
|
||||||
onError: (err) =>
|
onError: (err) =>
|
||||||
@@ -76,25 +60,25 @@ export default function RepairDetail() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isError || !repair) {
|
if (isError || !fm) {
|
||||||
const msg = error instanceof ApiRequestError ? error.body.message : 'Repair not found.';
|
const msg = error instanceof ApiRequestError ? error.body.message : 'FM not found.';
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Repair unavailable</CardTitle>
|
<CardTitle>FM unavailable</CardTitle>
|
||||||
<CardDescription>{msg}</CardDescription>
|
<CardDescription>{msg}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Button variant="outline" onClick={() => navigate('/repairs')}>
|
<Button variant="outline" onClick={() => navigate('/fms')}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
Back to repairs
|
Back to FMs
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const terminal = repair.status === 'COMPLETED' || repair.status === 'CANCELLED';
|
const closed = fm.status === 'CLOSED';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
@@ -103,98 +87,77 @@ export default function RepairDetail() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => navigate('/repairs')}
|
onClick={() => navigate('/fms')}
|
||||||
aria-label="Back"
|
aria-label="Back"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-xs uppercase tracking-wide text-muted-foreground">
|
<span className="text-xs uppercase tracking-wide text-muted-foreground">Asset</span>
|
||||||
Asset
|
|
||||||
</span>
|
|
||||||
<span className="font-mono text-2xl font-semibold tracking-tight text-foreground">
|
<span className="font-mono text-2xl font-semibold tracking-tight text-foreground">
|
||||||
{repair.host.assetId}
|
{fm.host.assetId}
|
||||||
</span>
|
</span>
|
||||||
<RepairStatusBadge status={repair.status} />
|
<Badge variant={closed ? 'secondary' : 'warning'}>
|
||||||
|
{closed ? 'Closed' : 'Open'}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
<div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
<Server className="h-3 w-3" />
|
<Server className="h-3 w-3" />
|
||||||
<span>{repair.host.name}</span>
|
<span>{fm.host.name}</span>
|
||||||
{repair.host.location && <span>· {repair.host.location}</span>}
|
{fm.host.location && <span>· {fm.host.location}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Select
|
<Button
|
||||||
value={repair.status}
|
variant={closed ? 'outline' : 'default'}
|
||||||
onValueChange={(v) => statusMutation.mutate(v as RepairStatus)}
|
onClick={() => toggleMutation.mutate()}
|
||||||
disabled={statusMutation.isPending}
|
disabled={toggleMutation.isPending}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-40">
|
{toggleMutation.isPending ? (
|
||||||
<SelectValue />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
</SelectTrigger>
|
) : closed ? (
|
||||||
<SelectContent>
|
<Pencil className="h-4 w-4" />
|
||||||
{repairStatusOptions.map((o) => (
|
) : (
|
||||||
<SelectItem key={o.value} value={o.value}>
|
<Check className="h-4 w-4" />
|
||||||
{o.label}
|
)}
|
||||||
</SelectItem>
|
{closed ? 'Reopen' : 'Close FM'}
|
||||||
))}
|
</Button>
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-[1.3fr_1fr]">
|
<div className="grid gap-4 md:grid-cols-[1.3fr_1fr]">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<ProblemCard repair={repair} onSaved={invalidate} disabled={terminal} />
|
<ProblemCard fm={fm} onSaved={invalidate} disabled={closed} />
|
||||||
<ProblemPartsCard repair={repair} onSaved={invalidate} disabled={terminal} />
|
<ProblemPartsCard fm={fm} onSaved={invalidate} disabled={closed} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Comments</CardTitle>
|
<CardTitle className="text-base">Timeline</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Discuss progress, record findings, tag handoffs.
|
The actual repair work lives in the external ticketing system.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<RepairCommentThread repairId={repair.id} />
|
<dl className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<Field label="Opened" value={new Date(fm.openedAt).toLocaleString()} />
|
||||||
|
<Field
|
||||||
|
label="Closed"
|
||||||
|
value={fm.closedAt ? new Date(fm.closedAt).toLocaleString() : '—'}
|
||||||
|
/>
|
||||||
|
<Field label="Updated" value={new Date(fm.updatedAt).toLocaleString()} />
|
||||||
|
</dl>
|
||||||
|
<Separator className="my-3" />
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Opened and closed events fire <code className="font-mono">fm.opened</code> and{' '}
|
||||||
|
<code className="font-mono">fm.closed</code> webhooks.
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -209,23 +172,23 @@ function Field({ label, value }: { label: string; value: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ProblemCard({
|
function ProblemCard({
|
||||||
repair,
|
fm,
|
||||||
onSaved,
|
onSaved,
|
||||||
disabled,
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
repair: { id: string; problem: string };
|
fm: { id: string; problem: string };
|
||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [value, setValue] = useState(repair.problem);
|
const [value, setValue] = useState(fm.problem);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setValue(repair.problem);
|
setValue(fm.problem);
|
||||||
}, [repair.problem]);
|
}, [fm.problem]);
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (problem: string) => updateRepair(repair.id, { problem }),
|
mutationFn: (problem: string) => updateFm(fm.id, { problem }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Problem updated');
|
toast.success('Problem updated');
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
@@ -260,7 +223,7 @@ function ProblemCard({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setValue(repair.problem);
|
setValue(fm.problem);
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
}}
|
}}
|
||||||
disabled={mutation.isPending}
|
disabled={mutation.isPending}
|
||||||
@@ -274,7 +237,7 @@ function ProblemCard({
|
|||||||
disabled={
|
disabled={
|
||||||
mutation.isPending ||
|
mutation.isPending ||
|
||||||
value.trim().length === 0 ||
|
value.trim().length === 0 ||
|
||||||
value.trim() === repair.problem
|
value.trim() === fm.problem
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{mutation.isPending ? (
|
{mutation.isPending ? (
|
||||||
@@ -287,7 +250,7 @@ function ProblemCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="whitespace-pre-wrap text-sm text-foreground">{repair.problem}</p>
|
<p className="whitespace-pre-wrap text-sm text-foreground">{fm.problem}</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -295,11 +258,11 @@ function ProblemCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ProblemPartsCard({
|
function ProblemPartsCard({
|
||||||
repair,
|
fm,
|
||||||
onSaved,
|
onSaved,
|
||||||
disabled,
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
repair: RepairJob;
|
fm: Fm;
|
||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
}) {
|
}) {
|
||||||
@@ -307,20 +270,19 @@ function ProblemPartsCard({
|
|||||||
const [draft, setDraft] = useState<string[]>([]);
|
const [draft, setDraft] = useState<string[]>([]);
|
||||||
|
|
||||||
const deployedQuery = useQuery({
|
const deployedQuery = useQuery({
|
||||||
queryKey: queryKeys.hosts.deployedParts(repair.hostId),
|
queryKey: queryKeys.hosts.deployedParts(fm.hostId),
|
||||||
queryFn: () => listHostDeployedParts(repair.hostId),
|
queryFn: () => listHostDeployedParts(fm.hostId),
|
||||||
enabled: picking,
|
enabled: picking,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (picking) {
|
if (picking) {
|
||||||
setDraft(repair.problemParts.map((pp) => pp.partId));
|
setDraft(fm.problemParts.map((pp) => pp.partId));
|
||||||
}
|
}
|
||||||
}, [picking, repair.problemParts]);
|
}, [picking, fm.problemParts]);
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (problemPartIds: string[]) =>
|
mutationFn: (problemPartIds: string[]) => updateFm(fm.id, { problemPartIds }),
|
||||||
updateRepair(repair.id, { problemPartIds }),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Problem parts updated');
|
toast.success('Problem parts updated');
|
||||||
setPicking(false);
|
setPicking(false);
|
||||||
@@ -332,8 +294,8 @@ function ProblemPartsCard({
|
|||||||
|
|
||||||
const removeMutation = useMutation({
|
const removeMutation = useMutation({
|
||||||
mutationFn: (partId: string) => {
|
mutationFn: (partId: string) => {
|
||||||
const next = repair.problemParts.map((pp) => pp.partId).filter((pid) => pid !== partId);
|
const next = fm.problemParts.map((pp) => pp.partId).filter((pid) => pid !== partId);
|
||||||
return updateRepair(repair.id, { problemPartIds: next });
|
return updateFm(fm.id, { problemPartIds: next });
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Part removed');
|
toast.success('Part removed');
|
||||||
@@ -354,9 +316,7 @@ function ProblemPartsCard({
|
|||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-base">Problem parts</CardTitle>
|
<CardTitle className="text-base">Problem parts</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>Deployed parts on this host involved in the issue.</CardDescription>
|
||||||
Deployed parts on this host involved in the issue.
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
</div>
|
||||||
{!picking && !disabled && (
|
{!picking && !disabled && (
|
||||||
<Button variant="outline" size="sm" onClick={() => setPicking(true)}>
|
<Button variant="outline" size="sm" onClick={() => setPicking(true)}>
|
||||||
@@ -380,17 +340,14 @@ function ProblemPartsCard({
|
|||||||
{deployedQuery.data.map((part) => {
|
{deployedQuery.data.map((part) => {
|
||||||
const checked = draft.includes(part.id);
|
const checked = draft.includes(part.id);
|
||||||
return (
|
return (
|
||||||
<li
|
<li key={part.id} className="flex items-center gap-2 px-3 py-2 text-sm">
|
||||||
key={part.id}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 text-sm"
|
|
||||||
>
|
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id={`rd-pp-${part.id}`}
|
id={`fd-pp-${part.id}`}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
onCheckedChange={(v) => toggle(part.id, v === true)}
|
onCheckedChange={(v) => toggle(part.id, v === true)}
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
htmlFor={`rd-pp-${part.id}`}
|
htmlFor={`fd-pp-${part.id}`}
|
||||||
className="flex-1 cursor-pointer select-none"
|
className="flex-1 cursor-pointer select-none"
|
||||||
>
|
>
|
||||||
<span className="font-mono text-xs">{part.serialNumber}</span>{' '}
|
<span className="font-mono text-xs">{part.serialNumber}</span>{' '}
|
||||||
@@ -428,13 +385,13 @@ function ProblemPartsCard({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : repair.problemParts.length === 0 ? (
|
) : fm.problemParts.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
No specific parts tagged — the repair is against the host itself.
|
No specific parts tagged — the FM is against the host itself.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-border rounded-md border border-border">
|
<ul className="divide-y divide-border rounded-md border border-border">
|
||||||
{repair.problemParts.map((pp) => (
|
{fm.problemParts.map((pp) => (
|
||||||
<li
|
<li
|
||||||
key={pp.partId}
|
key={pp.partId}
|
||||||
className="flex items-center justify-between gap-3 px-3 py-2"
|
className="flex items-center justify-between gap-3 px-3 py-2"
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
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 { MoreHorizontal, Plus, Trash2, Wrench } from 'lucide-react';
|
||||||
|
import type { FmStatus } from '@vector/shared';
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@vector/ui';
|
||||||
|
import { PageHeader } from '../components/layout/PageHeader.js';
|
||||||
|
import { DataTable } from '../components/data-table/DataTable.js';
|
||||||
|
import { FmFormDialog } from '../components/fms/FmFormDialog.js';
|
||||||
|
import { ConfirmDialog } from '../components/ConfirmDialog.js';
|
||||||
|
import { deleteFm, listFms } from '../lib/api/fms.js';
|
||||||
|
import { ApiRequestError } from '../lib/api/client.js';
|
||||||
|
import type { Fm } from '../lib/api/types.js';
|
||||||
|
import { queryKeys } from '../lib/queryKeys.js';
|
||||||
|
|
||||||
|
type FmFilters = {
|
||||||
|
status: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterParsers = {
|
||||||
|
status: parseAsString,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALL = '__all__';
|
||||||
|
const STATUS_OPTIONS: { value: FmStatus; label: string }[] = [
|
||||||
|
{ value: 'OPEN', label: 'Open' },
|
||||||
|
{ value: 'CLOSED', label: 'Closed' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function FmStatusBadge({ status }: { status: FmStatus }) {
|
||||||
|
return (
|
||||||
|
<Badge variant={status === 'OPEN' ? 'warning' : 'secondary'}>
|
||||||
|
{status === 'OPEN' ? 'Open' : 'Closed'}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Fms() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState<Fm | null>(null);
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => deleteFm(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('FM removed');
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.fms.all });
|
||||||
|
setDeleting(null);
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnDef<Fm>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
cell: ({ row }) => <FmStatusBadge status={row.original.status} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'assetId',
|
||||||
|
header: 'Asset ID',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Link
|
||||||
|
to={`/fms/${row.original.id}`}
|
||||||
|
className="font-mono text-xs font-medium text-foreground hover:underline"
|
||||||
|
>
|
||||||
|
{row.original.host.assetId}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'host',
|
||||||
|
header: 'Host',
|
||||||
|
cell: ({ row }) => <span className="text-sm">{row.original.host.name}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'problem',
|
||||||
|
header: 'Problem',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Link
|
||||||
|
to={`/fms/${row.original.id}`}
|
||||||
|
className="line-clamp-1 max-w-sm text-sm text-foreground hover:underline"
|
||||||
|
>
|
||||||
|
{row.original.problem}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'openedAt',
|
||||||
|
header: 'Opened',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{new Date(row.original.openedAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'closedAt',
|
||||||
|
header: 'Closed',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{row.original.closedAt
|
||||||
|
? new Date(row.original.closedAt).toLocaleDateString()
|
||||||
|
: '—'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: () => <span className="sr-only">Actions</span>,
|
||||||
|
size: 40,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<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={() => setDeleting(row.original)}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<PageHeader
|
||||||
|
title="FMs"
|
||||||
|
description="Future Maintenance items open against hosts. n8n handles the ticketing; Vector just tracks open/closed."
|
||||||
|
actions={
|
||||||
|
<Button onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Open FM
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DataTable<Fm, FmFilters>
|
||||||
|
columns={columns}
|
||||||
|
getRowId={(r) => r.id}
|
||||||
|
filterParsers={filterParsers}
|
||||||
|
queryKey={(params) =>
|
||||||
|
queryKeys.fms.list({
|
||||||
|
page: params.page,
|
||||||
|
pageSize: params.pageSize,
|
||||||
|
status: params.filters.status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
queryFn={(params) =>
|
||||||
|
listFms({
|
||||||
|
page: params.page,
|
||||||
|
pageSize: params.pageSize,
|
||||||
|
status: (params.filters.status ?? undefined) as FmStatus | undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
enableSearch={false}
|
||||||
|
toolbar={({ filters, setFilter }) => (
|
||||||
|
<Select
|
||||||
|
value={filters.status ?? ALL}
|
||||||
|
onValueChange={(v) => setFilter('status', v === ALL ? null : v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-40">
|
||||||
|
<SelectValue placeholder="Any status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={ALL}>Any status</SelectItem>
|
||||||
|
{STATUS_OPTIONS.map((o) => (
|
||||||
|
<SelectItem key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
emptyState={
|
||||||
|
<div className="flex flex-col items-center gap-1 py-8 text-muted-foreground">
|
||||||
|
<Wrench className="h-6 w-6" />
|
||||||
|
<span className="text-sm">No FMs yet.</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FmFormDialog
|
||||||
|
open={createOpen}
|
||||||
|
onOpenChange={setCreateOpen}
|
||||||
|
onCreated={(fm) => navigate(`/fms/${fm.id}`)}
|
||||||
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(deleting)}
|
||||||
|
onOpenChange={(o) => !o && setDeleting(null)}
|
||||||
|
title="Delete FM?"
|
||||||
|
description={
|
||||||
|
deleting
|
||||||
|
? `Remove FM "${deleting.problem.slice(0, 60)}" on ${deleting.host.name}. This cannot be undone.`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
destructive
|
||||||
|
pending={deleteMutation.isPending}
|
||||||
|
onConfirm={() => deleting && deleteMutation.mutate(deleting.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Hand, PackageCheck } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Button } from '@vector/ui';
|
||||||
|
import { PageHeader } from '../components/layout/PageHeader.js';
|
||||||
|
import { DataTable } from '../components/data-table/DataTable.js';
|
||||||
|
import { DropOffDialog } from '../components/custody/DropOffDialog.js';
|
||||||
|
import { dropOff, listMyCustody } from '../lib/api/custody.js';
|
||||||
|
import { ApiRequestError } from '../lib/api/client.js';
|
||||||
|
import { PartStateBadge } from '../components/parts/PartStateBadge.js';
|
||||||
|
import type { Part } from '../lib/api/types.js';
|
||||||
|
import { queryKeys } from '../lib/queryKeys.js';
|
||||||
|
|
||||||
|
export default function MyCustody() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [dropping, setDropping] = useState<Part | null>(null);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: ({ partId, binId }: { partId: string; binId: string | null }) =>
|
||||||
|
dropOff(partId, { binId }),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Dropped off');
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.custody.all });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
|
||||||
|
setDropping(null);
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast.error(err instanceof ApiRequestError ? err.body.message : 'Drop-off failed'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnDef<Part>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: 'serial',
|
||||||
|
header: 'Serial',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Link
|
||||||
|
to={`/parts/${row.original.id}`}
|
||||||
|
className="font-mono text-xs font-medium text-foreground hover:underline"
|
||||||
|
>
|
||||||
|
{row.original.serialNumber}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mpn',
|
||||||
|
header: 'MPN',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-mono text-xs">{row.original.partModel.mpn}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{row.original.manufacturer.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'state',
|
||||||
|
header: 'State',
|
||||||
|
cell: ({ row }) => <PartStateBadge state={row.original.state} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'since',
|
||||||
|
header: 'Since',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{new Date(row.original.updatedAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: () => <span className="sr-only">Actions</span>,
|
||||||
|
size: 140,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setDropping(row.original)}>
|
||||||
|
<PackageCheck className="h-3.5 w-3.5" />
|
||||||
|
Drop in bin
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<PageHeader
|
||||||
|
title="My Custody"
|
||||||
|
description="Broken parts you're holding until you drop them in a bin."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DataTable<Part, Record<string, never>>
|
||||||
|
columns={columns}
|
||||||
|
getRowId={(p) => p.id}
|
||||||
|
queryKey={(params) =>
|
||||||
|
queryKeys.custody.mine({ page: params.page, pageSize: params.pageSize })
|
||||||
|
}
|
||||||
|
queryFn={(params) =>
|
||||||
|
listMyCustody({ page: params.page, pageSize: params.pageSize })
|
||||||
|
}
|
||||||
|
enableSearch={false}
|
||||||
|
emptyState={
|
||||||
|
<div className="flex flex-col items-center gap-1 py-8 text-muted-foreground">
|
||||||
|
<Hand className="h-6 w-6" />
|
||||||
|
<span className="text-sm">Nothing in your custody.</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DropOffDialog
|
||||||
|
part={dropping}
|
||||||
|
onOpenChange={(o) => !o && setDropping(null)}
|
||||||
|
onConfirm={(binId) =>
|
||||||
|
dropping && mutation.mutate({ partId: dropping.id, binId })
|
||||||
|
}
|
||||||
|
pending={mutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,7 +20,6 @@ import { useAuth } from '../contexts/AuthContext.js';
|
|||||||
import { PartStateBadge } from '../components/parts/PartStateBadge.js';
|
import { PartStateBadge } from '../components/parts/PartStateBadge.js';
|
||||||
import { PartEventTimeline } from '../components/parts/PartEventTimeline.js';
|
import { PartEventTimeline } from '../components/parts/PartEventTimeline.js';
|
||||||
import { PartFormDialog } from '../components/parts/PartFormDialog.js';
|
import { PartFormDialog } from '../components/parts/PartFormDialog.js';
|
||||||
import { PartRepairSection } from '../components/parts/PartRepairSection.js';
|
|
||||||
import { TagPicker } from '../components/tags/TagPicker.js';
|
import { TagPicker } from '../components/tags/TagPicker.js';
|
||||||
import { ConfirmDialog } from '../components/ConfirmDialog.js';
|
import { ConfirmDialog } from '../components/ConfirmDialog.js';
|
||||||
|
|
||||||
@@ -170,6 +169,8 @@ export default function PartDetail() {
|
|||||||
<span className="font-mono text-xs">
|
<span className="font-mono text-xs">
|
||||||
{part.host.assetId} / {part.host.name}
|
{part.host.assetId} / {part.host.name}
|
||||||
</span>
|
</span>
|
||||||
|
) : part.custodian ? (
|
||||||
|
<span className="text-xs">Custody: {part.custodian.username}</span>
|
||||||
) : part.bin?.fullPath ? (
|
) : part.bin?.fullPath ? (
|
||||||
<span className="font-mono text-xs">{part.bin.fullPath}</span>
|
<span className="font-mono text-xs">{part.bin.fullPath}</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -223,8 +224,6 @@ export default function PartDetail() {
|
|||||||
<p className="mb-2 text-xs font-medium text-muted-foreground">Tags</p>
|
<p className="mb-2 text-xs font-medium text-muted-foreground">Tags</p>
|
||||||
<TagPicker partId={part.id} />
|
<TagPicker partId={part.id} />
|
||||||
</div>
|
</div>
|
||||||
<Separator className="my-3" />
|
|
||||||
<PartRepairSection partId={part.id} />
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Edit, Layers, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
|
import { Check, Edit, Layers, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
@@ -80,6 +80,16 @@ export default function PartModels() {
|
|||||||
<span className="text-sm tabular-nums">{row.original._count?.parts ?? 0}</span>
|
<span className="text-sm tabular-nums">{row.original._count?.parts ?? 0}</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'destroyOnFail',
|
||||||
|
header: 'Destroy on fail',
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.destroyOnFail ? (
|
||||||
|
<Check className="h-4 w-4 text-foreground" />
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
header: () => <span className="sr-only">Actions</span>,
|
header: () => <span className="sr-only">Actions</span>,
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export default function Parts() {
|
|||||||
id: 'location',
|
id: 'location',
|
||||||
header: 'Location',
|
header: 'Location',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const host = row.original.host;
|
const { host, custodian, bin } = row.original;
|
||||||
if (host) {
|
if (host) {
|
||||||
return (
|
return (
|
||||||
<span className="text-xs font-mono text-muted-foreground">
|
<span className="text-xs font-mono text-muted-foreground">
|
||||||
@@ -124,9 +124,15 @@ export default function Parts() {
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const path = row.original.bin?.fullPath;
|
if (custodian) {
|
||||||
return path ? (
|
return (
|
||||||
<span className="text-xs font-mono text-muted-foreground">{path}</span>
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Custody: {custodian.username}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return bin?.fullPath ? (
|
||||||
|
<span className="text-xs font-mono text-muted-foreground">{bin.fullPath}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-muted-foreground italic">Unassigned</span>
|
<span className="text-xs text-muted-foreground italic">Unassigned</span>
|
||||||
);
|
);
|
||||||
|
|||||||
+55
-159
@@ -1,143 +1,84 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { ArrowRightLeft, Plus } from 'lucide-react';
|
||||||
import { parseAsString } from 'nuqs';
|
import { Button } from '@vector/ui';
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { MoreHorizontal, Plus, Trash2, Wrench } from 'lucide-react';
|
|
||||||
import type { RepairStatus } from '@vector/shared';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@vector/ui';
|
|
||||||
import { PageHeader } from '../components/layout/PageHeader.js';
|
import { PageHeader } from '../components/layout/PageHeader.js';
|
||||||
import { DataTable } from '../components/data-table/DataTable.js';
|
import { DataTable } from '../components/data-table/DataTable.js';
|
||||||
import { RepairFormDialog } from '../components/repairs/RepairFormDialog.js';
|
import { LogRepairDialog } from '../components/repairs/LogRepairDialog.js';
|
||||||
import {
|
import { listRepairs } from '../lib/api/repairs.js';
|
||||||
RepairStatusBadge,
|
import type { Repair } from '../lib/api/types.js';
|
||||||
repairStatusOptions,
|
|
||||||
} from '../components/repairs/RepairStatusBadge.js';
|
|
||||||
import { ConfirmDialog } from '../components/ConfirmDialog.js';
|
|
||||||
import { deleteRepair, listRepairs } from '../lib/api/repairs.js';
|
|
||||||
import { ApiRequestError } from '../lib/api/client.js';
|
|
||||||
import type { RepairJob } from '../lib/api/types.js';
|
|
||||||
import { queryKeys } from '../lib/queryKeys.js';
|
import { queryKeys } from '../lib/queryKeys.js';
|
||||||
|
|
||||||
type RepairFilters = {
|
|
||||||
status: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const filterParsers = {
|
|
||||||
status: parseAsString,
|
|
||||||
};
|
|
||||||
|
|
||||||
const ALL = '__all__';
|
|
||||||
|
|
||||||
export default function Repairs() {
|
export default function Repairs() {
|
||||||
const queryClient = useQueryClient();
|
const [logOpen, setLogOpen] = useState(false);
|
||||||
const navigate = useNavigate();
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
|
||||||
const [deleting, setDeleting] = useState<RepairJob | null>(null);
|
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const columns = useMemo<ColumnDef<Repair>[]>(
|
||||||
mutationFn: (id: string) => deleteRepair(id),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success('Repair removed');
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.repairs.all });
|
|
||||||
setDeleting(null);
|
|
||||||
},
|
|
||||||
onError: (err) =>
|
|
||||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<RepairJob>[]>(
|
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
accessorKey: 'status',
|
id: 'performedAt',
|
||||||
header: 'Status',
|
header: 'When',
|
||||||
cell: ({ row }) => <RepairStatusBadge status={row.original.status} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'assetId',
|
|
||||||
header: 'Asset ID',
|
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Link
|
<span className="text-xs text-muted-foreground">
|
||||||
to={`/repairs/${row.original.id}`}
|
{new Date(row.original.performedAt).toLocaleString()}
|
||||||
className="font-mono text-xs font-medium text-foreground hover:underline"
|
</span>
|
||||||
>
|
|
||||||
{row.original.host.assetId}
|
|
||||||
</Link>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'host',
|
id: 'host',
|
||||||
header: 'Host',
|
header: 'Host',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-sm">{row.original.host.name}</span>
|
<div className="flex flex-col">
|
||||||
|
<span className="font-mono text-xs">{row.original.host.assetId}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{row.original.host.name}</span>
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'problem',
|
id: 'broken',
|
||||||
header: 'Problem',
|
header: 'Broken',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Link
|
<Link
|
||||||
to={`/repairs/${row.original.id}`}
|
to={`/parts/${row.original.brokenPart.id}`}
|
||||||
className="line-clamp-1 max-w-sm text-sm text-foreground hover:underline"
|
className="font-mono text-xs hover:underline"
|
||||||
>
|
>
|
||||||
{row.original.problem}
|
{row.original.brokenPart.serialNumber}
|
||||||
</Link>
|
</Link>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'openedAt',
|
id: 'replacement',
|
||||||
header: 'Opened',
|
header: 'Replacement',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-xs text-muted-foreground">
|
<Link
|
||||||
{new Date(row.original.openedAt).toLocaleDateString()}
|
to={`/parts/${row.original.replacement.id}`}
|
||||||
</span>
|
className="font-mono text-xs hover:underline"
|
||||||
|
>
|
||||||
|
{row.original.replacement.serialNumber}
|
||||||
|
</Link>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'closedAt',
|
id: 'performedBy',
|
||||||
header: 'Closed',
|
header: 'By',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs">{row.original.performedBy.username}</span>
|
||||||
{row.original.closedAt
|
|
||||||
? new Date(row.original.closedAt).toLocaleDateString()
|
|
||||||
: '—'}
|
|
||||||
</span>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'fm',
|
||||||
header: () => <span className="sr-only">Actions</span>,
|
header: 'FM',
|
||||||
size: 40,
|
cell: ({ row }) =>
|
||||||
cell: ({ row }) => (
|
row.original.fmId ? (
|
||||||
<DropdownMenu>
|
<Link
|
||||||
<DropdownMenuTrigger asChild>
|
to={`/fms/${row.original.fmId}`}
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
className="text-xs text-foreground hover:underline"
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
>
|
||||||
</Button>
|
View FM
|
||||||
</DropdownMenuTrigger>
|
</Link>
|
||||||
<DropdownMenuContent align="end" className="w-36">
|
) : (
|
||||||
<DropdownMenuItem
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
onSelect={() => setDeleting(row.original)}
|
),
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
@@ -147,79 +88,34 @@ export default function Repairs() {
|
|||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Repairs"
|
title="Repairs"
|
||||||
description="Open work against hosts. Click a row to view and comment."
|
description="Physical part swaps. Logging a repair moves the broken part into your custody."
|
||||||
actions={
|
actions={
|
||||||
<Button onClick={() => setCreateOpen(true)}>
|
<Button onClick={() => setLogOpen(true)}>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Open repair
|
Log repair
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DataTable<RepairJob, RepairFilters>
|
<DataTable<Repair, Record<string, never>>
|
||||||
columns={columns}
|
columns={columns}
|
||||||
getRowId={(r) => r.id}
|
getRowId={(r) => r.id}
|
||||||
filterParsers={filterParsers}
|
|
||||||
queryKey={(params) =>
|
queryKey={(params) =>
|
||||||
queryKeys.repairs.list({
|
queryKeys.repairs.list({ page: params.page, pageSize: params.pageSize })
|
||||||
page: params.page,
|
|
||||||
pageSize: params.pageSize,
|
|
||||||
status: params.filters.status,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
queryFn={(params) =>
|
queryFn={(params) =>
|
||||||
listRepairs({
|
listRepairs({ page: params.page, pageSize: params.pageSize })
|
||||||
page: params.page,
|
|
||||||
pageSize: params.pageSize,
|
|
||||||
status: (params.filters.status ?? undefined) as RepairStatus | undefined,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
enableSearch={false}
|
enableSearch={false}
|
||||||
toolbar={({ filters, setFilter }) => (
|
|
||||||
<Select
|
|
||||||
value={filters.status ?? ALL}
|
|
||||||
onValueChange={(v) => setFilter('status', v === ALL ? null : v)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-40">
|
|
||||||
<SelectValue placeholder="Any status" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value={ALL}>Any status</SelectItem>
|
|
||||||
{repairStatusOptions.map((o) => (
|
|
||||||
<SelectItem key={o.value} value={o.value}>
|
|
||||||
{o.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
emptyState={
|
emptyState={
|
||||||
<div className="flex flex-col items-center gap-1 py-8 text-muted-foreground">
|
<div className="flex flex-col items-center gap-1 py-8 text-muted-foreground">
|
||||||
<Wrench className="h-6 w-6" />
|
<ArrowRightLeft className="h-6 w-6" />
|
||||||
<span className="text-sm">No repair jobs yet.</span>
|
<span className="text-sm">No repairs logged yet.</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<RepairFormDialog
|
<LogRepairDialog open={logOpen} onOpenChange={setLogOpen} />
|
||||||
open={createOpen}
|
|
||||||
onOpenChange={setCreateOpen}
|
|
||||||
onCreated={(repair) => navigate(`/repairs/${repair.id}`)}
|
|
||||||
/>
|
|
||||||
<ConfirmDialog
|
|
||||||
open={Boolean(deleting)}
|
|
||||||
onOpenChange={(o) => !o && setDeleting(null)}
|
|
||||||
title="Delete repair?"
|
|
||||||
description={
|
|
||||||
deleting
|
|
||||||
? `Remove repair "${deleting.problem.slice(0, 60)}" on ${deleting.host.name}. This cannot be undone.`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
confirmLabel="Delete"
|
|
||||||
destructive
|
|
||||||
pending={deleteMutation.isPending}
|
|
||||||
onConfirm={() => deleting && deleteMutation.mutate(deleting.id)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the `RepairComment` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- You are about to drop the `RepairJob` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- You are about to drop the `RepairJobPart` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "RepairComment_repairJobId_createdAt_idx";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "RepairJob_status_openedAt_idx";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "RepairJob_assigneeId_idx";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "RepairJob_hostId_idx";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "RepairJob_status_idx";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "RepairJobPart_partId_idx";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
PRAGMA foreign_keys=off;
|
||||||
|
DROP TABLE "RepairComment";
|
||||||
|
PRAGMA foreign_keys=on;
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
PRAGMA foreign_keys=off;
|
||||||
|
DROP TABLE "RepairJob";
|
||||||
|
PRAGMA foreign_keys=on;
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
PRAGMA foreign_keys=off;
|
||||||
|
DROP TABLE "RepairJobPart";
|
||||||
|
PRAGMA foreign_keys=on;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "fms" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"hostId" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'OPEN',
|
||||||
|
"problem" TEXT NOT NULL,
|
||||||
|
"openedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"closedAt" DATETIME,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "fms_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "fm_parts" (
|
||||||
|
"fmId" TEXT NOT NULL,
|
||||||
|
"partId" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
PRIMARY KEY ("fmId", "partId"),
|
||||||
|
CONSTRAINT "fm_parts_fmId_fkey" FOREIGN KEY ("fmId") REFERENCES "fms" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "fm_parts_partId_fkey" FOREIGN KEY ("partId") REFERENCES "Part" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "repairs" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"hostId" TEXT NOT NULL,
|
||||||
|
"brokenPartId" TEXT NOT NULL,
|
||||||
|
"replacementPartId" TEXT NOT NULL,
|
||||||
|
"performedById" TEXT NOT NULL,
|
||||||
|
"performedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"fmId" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "repairs_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "repairs_brokenPartId_fkey" FOREIGN KEY ("brokenPartId") REFERENCES "Part" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "repairs_replacementPartId_fkey" FOREIGN KEY ("replacementPartId") REFERENCES "Part" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "repairs_performedById_fkey" FOREIGN KEY ("performedById") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "repairs_fmId_fkey" FOREIGN KEY ("fmId") REFERENCES "fms" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
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,
|
||||||
|
"custodianId" TEXT,
|
||||||
|
"notes" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "Part_partModelId_fkey" FOREIGN KEY ("partModelId") REFERENCES "PartModel" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "Part_manufacturerId_fkey" FOREIGN KEY ("manufacturerId") REFERENCES "Manufacturer" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "Part_binId_fkey" FOREIGN KEY ("binId") REFERENCES "Bin" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "Part_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,
|
||||||
|
CONSTRAINT "Part_custodianId_fkey" FOREIGN KEY ("custodianId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO "new_Part" ("binId", "categoryId", "createdAt", "hostId", "id", "manufacturerId", "notes", "partModelId", "price", "serialNumber", "state", "updatedAt") SELECT "binId", "categoryId", "createdAt", "hostId", "id", "manufacturerId", "notes", "partModelId", "price", "serialNumber", "state", "updatedAt" FROM "Part";
|
||||||
|
DROP TABLE "Part";
|
||||||
|
ALTER TABLE "new_Part" RENAME TO "Part";
|
||||||
|
CREATE UNIQUE INDEX "Part_serialNumber_key" ON "Part"("serialNumber");
|
||||||
|
CREATE INDEX "Part_state_idx" ON "Part"("state");
|
||||||
|
CREATE INDEX "Part_binId_idx" ON "Part"("binId");
|
||||||
|
CREATE INDEX "Part_manufacturerId_idx" ON "Part"("manufacturerId");
|
||||||
|
CREATE INDEX "Part_partModelId_idx" ON "Part"("partModelId");
|
||||||
|
CREATE INDEX "Part_categoryId_idx" ON "Part"("categoryId");
|
||||||
|
CREATE INDEX "Part_hostId_idx" ON "Part"("hostId");
|
||||||
|
CREATE INDEX "Part_custodianId_idx" ON "Part"("custodianId");
|
||||||
|
CREATE TABLE "new_PartModel" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"manufacturerId" TEXT NOT NULL,
|
||||||
|
"mpn" TEXT NOT NULL,
|
||||||
|
"eolDate" DATETIME,
|
||||||
|
"destroyOnFail" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"notes" TEXT,
|
||||||
|
"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
|
||||||
|
);
|
||||||
|
INSERT INTO "new_PartModel" ("createdAt", "eolDate", "id", "manufacturerId", "mpn", "notes", "updatedAt") SELECT "createdAt", "eolDate", "id", "manufacturerId", "mpn", "notes", "updatedAt" FROM "PartModel";
|
||||||
|
DROP TABLE "PartModel";
|
||||||
|
ALTER TABLE "new_PartModel" RENAME TO "PartModel";
|
||||||
|
CREATE INDEX "PartModel_manufacturerId_idx" ON "PartModel"("manufacturerId");
|
||||||
|
CREATE INDEX "PartModel_eolDate_idx" ON "PartModel"("eolDate");
|
||||||
|
CREATE UNIQUE INDEX "PartModel_manufacturerId_mpn_key" ON "PartModel"("manufacturerId", "mpn");
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "fms_status_idx" ON "fms"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "fms_hostId_idx" ON "fms"("hostId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "fms_status_openedAt_idx" ON "fms"("status", "openedAt" DESC);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "fm_parts_partId_idx" ON "fm_parts"("partId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "repairs_hostId_performedAt_idx" ON "repairs"("hostId", "performedAt" DESC);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "repairs_fmId_idx" ON "repairs"("fmId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "repairs_performedById_performedAt_idx" ON "repairs"("performedById", "performedAt" DESC);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "repairs_brokenPartId_idx" ON "repairs"("brokenPartId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "repairs_replacementPartId_idx" ON "repairs"("replacementPartId");
|
||||||
@@ -16,19 +16,19 @@ datasource db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
username String @unique
|
username String @unique
|
||||||
email String @unique
|
email String @unique
|
||||||
passwordHash String
|
passwordHash String
|
||||||
role String @default("TECHNICIAN")
|
role String @default("TECHNICIAN")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
partEvents PartEvent[]
|
partEvents PartEvent[]
|
||||||
refreshTokens RefreshToken[]
|
refreshTokens RefreshToken[]
|
||||||
repairAssignments RepairJob[] @relation("RepairAssignee")
|
custodyParts Part[] @relation("Custody")
|
||||||
repairComments RepairComment[]
|
repairs Repair[]
|
||||||
savedViews SavedView[]
|
savedViews SavedView[]
|
||||||
csvImportJobs CsvImportJob[]
|
csvImportJobs CsvImportJob[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model RefreshToken {
|
model RefreshToken {
|
||||||
@@ -60,6 +60,7 @@ model PartModel {
|
|||||||
manufacturer Manufacturer @relation(fields: [manufacturerId], references: [id], onDelete: Restrict)
|
manufacturer Manufacturer @relation(fields: [manufacturerId], references: [id], onDelete: Restrict)
|
||||||
mpn String
|
mpn String
|
||||||
eolDate DateTime?
|
eolDate DateTime?
|
||||||
|
destroyOnFail Boolean @default(false)
|
||||||
notes String?
|
notes String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -114,26 +115,30 @@ model Category {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Part {
|
model Part {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
serialNumber String @unique
|
serialNumber String @unique
|
||||||
partModelId String
|
partModelId String
|
||||||
partModel PartModel @relation(fields: [partModelId], references: [id], onDelete: Restrict)
|
partModel PartModel @relation(fields: [partModelId], references: [id], onDelete: Restrict)
|
||||||
manufacturerId String
|
manufacturerId String
|
||||||
manufacturer Manufacturer @relation(fields: [manufacturerId], references: [id], onDelete: Restrict)
|
manufacturer Manufacturer @relation(fields: [manufacturerId], references: [id], onDelete: Restrict)
|
||||||
price Float?
|
price Float?
|
||||||
state String @default("SPARE")
|
state String @default("SPARE")
|
||||||
binId String?
|
binId String?
|
||||||
bin Bin? @relation(fields: [binId], references: [id], onDelete: SetNull)
|
bin Bin? @relation(fields: [binId], references: [id], onDelete: SetNull)
|
||||||
categoryId String?
|
categoryId String?
|
||||||
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||||
hostId String?
|
hostId String?
|
||||||
host Host? @relation(fields: [hostId], references: [id], onDelete: SetNull)
|
host Host? @relation(fields: [hostId], references: [id], onDelete: SetNull)
|
||||||
notes String?
|
custodianId String?
|
||||||
createdAt DateTime @default(now())
|
custodian User? @relation("Custody", fields: [custodianId], references: [id], onDelete: SetNull)
|
||||||
updatedAt DateTime @updatedAt
|
notes String?
|
||||||
events PartEvent[]
|
createdAt DateTime @default(now())
|
||||||
tags PartTag[]
|
updatedAt DateTime @updatedAt
|
||||||
problemInRepairs RepairJobPart[]
|
events PartEvent[]
|
||||||
|
tags PartTag[]
|
||||||
|
problemInFms FmPart[]
|
||||||
|
brokenRepairs Repair[] @relation("BrokenRepairs")
|
||||||
|
replacementRepairs Repair[] @relation("ReplacementRepairs")
|
||||||
|
|
||||||
@@index([state])
|
@@index([state])
|
||||||
@@index([binId])
|
@@index([binId])
|
||||||
@@ -141,6 +146,7 @@ model Part {
|
|||||||
@@index([partModelId])
|
@@index([partModelId])
|
||||||
@@index([categoryId])
|
@@index([categoryId])
|
||||||
@@index([hostId])
|
@@index([hostId])
|
||||||
|
@@index([custodianId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model PartEvent {
|
model PartEvent {
|
||||||
@@ -180,60 +186,71 @@ model PartTag {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Host {
|
model Host {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
assetId String @unique
|
assetId String @unique
|
||||||
name String @unique
|
name String @unique
|
||||||
location String?
|
location String?
|
||||||
notes String?
|
notes String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
parts Part[]
|
parts Part[]
|
||||||
repairs RepairJob[]
|
fms Fm[]
|
||||||
|
repairs Repair[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model RepairJob {
|
model Fm {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
hostId String
|
hostId String
|
||||||
host Host @relation(fields: [hostId], references: [id], onDelete: Restrict)
|
host Host @relation(fields: [hostId], references: [id], onDelete: Restrict)
|
||||||
assigneeId String?
|
status String @default("OPEN")
|
||||||
assignee User? @relation("RepairAssignee", fields: [assigneeId], references: [id], onDelete: SetNull)
|
|
||||||
status String @default("PENDING")
|
|
||||||
problem String
|
problem String
|
||||||
openedAt DateTime @default(now())
|
openedAt DateTime @default(now())
|
||||||
closedAt DateTime?
|
closedAt DateTime?
|
||||||
notes String?
|
createdAt DateTime @default(now())
|
||||||
createdAt DateTime @default(now())
|
updatedAt DateTime @updatedAt
|
||||||
updatedAt DateTime @updatedAt
|
problemParts FmPart[]
|
||||||
problemParts RepairJobPart[]
|
repairs Repair[]
|
||||||
comments RepairComment[]
|
|
||||||
|
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@index([hostId])
|
@@index([hostId])
|
||||||
@@index([assigneeId])
|
|
||||||
@@index([status, openedAt(sort: Desc)])
|
@@index([status, openedAt(sort: Desc)])
|
||||||
|
@@map("fms")
|
||||||
}
|
}
|
||||||
|
|
||||||
model RepairJobPart {
|
model FmPart {
|
||||||
repairJobId String
|
fmId String
|
||||||
partId String
|
partId String
|
||||||
repairJob RepairJob @relation(fields: [repairJobId], references: [id], onDelete: Cascade)
|
fm Fm @relation(fields: [fmId], references: [id], onDelete: Cascade)
|
||||||
part Part @relation(fields: [partId], references: [id], onDelete: Restrict)
|
part Part @relation(fields: [partId], references: [id], onDelete: Restrict)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@id([repairJobId, partId])
|
@@id([fmId, partId])
|
||||||
@@index([partId])
|
@@index([partId])
|
||||||
|
@@map("fm_parts")
|
||||||
}
|
}
|
||||||
|
|
||||||
model RepairComment {
|
model Repair {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
repairJobId String
|
hostId String
|
||||||
repairJob RepairJob @relation(fields: [repairJobId], references: [id], onDelete: Cascade)
|
host Host @relation(fields: [hostId], references: [id], onDelete: Restrict)
|
||||||
userId String?
|
brokenPartId String
|
||||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
brokenPart Part @relation("BrokenRepairs", fields: [brokenPartId], references: [id], onDelete: Restrict)
|
||||||
content String
|
replacementPartId String
|
||||||
createdAt DateTime @default(now())
|
replacement Part @relation("ReplacementRepairs", fields: [replacementPartId], references: [id], onDelete: Restrict)
|
||||||
|
performedById String
|
||||||
|
performedBy User @relation(fields: [performedById], references: [id], onDelete: Restrict)
|
||||||
|
performedAt DateTime @default(now())
|
||||||
|
fmId String?
|
||||||
|
fm Fm? @relation(fields: [fmId], references: [id], onDelete: SetNull)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([repairJobId, createdAt])
|
@@index([hostId, performedAt(sort: Desc)])
|
||||||
|
@@index([fmId])
|
||||||
|
@@index([performedById, performedAt(sort: Desc)])
|
||||||
|
@@index([brokenPartId])
|
||||||
|
@@index([replacementPartId])
|
||||||
|
@@map("repairs")
|
||||||
}
|
}
|
||||||
|
|
||||||
model WebhookSubscription {
|
model WebhookSubscription {
|
||||||
|
|||||||
@@ -33,5 +33,5 @@ export interface DashboardAnalytics {
|
|||||||
ageBuckets: AgeBucket[];
|
ageBuckets: AgeBucket[];
|
||||||
topBins: BinCount[];
|
topBins: BinCount[];
|
||||||
deployedPastEol: PartModelEolSummary[];
|
deployedPastEol: PartModelEolSummary[];
|
||||||
openRepairs: number;
|
openFms: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { PaginationQuery } from './pagination.js';
|
||||||
|
|
||||||
|
export const DropOffRequest = z.object({
|
||||||
|
binId: z.string().uuid().nullable(),
|
||||||
|
});
|
||||||
|
export type DropOffRequest = z.infer<typeof DropOffRequest>;
|
||||||
|
|
||||||
|
export const CustodyListQuery = PaginationQuery.extend({
|
||||||
|
userId: z.string().uuid().optional(),
|
||||||
|
});
|
||||||
|
export type CustodyListQuery = z.infer<typeof CustodyListQuery>;
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const PartState = z.enum(['SPARE', 'DEPLOYED', 'BROKEN', 'PENDING_DESTRUCTION']);
|
export const PartState = z.enum([
|
||||||
|
'SPARE',
|
||||||
|
'DEPLOYED',
|
||||||
|
'BROKEN',
|
||||||
|
'PENDING_DESTRUCTION',
|
||||||
|
'PENDING_DROP_IN_CUSTODY',
|
||||||
|
'PENDING_DESTRUCTION_IN_CUSTODY',
|
||||||
|
]);
|
||||||
export type PartState = z.infer<typeof PartState>;
|
export type PartState = z.infer<typeof PartState>;
|
||||||
|
|
||||||
export const Role = z.enum(['ADMIN', 'TECHNICIAN']);
|
export const Role = z.enum(['ADMIN', 'TECHNICIAN']);
|
||||||
@@ -11,17 +18,16 @@ export const PartEventType = z.enum([
|
|||||||
'STATE_CHANGED',
|
'STATE_CHANGED',
|
||||||
'LOCATION_CHANGED',
|
'LOCATION_CHANGED',
|
||||||
'FIELD_UPDATED',
|
'FIELD_UPDATED',
|
||||||
'REPAIR_STARTED',
|
'FM_OPENED',
|
||||||
'REPAIR_COMPLETED',
|
'FM_CLOSED',
|
||||||
'REPAIR_CANCELLED',
|
'PART_SWAPPED',
|
||||||
'REPAIR_COMMENTED',
|
|
||||||
'TAG_ADDED',
|
'TAG_ADDED',
|
||||||
'TAG_REMOVED',
|
'TAG_REMOVED',
|
||||||
]);
|
]);
|
||||||
export type PartEventType = z.infer<typeof PartEventType>;
|
export type PartEventType = z.infer<typeof PartEventType>;
|
||||||
|
|
||||||
export const RepairStatus = z.enum(['PENDING', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED']);
|
export const FmStatus = z.enum(['OPEN', 'CLOSED']);
|
||||||
export type RepairStatus = z.infer<typeof RepairStatus>;
|
export type FmStatus = z.infer<typeof FmStatus>;
|
||||||
|
|
||||||
export const CsvImportStatus = z.enum([
|
export const CsvImportStatus = z.enum([
|
||||||
'PENDING',
|
'PENDING',
|
||||||
@@ -39,9 +45,9 @@ export const WebhookEventName = z.enum([
|
|||||||
'part.deleted',
|
'part.deleted',
|
||||||
'part.state_changed',
|
'part.state_changed',
|
||||||
'part.location_changed',
|
'part.location_changed',
|
||||||
'repair.started',
|
'fm.opened',
|
||||||
'repair.completed',
|
'fm.closed',
|
||||||
'repair.cancelled',
|
'repair.logged',
|
||||||
'tag.assigned',
|
'tag.assigned',
|
||||||
'tag.removed',
|
'tag.removed',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { FmStatus } from './enums.js';
|
||||||
|
import { PaginationQuery } from './pagination.js';
|
||||||
|
|
||||||
|
// Host lookup accepts either a uuid `hostId` or a string `assetId` — exactly one.
|
||||||
|
const hostSelector = {
|
||||||
|
hostId: z.string().uuid().optional(),
|
||||||
|
assetId: z.string().trim().min(1).max(128).optional(),
|
||||||
|
};
|
||||||
|
|
||||||
|
function hostSelectorRefine<T extends { hostId?: string; assetId?: string }>(
|
||||||
|
v: T,
|
||||||
|
ctx: z.RefinementCtx,
|
||||||
|
) {
|
||||||
|
const has = [v.hostId, v.assetId].filter((x) => x !== undefined && x !== '').length;
|
||||||
|
if (has !== 1) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'Provide exactly one of hostId or assetId',
|
||||||
|
path: ['hostId'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreateFmRequest = z
|
||||||
|
.object({
|
||||||
|
...hostSelector,
|
||||||
|
problem: z.string().trim().min(1, 'Problem is required').max(2000),
|
||||||
|
problemPartIds: z.array(z.string().uuid()).max(100).optional(),
|
||||||
|
})
|
||||||
|
.superRefine(hostSelectorRefine);
|
||||||
|
export type CreateFmRequest = z.infer<typeof CreateFmRequest>;
|
||||||
|
|
||||||
|
export const UpdateFmRequest = z
|
||||||
|
.object({
|
||||||
|
status: FmStatus.optional(),
|
||||||
|
problem: z.string().trim().min(1).max(2000).optional(),
|
||||||
|
problemPartIds: z.array(z.string().uuid()).max(100).optional(),
|
||||||
|
})
|
||||||
|
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
|
||||||
|
export type UpdateFmRequest = z.infer<typeof UpdateFmRequest>;
|
||||||
|
|
||||||
|
export const FmListQuery = PaginationQuery.extend({
|
||||||
|
status: FmStatus.optional(),
|
||||||
|
hostId: z.string().uuid().optional(),
|
||||||
|
problemPartId: z.string().uuid().optional(),
|
||||||
|
openOnly: z
|
||||||
|
.union([z.literal('true'), z.literal('false'), z.boolean()])
|
||||||
|
.transform((v) => v === true || v === 'true')
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
export type FmListQuery = z.infer<typeof FmListQuery>;
|
||||||
@@ -8,7 +8,9 @@ export * from './parts.js';
|
|||||||
export * from './env.js';
|
export * from './env.js';
|
||||||
export * from './pagination.js';
|
export * from './pagination.js';
|
||||||
export * from './hosts.js';
|
export * from './hosts.js';
|
||||||
|
export * from './fms.js';
|
||||||
export * from './repairs.js';
|
export * from './repairs.js';
|
||||||
|
export * from './custody.js';
|
||||||
export * from './tags.js';
|
export * from './tags.js';
|
||||||
export * from './categories.js';
|
export * from './categories.js';
|
||||||
export * from './webhooks.js';
|
export * from './webhooks.js';
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const CreatePartModelRequest = z.object({
|
|||||||
manufacturerId: z.string().uuid(),
|
manufacturerId: z.string().uuid(),
|
||||||
mpn: z.string().min(1).max(128),
|
mpn: z.string().min(1).max(128),
|
||||||
eolDate: IsoDate.nullable().optional(),
|
eolDate: IsoDate.nullable().optional(),
|
||||||
|
destroyOnFail: z.boolean().optional(),
|
||||||
notes: z.string().max(4096).nullable().optional(),
|
notes: z.string().max(4096).nullable().optional(),
|
||||||
});
|
});
|
||||||
export type CreatePartModelRequest = z.infer<typeof CreatePartModelRequest>;
|
export type CreatePartModelRequest = z.infer<typeof CreatePartModelRequest>;
|
||||||
@@ -16,6 +17,7 @@ export const UpdatePartModelRequest = z
|
|||||||
manufacturerId: z.string().uuid().optional(),
|
manufacturerId: z.string().uuid().optional(),
|
||||||
mpn: z.string().min(1).max(128).optional(),
|
mpn: z.string().min(1).max(128).optional(),
|
||||||
eolDate: IsoDate.nullable().optional(),
|
eolDate: IsoDate.nullable().optional(),
|
||||||
|
destroyOnFail: z.boolean().optional(),
|
||||||
notes: z.string().max(4096).nullable().optional(),
|
notes: z.string().max(4096).nullable().optional(),
|
||||||
})
|
})
|
||||||
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
|
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
|
||||||
|
|||||||
@@ -15,6 +15,28 @@ const modelSelector = z
|
|||||||
{ message: 'Provide partModelId or both manufacturerId and mpn' },
|
{ message: 'Provide partModelId or both manufacturerId and mpn' },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Which of hostId / binId / custodianId may be set for a given state.
|
||||||
|
// `null` counts as "not set" for the purposes of these checks — callers are expected
|
||||||
|
// to treat undefined / null consistently when wiring a request.
|
||||||
|
export function allowedLocationFieldsForState(state: PartState): {
|
||||||
|
hostId: 'required' | 'forbidden';
|
||||||
|
binId: 'optional' | 'forbidden';
|
||||||
|
custodianId: 'required' | 'forbidden';
|
||||||
|
} {
|
||||||
|
switch (state) {
|
||||||
|
case 'DEPLOYED':
|
||||||
|
return { hostId: 'required', binId: 'forbidden', custodianId: 'forbidden' };
|
||||||
|
case 'PENDING_DROP_IN_CUSTODY':
|
||||||
|
case 'PENDING_DESTRUCTION_IN_CUSTODY':
|
||||||
|
return { hostId: 'forbidden', binId: 'forbidden', custodianId: 'required' };
|
||||||
|
case 'SPARE':
|
||||||
|
case 'BROKEN':
|
||||||
|
case 'PENDING_DESTRUCTION':
|
||||||
|
default:
|
||||||
|
return { hostId: 'forbidden', binId: 'optional', custodianId: 'forbidden' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const CreatePartRequest = z
|
export const CreatePartRequest = z
|
||||||
.object({
|
.object({
|
||||||
serialNumber: z.string().min(1).max(128),
|
serialNumber: z.string().min(1).max(128),
|
||||||
@@ -25,6 +47,7 @@ export const CreatePartRequest = z
|
|||||||
state: PartState.optional(),
|
state: PartState.optional(),
|
||||||
binId: z.string().uuid().optional().nullable(),
|
binId: z.string().uuid().optional().nullable(),
|
||||||
hostId: z.string().uuid().optional().nullable(),
|
hostId: z.string().uuid().optional().nullable(),
|
||||||
|
custodianId: z.string().uuid().optional().nullable(),
|
||||||
notes: z.string().max(4096).optional().nullable(),
|
notes: z.string().max(4096).optional().nullable(),
|
||||||
categoryId: z.string().uuid().optional().nullable(),
|
categoryId: z.string().uuid().optional().nullable(),
|
||||||
tagIds: z.array(z.string().uuid()).max(32).optional(),
|
tagIds: z.array(z.string().uuid()).max(32).optional(),
|
||||||
@@ -42,30 +65,43 @@ export const CreatePartRequest = z
|
|||||||
path: ['partModelId'],
|
path: ['partModelId'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// State/location coupling: DEPLOYED parts live on a host; every other state lives in a bin.
|
|
||||||
const state = v.state ?? 'SPARE';
|
const state = v.state ?? 'SPARE';
|
||||||
if (state === 'DEPLOYED') {
|
const rules = allowedLocationFieldsForState(state);
|
||||||
if (!v.hostId) {
|
if (rules.hostId === 'required' && !v.hostId) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: 'A deployed part must be assigned to a host',
|
message: 'A deployed part must be assigned to a host',
|
||||||
path: ['hostId'],
|
path: ['hostId'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (v.binId) {
|
if (rules.hostId === 'forbidden' && v.hostId) {
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: 'A deployed part cannot also be in a storage bin',
|
|
||||||
path: ['binId'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (v.hostId) {
|
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: 'Only deployed parts can be assigned to a host',
|
message: 'Only deployed parts can be assigned to a host',
|
||||||
path: ['hostId'],
|
path: ['hostId'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (rules.binId === 'forbidden' && v.binId) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'This state cannot have a bin assignment',
|
||||||
|
path: ['binId'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (rules.custodianId === 'required' && !v.custodianId) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'A part in custody must name a custodian',
|
||||||
|
path: ['custodianId'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (rules.custodianId === 'forbidden' && v.custodianId) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'Only parts in a custody state can have a custodian',
|
||||||
|
path: ['custodianId'],
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
export type CreatePartRequest = z.infer<typeof CreatePartRequest>;
|
export type CreatePartRequest = z.infer<typeof CreatePartRequest>;
|
||||||
|
|
||||||
@@ -77,14 +113,46 @@ export const UpdatePartRequest = z
|
|||||||
state: PartState.optional(),
|
state: PartState.optional(),
|
||||||
binId: z.string().uuid().nullable().optional(),
|
binId: z.string().uuid().nullable().optional(),
|
||||||
hostId: z.string().uuid().nullable().optional(),
|
hostId: z.string().uuid().nullable().optional(),
|
||||||
|
custodianId: z.string().uuid().nullable().optional(),
|
||||||
notes: z.string().max(4096).nullable().optional(),
|
notes: z.string().max(4096).nullable().optional(),
|
||||||
categoryId: z.string().uuid().nullable().optional(),
|
categoryId: z.string().uuid().nullable().optional(),
|
||||||
tagIds: z.array(z.string().uuid()).max(32).optional(),
|
tagIds: z.array(z.string().uuid()).max(32).optional(),
|
||||||
})
|
})
|
||||||
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' })
|
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' })
|
||||||
.refine((v) => !(v.binId && v.hostId), {
|
.superRefine((v, ctx) => {
|
||||||
message: 'A part cannot be assigned to both a host and a bin',
|
// When state is supplied we can enforce the full matrix against the input fields.
|
||||||
path: ['binId'],
|
// When state is absent the server resolver still enforces the invariant using
|
||||||
|
// current-row state + input overlay, so we keep zod to input-level sanity checks.
|
||||||
|
if (v.state) {
|
||||||
|
const rules = allowedLocationFieldsForState(v.state);
|
||||||
|
if (rules.hostId === 'forbidden' && v.hostId) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'Only deployed parts can be assigned to a host',
|
||||||
|
path: ['hostId'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (rules.binId === 'forbidden' && v.binId) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'This state cannot have a bin assignment',
|
||||||
|
path: ['binId'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (rules.custodianId === 'forbidden' && v.custodianId) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'Only parts in a custody state can have a custodian',
|
||||||
|
path: ['custodianId'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (v.binId && v.hostId) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'A part cannot be assigned to both a host and a bin',
|
||||||
|
path: ['binId'],
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
export type UpdatePartRequest = z.infer<typeof UpdatePartRequest>;
|
export type UpdatePartRequest = z.infer<typeof UpdatePartRequest>;
|
||||||
|
|
||||||
@@ -94,6 +162,7 @@ export const PartListQuery = PaginationQuery.extend({
|
|||||||
manufacturerId: z.string().uuid().optional(),
|
manufacturerId: z.string().uuid().optional(),
|
||||||
partModelId: z.string().uuid().optional(),
|
partModelId: z.string().uuid().optional(),
|
||||||
hostId: z.string().uuid().optional(),
|
hostId: z.string().uuid().optional(),
|
||||||
|
custodianId: z.string().uuid().optional(),
|
||||||
mpn: z.string().max(128).optional(),
|
mpn: z.string().max(128).optional(),
|
||||||
serialNumber: z.string().max(128).optional(),
|
serialNumber: z.string().max(128).optional(),
|
||||||
q: z.string().max(128).optional(),
|
q: z.string().max(128).optional(),
|
||||||
|
|||||||
@@ -1,43 +1,34 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { RepairStatus } from './enums.js';
|
|
||||||
import { PaginationQuery } from './pagination.js';
|
import { PaginationQuery } from './pagination.js';
|
||||||
|
|
||||||
export const CreateRepairJobRequest = z.object({
|
// Repair = a physical part-swap log entry. Tech enters host + broken serial/mpn + replacement serial.
|
||||||
hostId: z.string().uuid(),
|
// If the broken part isn't in the catalog yet it gets auto-ingested (requires mpn + manufacturer).
|
||||||
problem: z.string().trim().min(1, 'Problem is required').max(2000),
|
export const LogRepairRequest = z
|
||||||
problemPartIds: z.array(z.string().uuid()).max(100).optional(),
|
|
||||||
assigneeId: z.string().uuid().optional().nullable(),
|
|
||||||
notes: z.string().max(4096).optional().nullable(),
|
|
||||||
});
|
|
||||||
export type CreateRepairJobRequest = z.infer<typeof CreateRepairJobRequest>;
|
|
||||||
|
|
||||||
export const UpdateRepairJobRequest = z
|
|
||||||
.object({
|
.object({
|
||||||
status: RepairStatus.optional(),
|
hostId: z.string().uuid().optional(),
|
||||||
problem: z.string().trim().min(1).max(2000).optional(),
|
assetId: z.string().trim().min(1).max(128).optional(),
|
||||||
problemPartIds: z.array(z.string().uuid()).max(100).optional(),
|
brokenSerial: z.string().trim().min(1).max(128),
|
||||||
assigneeId: z.string().uuid().nullable().optional(),
|
brokenMpn: z.string().trim().min(1).max(128),
|
||||||
notes: z.string().max(4096).nullable().optional(),
|
brokenManufacturerId: z.string().uuid(),
|
||||||
|
replacementSerial: z.string().trim().min(1).max(128),
|
||||||
|
fmId: z.string().uuid().optional(),
|
||||||
})
|
})
|
||||||
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
|
.superRefine((v, ctx) => {
|
||||||
export type UpdateRepairJobRequest = z.infer<typeof UpdateRepairJobRequest>;
|
const has = [v.hostId, v.assetId].filter((x) => x !== undefined && x !== '').length;
|
||||||
|
if (has !== 1) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'Provide exactly one of hostId or assetId',
|
||||||
|
path: ['hostId'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
export type LogRepairRequest = z.infer<typeof LogRepairRequest>;
|
||||||
|
|
||||||
export const RepairJobListQuery = PaginationQuery.extend({
|
export const RepairListQuery = PaginationQuery.extend({
|
||||||
status: RepairStatus.optional(),
|
|
||||||
hostId: z.string().uuid().optional(),
|
hostId: z.string().uuid().optional(),
|
||||||
problemPartId: z.string().uuid().optional(),
|
performedById: z.string().uuid().optional(),
|
||||||
assigneeId: z.string().uuid().optional(),
|
fmId: z.string().uuid().optional(),
|
||||||
openOnly: z
|
since: z.string().datetime().optional(),
|
||||||
.union([z.literal('true'), z.literal('false'), z.boolean()])
|
|
||||||
.transform((v) => v === true || v === 'true')
|
|
||||||
.optional(),
|
|
||||||
});
|
});
|
||||||
export type RepairJobListQuery = z.infer<typeof RepairJobListQuery>;
|
export type RepairListQuery = z.infer<typeof RepairListQuery>;
|
||||||
|
|
||||||
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>;
|
|
||||||
|
|||||||
Reference in New Issue
Block a user