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