feat: remove FM feature from Vector
FMs move to a separate application. Drops Fm/FmPart tables + Repair.fmId column, deletes FM_OPENED/FM_CLOSED PartEvent rows, strips FM enums + webhook events + shared contracts, removes FM routes/services/pages/UI, and collapses dashboard admin ops to Repairs 7d/30d + trend + custody backlog. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -22,7 +22,6 @@ import partRoutes from './routes/parts.js';
|
|||||||
import tagRoutes from './routes/tags.js';
|
import tagRoutes from './routes/tags.js';
|
||||||
import categoryRoutes from './routes/categories.js';
|
import categoryRoutes from './routes/categories.js';
|
||||||
import hostRoutes from './routes/hosts.js';
|
import hostRoutes from './routes/hosts.js';
|
||||||
import fmRoutes from './routes/fms.js';
|
|
||||||
import repairRoutes from './routes/repairs.js';
|
import repairRoutes from './routes/repairs.js';
|
||||||
import custodyRoutes from './routes/custody.js';
|
import custodyRoutes from './routes/custody.js';
|
||||||
import savedViewRoutes from './routes/saved-views.js';
|
import savedViewRoutes from './routes/saved-views.js';
|
||||||
@@ -90,7 +89,6 @@ app.use('/api/parts', partRoutes);
|
|||||||
app.use('/api/tags', tagRoutes);
|
app.use('/api/tags', tagRoutes);
|
||||||
app.use('/api/categories', categoryRoutes);
|
app.use('/api/categories', categoryRoutes);
|
||||||
app.use('/api/hosts', hostRoutes);
|
app.use('/api/hosts', hostRoutes);
|
||||||
app.use('/api/fms', fmRoutes);
|
|
||||||
app.use('/api/repairs', repairRoutes);
|
app.use('/api/repairs', repairRoutes);
|
||||||
app.use('/api/custody', custodyRoutes);
|
app.use('/api/custody', custodyRoutes);
|
||||||
app.use('/api/saved-views', savedViewRoutes);
|
app.use('/api/saved-views', savedViewRoutes);
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import type { NextFunction, Request, Response } from 'express';
|
|
||||||
import { prisma } from '@vector/db';
|
|
||||||
import type { CreateFmRequest, FmListQuery, UpdateFmRequest } from '@vector/shared';
|
|
||||||
import * as svc from '../services/fms.js';
|
|
||||||
import { errors } from '../lib/http-error.js';
|
|
||||||
|
|
||||||
export async function list(req: Request, res: Response, next: NextFunction) {
|
|
||||||
try {
|
|
||||||
const q = req.validated!.query as FmListQuery;
|
|
||||||
const result = await prisma.$transaction((tx) => svc.list(tx, q));
|
|
||||||
res.json(result);
|
|
||||||
} catch (err) {
|
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function get(req: Request<{ id: string }>, res: Response, next: NextFunction) {
|
|
||||||
try {
|
|
||||||
const fm = await prisma.$transaction((tx) => svc.get(tx, req.params.id));
|
|
||||||
if (!fm) throw errors.notFound('FM');
|
|
||||||
res.json(fm);
|
|
||||||
} catch (err) {
|
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function create(req: Request, res: Response, next: NextFunction) {
|
|
||||||
try {
|
|
||||||
const input = req.validated!.body as CreateFmRequest;
|
|
||||||
const fm = await prisma.$transaction((tx) => svc.create(tx, input, req.user ?? null));
|
|
||||||
res.status(201).json(fm);
|
|
||||||
} catch (err) {
|
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
|
|
||||||
try {
|
|
||||||
const input = req.validated!.body as UpdateFmRequest;
|
|
||||||
const fm = await prisma.$transaction((tx) =>
|
|
||||||
svc.update(tx, req.params.id, input, req.user ?? null),
|
|
||||||
);
|
|
||||||
res.json(fm);
|
|
||||||
} catch (err) {
|
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) {
|
|
||||||
try {
|
|
||||||
await prisma.$transaction((tx) => svc.remove(tx, req.params.id));
|
|
||||||
res.status(204).end();
|
|
||||||
} catch (err) {
|
|
||||||
next(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { Router } from 'express';
|
|
||||||
import { CreateFmRequest, FmListQuery, UpdateFmRequest } from '@vector/shared';
|
|
||||||
import * as ctrl from '../controllers/fms.js';
|
|
||||||
import { requireAuth } from '../middleware/auth.js';
|
|
||||||
import { validate } from '../middleware/validate.js';
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
router.get('/', requireAuth, validate('query', FmListQuery), ctrl.list);
|
|
||||||
router.post('/', requireAuth, validate('body', CreateFmRequest), ctrl.create);
|
|
||||||
router.get('/:id', requireAuth, ctrl.get);
|
|
||||||
router.patch('/:id', requireAuth, validate('body', UpdateFmRequest), ctrl.update);
|
|
||||||
router.delete('/:id', requireAuth, ctrl.remove);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -20,17 +20,12 @@ type FakeArgs = {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
partModelId: string;
|
partModelId: string;
|
||||||
}[];
|
}[];
|
||||||
openFms: number;
|
|
||||||
pastEolModels: EolPartModel[];
|
pastEolModels: EolPartModel[];
|
||||||
upcomingEolModels: EolPartModel[];
|
upcomingEolModels: EolPartModel[];
|
||||||
bins: { id: string; name: string; room: { name: string; site: { name: string } } }[];
|
bins: { id: string; name: string; room: { name: string; site: { name: string } } }[];
|
||||||
// Admin-only inputs. Ignored when isAdmin=false path is exercised.
|
// Admin-only inputs. Ignored when isAdmin=false path is exercised.
|
||||||
repairs?: { performedAt: Date }[];
|
repairs?: { performedAt: Date }[];
|
||||||
fmsClosed?: { openedAt: Date; closedAt: Date | null }[];
|
|
||||||
newFms7d?: number;
|
|
||||||
openFmGroups?: { hostId: string; count: number }[];
|
|
||||||
custodyGroups?: { custodianId: string | null; count: number }[];
|
custodyGroups?: { custodianId: string | null; count: number }[];
|
||||||
hosts?: { id: string; name: string }[];
|
|
||||||
users?: { id: string; username: string }[];
|
users?: { id: string; username: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -53,18 +48,6 @@ function makeTx(args: FakeArgs): Tx {
|
|||||||
},
|
},
|
||||||
findMany: async () => args.parts,
|
findMany: async () => args.parts,
|
||||||
},
|
},
|
||||||
fm: {
|
|
||||||
count: async (q: { where?: { status?: string; openedAt?: { gte: Date } } }) => {
|
|
||||||
if (q.where?.openedAt) return args.newFms7d ?? 0;
|
|
||||||
return args.openFms;
|
|
||||||
},
|
|
||||||
findMany: async () => args.fmsClosed ?? [],
|
|
||||||
groupBy: async () =>
|
|
||||||
(args.openFmGroups ?? []).map((g) => ({
|
|
||||||
hostId: g.hostId,
|
|
||||||
_count: { _all: g.count },
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
partModel: {
|
partModel: {
|
||||||
findMany: async (q: { where?: { eolDate?: { gt?: Date; lte?: Date; not?: unknown } } }) => {
|
findMany: async (q: { where?: { eolDate?: { gt?: Date; lte?: Date; not?: unknown } } }) => {
|
||||||
const gt = q.where?.eolDate?.gt;
|
const gt = q.where?.eolDate?.gt;
|
||||||
@@ -87,9 +70,6 @@ function makeTx(args: FakeArgs): Tx {
|
|||||||
return (args.repairs ?? []).filter((r) => r.performedAt >= gte);
|
return (args.repairs ?? []).filter((r) => r.performedAt >= gte);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
host: {
|
|
||||||
findMany: async () => args.hosts ?? [],
|
|
||||||
},
|
|
||||||
user: {
|
user: {
|
||||||
findMany: async () => args.users ?? [],
|
findMany: async () => args.users ?? [],
|
||||||
},
|
},
|
||||||
@@ -100,20 +80,18 @@ function makeTx(args: FakeArgs): Tx {
|
|||||||
const now = new Date('2026-04-16T00:00:00.000Z');
|
const now = new Date('2026-04-16T00:00:00.000Z');
|
||||||
const daysAgo = (n: number) => new Date(now.getTime() - n * 24 * 60 * 60 * 1000);
|
const daysAgo = (n: number) => new Date(now.getTime() - n * 24 * 60 * 60 * 1000);
|
||||||
const daysAhead = (n: number) => new Date(now.getTime() + n * 24 * 60 * 60 * 1000);
|
const daysAhead = (n: number) => new Date(now.getTime() + n * 24 * 60 * 60 * 1000);
|
||||||
const HOUR_MS = 60 * 60 * 1000;
|
|
||||||
|
|
||||||
const EMPTY: FakeArgs = {
|
const EMPTY: FakeArgs = {
|
||||||
partCount: 0,
|
partCount: 0,
|
||||||
stateRows: [],
|
stateRows: [],
|
||||||
parts: [],
|
parts: [],
|
||||||
openFms: 0,
|
|
||||||
pastEolModels: [],
|
pastEolModels: [],
|
||||||
upcomingEolModels: [],
|
upcomingEolModels: [],
|
||||||
bins: [],
|
bins: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('analytics.dashboard — base fields', () => {
|
describe('analytics.dashboard — base fields', () => {
|
||||||
it('aggregates totals, state counts and open FMs', async () => {
|
it('aggregates totals and state counts', async () => {
|
||||||
const tx = makeTx({
|
const tx = makeTx({
|
||||||
...EMPTY,
|
...EMPTY,
|
||||||
partCount: 5,
|
partCount: 5,
|
||||||
@@ -121,12 +99,10 @@ describe('analytics.dashboard — base fields', () => {
|
|||||||
{ state: 'SPARE', count: 3, totalPrice: 1500 },
|
{ state: 'SPARE', count: 3, totalPrice: 1500 },
|
||||||
{ state: 'DEPLOYED', count: 2, totalPrice: 8000 },
|
{ state: 'DEPLOYED', count: 2, totalPrice: 8000 },
|
||||||
],
|
],
|
||||||
openFms: 4,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const r = await dashboard(tx, { isAdmin: false });
|
const r = await dashboard(tx, { isAdmin: false });
|
||||||
expect(r.totalParts).toBe(5);
|
expect(r.totalParts).toBe(5);
|
||||||
expect(r.openFms).toBe(4);
|
|
||||||
expect(r.byState).toEqual([
|
expect(r.byState).toEqual([
|
||||||
{ state: 'SPARE', count: 3, totalPrice: 1500 },
|
{ state: 'SPARE', count: 3, totalPrice: 1500 },
|
||||||
{ state: 'DEPLOYED', count: 2, totalPrice: 8000 },
|
{ state: 'DEPLOYED', count: 2, totalPrice: 8000 },
|
||||||
@@ -279,11 +255,7 @@ describe('analytics.dashboard — isAdmin gating', () => {
|
|||||||
const tx = makeTx({
|
const tx = makeTx({
|
||||||
...EMPTY,
|
...EMPTY,
|
||||||
repairs: [{ performedAt: daysAgo(1) }],
|
repairs: [{ performedAt: daysAgo(1) }],
|
||||||
fmsClosed: [{ openedAt: daysAgo(2), closedAt: daysAgo(1) }],
|
|
||||||
newFms7d: 3,
|
|
||||||
openFmGroups: [{ hostId: 'h1', count: 2 }],
|
|
||||||
custodyGroups: [{ custodianId: 'u1', count: 1 }],
|
custodyGroups: [{ custodianId: 'u1', count: 1 }],
|
||||||
hosts: [{ id: 'h1', name: 'host-1' }],
|
|
||||||
users: [{ id: 'u1', username: 'alice' }],
|
users: [{ id: 'u1', username: 'alice' }],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -292,10 +264,8 @@ describe('analytics.dashboard — isAdmin gating', () => {
|
|||||||
expect(r.operations).toMatchObject({
|
expect(r.operations).toMatchObject({
|
||||||
repairs7d: 1,
|
repairs7d: 1,
|
||||||
repairs30d: 1,
|
repairs30d: 1,
|
||||||
newFms7d: 3,
|
|
||||||
});
|
});
|
||||||
expect(r.operations!.repairsTrend30d).toHaveLength(30);
|
expect(r.operations!.repairsTrend30d).toHaveLength(30);
|
||||||
expect(r.operations!.openFmsByHost).toEqual([{ hostId: 'h1', hostName: 'host-1', count: 2 }]);
|
|
||||||
expect(r.operations!.custodyBacklog).toEqual([
|
expect(r.operations!.custodyBacklog).toEqual([
|
||||||
{ userId: 'u1', username: 'alice', count: 1 },
|
{ userId: 'u1', username: 'alice', count: 1 },
|
||||||
]);
|
]);
|
||||||
@@ -304,43 +274,23 @@ describe('analytics.dashboard — isAdmin gating', () => {
|
|||||||
|
|
||||||
describe('analytics.dashboard — operations fields', () => {
|
describe('analytics.dashboard — operations fields', () => {
|
||||||
it('repairsTrend30d has 30 entries and zero-fills empty days', async () => {
|
it('repairsTrend30d has 30 entries and zero-fills empty days', async () => {
|
||||||
|
// Anchor the repairs to real "now" so they land inside the dashboard's
|
||||||
|
// 30-day window regardless of when the test runs.
|
||||||
|
const realNow = new Date();
|
||||||
|
const realDaysAgo = (n: number) => new Date(realNow.getTime() - n * 24 * 60 * 60 * 1000);
|
||||||
const tx = makeTx({
|
const tx = makeTx({
|
||||||
...EMPTY,
|
...EMPTY,
|
||||||
repairs: [{ performedAt: daysAgo(5) }, { performedAt: daysAgo(28) }],
|
repairs: [{ performedAt: realDaysAgo(2) }, { performedAt: realDaysAgo(10) }],
|
||||||
});
|
});
|
||||||
|
|
||||||
const r = await dashboard(tx, { isAdmin: true });
|
const r = await dashboard(tx, { isAdmin: true });
|
||||||
const trend = r.operations!.repairsTrend30d;
|
const trend = r.operations!.repairsTrend30d;
|
||||||
expect(trend).toHaveLength(30);
|
expect(trend).toHaveLength(30);
|
||||||
expect(trend.filter((d) => d.count === 0)).toHaveLength(28);
|
const totalCount = trend.reduce((s, d) => s + d.count, 0);
|
||||||
expect(trend.filter((d) => d.count === 1)).toHaveLength(2);
|
expect(totalCount).toBe(2);
|
||||||
// Chronological order: earliest first, today last
|
// Chronological order: earliest first, today last
|
||||||
for (let i = 1; i < trend.length; i++) {
|
for (let i = 1; i < trend.length; i++) {
|
||||||
expect(trend[i]!.date >= trend[i - 1]!.date).toBe(true);
|
expect(trend[i]!.date >= trend[i - 1]!.date).toBe(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('avgFmCloseHours30d is null when no FMs closed in window', async () => {
|
|
||||||
const tx = makeTx({ ...EMPTY, fmsClosed: [] });
|
|
||||||
const r = await dashboard(tx, { isAdmin: true });
|
|
||||||
expect(r.operations!.avgFmCloseHours30d).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('avgFmCloseHours30d averages close durations in hours', async () => {
|
|
||||||
const tx = makeTx({
|
|
||||||
...EMPTY,
|
|
||||||
fmsClosed: [
|
|
||||||
{
|
|
||||||
openedAt: new Date(now.getTime() - 4 * HOUR_MS),
|
|
||||||
closedAt: new Date(now.getTime() - 2 * HOUR_MS),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
openedAt: new Date(now.getTime() - 10 * HOUR_MS),
|
|
||||||
closedAt: new Date(now.getTime() - 4 * HOUR_MS),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const r = await dashboard(tx, { isAdmin: true });
|
|
||||||
expect(r.operations!.avgFmCloseHours30d).toBe(4);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { DashboardAnalytics, OperationsAnalytics } from '@vector/shared';
|
|||||||
import type { Tx } from './types.js';
|
import type { Tx } from './types.js';
|
||||||
|
|
||||||
const DAY = 24 * 60 * 60 * 1000;
|
const DAY = 24 * 60 * 60 * 1000;
|
||||||
const HOUR = 60 * 60 * 1000;
|
|
||||||
|
|
||||||
const AGE_BUCKETS: { label: string; maxDays: number | null }[] = [
|
const AGE_BUCKETS: { label: string; maxDays: number | null }[] = [
|
||||||
{ label: '0–30d', maxDays: 30 },
|
{ label: '0–30d', maxDays: 30 },
|
||||||
@@ -30,7 +29,7 @@ export async function dashboard(
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const upcomingEolCutoff = new Date(now.getTime() + 180 * DAY);
|
const upcomingEolCutoff = new Date(now.getTime() + 180 * DAY);
|
||||||
|
|
||||||
const [totalParts, stateRows, parts, openFms, pastEolModels, upcomingEolModels] =
|
const [totalParts, stateRows, parts, pastEolModels, upcomingEolModels] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
tx.part.count(),
|
tx.part.count(),
|
||||||
tx.part.groupBy({
|
tx.part.groupBy({
|
||||||
@@ -41,7 +40,6 @@ export async function dashboard(
|
|||||||
tx.part.findMany({
|
tx.part.findMany({
|
||||||
select: { id: true, state: true, binId: true, createdAt: true, partModelId: true },
|
select: { id: true, state: true, binId: true, createdAt: true, partModelId: true },
|
||||||
}),
|
}),
|
||||||
tx.fm.count({ where: { status: 'OPEN' } }),
|
|
||||||
tx.partModel.findMany({
|
tx.partModel.findMany({
|
||||||
where: { eolDate: { not: null, lte: now } },
|
where: { eolDate: { not: null, lte: now } },
|
||||||
select: {
|
select: {
|
||||||
@@ -139,7 +137,6 @@ export async function dashboard(
|
|||||||
topBins,
|
topBins,
|
||||||
deployedPastEol,
|
deployedPastEol,
|
||||||
upcomingEol,
|
upcomingEol,
|
||||||
openFms,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!opts.isAdmin) return base;
|
if (!opts.isAdmin) return base;
|
||||||
@@ -147,31 +144,13 @@ export async function dashboard(
|
|||||||
const sevenDaysAgo = new Date(nowMs - 7 * DAY);
|
const sevenDaysAgo = new Date(nowMs - 7 * DAY);
|
||||||
const thirtyDaysAgo = new Date(nowMs - 30 * DAY);
|
const thirtyDaysAgo = new Date(nowMs - 30 * DAY);
|
||||||
|
|
||||||
const [
|
const [repairs7d, repairs30d, recentRepairs, custodyGroups] = await Promise.all([
|
||||||
repairs7d,
|
|
||||||
repairs30d,
|
|
||||||
newFms7d,
|
|
||||||
closedFms,
|
|
||||||
recentRepairs,
|
|
||||||
openFmGroups,
|
|
||||||
custodyGroups,
|
|
||||||
] = await Promise.all([
|
|
||||||
tx.repair.count({ where: { performedAt: { gte: sevenDaysAgo } } }),
|
tx.repair.count({ where: { performedAt: { gte: sevenDaysAgo } } }),
|
||||||
tx.repair.count({ where: { performedAt: { gte: thirtyDaysAgo } } }),
|
tx.repair.count({ where: { performedAt: { gte: thirtyDaysAgo } } }),
|
||||||
tx.fm.count({ where: { openedAt: { gte: sevenDaysAgo } } }),
|
|
||||||
tx.fm.findMany({
|
|
||||||
where: { closedAt: { gte: thirtyDaysAgo } },
|
|
||||||
select: { openedAt: true, closedAt: true },
|
|
||||||
}),
|
|
||||||
tx.repair.findMany({
|
tx.repair.findMany({
|
||||||
where: { performedAt: { gte: thirtyDaysAgo } },
|
where: { performedAt: { gte: thirtyDaysAgo } },
|
||||||
select: { performedAt: true },
|
select: { performedAt: true },
|
||||||
}),
|
}),
|
||||||
tx.fm.groupBy({
|
|
||||||
by: ['hostId'],
|
|
||||||
where: { status: 'OPEN' },
|
|
||||||
_count: { _all: true },
|
|
||||||
}),
|
|
||||||
tx.part.groupBy({
|
tx.part.groupBy({
|
||||||
by: ['custodianId'],
|
by: ['custodianId'],
|
||||||
where: {
|
where: {
|
||||||
@@ -182,17 +161,6 @@ export async function dashboard(
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const closedWithDates = closedFms.filter(
|
|
||||||
(f): f is { openedAt: Date; closedAt: Date } => f.closedAt !== null,
|
|
||||||
);
|
|
||||||
const avgFmCloseHours30d =
|
|
||||||
closedWithDates.length === 0
|
|
||||||
? null
|
|
||||||
: closedWithDates.reduce(
|
|
||||||
(sum, f) => sum + (f.closedAt.getTime() - f.openedAt.getTime()) / HOUR,
|
|
||||||
0,
|
|
||||||
) / closedWithDates.length;
|
|
||||||
|
|
||||||
const trendByDay = new Map<string, number>();
|
const trendByDay = new Map<string, number>();
|
||||||
for (const r of recentRepairs) {
|
for (const r of recentRepairs) {
|
||||||
const key = utcDateKey(r.performedAt);
|
const key = utcDateKey(r.performedAt);
|
||||||
@@ -205,22 +173,6 @@ export async function dashboard(
|
|||||||
repairsTrend30d.push({ date: key, count: trendByDay.get(key) ?? 0 });
|
repairsTrend30d.push({ date: key, count: trendByDay.get(key) ?? 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const topOpenFmHostIds = [...openFmGroups]
|
|
||||||
.sort((a, b) => b._count._all - a._count._all)
|
|
||||||
.slice(0, 8);
|
|
||||||
const openFmHostRows = topOpenFmHostIds.length
|
|
||||||
? await tx.host.findMany({
|
|
||||||
where: { id: { in: topOpenFmHostIds.map((g) => g.hostId) } },
|
|
||||||
select: { id: true, name: true },
|
|
||||||
})
|
|
||||||
: [];
|
|
||||||
const openFmHostNames = new Map(openFmHostRows.map((h) => [h.id, h.name]));
|
|
||||||
const openFmsByHost = topOpenFmHostIds.map((g) => ({
|
|
||||||
hostId: g.hostId,
|
|
||||||
hostName: openFmHostNames.get(g.hostId) ?? 'Unknown',
|
|
||||||
count: g._count._all,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const topCustodians = [...custodyGroups]
|
const topCustodians = [...custodyGroups]
|
||||||
.filter((g): g is typeof g & { custodianId: string } => g.custodianId !== null)
|
.filter((g): g is typeof g & { custodianId: string } => g.custodianId !== null)
|
||||||
.sort((a, b) => b._count._all - a._count._all)
|
.sort((a, b) => b._count._all - a._count._all)
|
||||||
@@ -241,10 +193,7 @@ export async function dashboard(
|
|||||||
const operations: OperationsAnalytics = {
|
const operations: OperationsAnalytics = {
|
||||||
repairs7d,
|
repairs7d,
|
||||||
repairs30d,
|
repairs30d,
|
||||||
newFms7d,
|
|
||||||
avgFmCloseHours30d,
|
|
||||||
repairsTrend30d,
|
repairsTrend30d,
|
||||||
openFmsByHost,
|
|
||||||
custodyBacklog,
|
custodyBacklog,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,279 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
const emitMock = vi.fn();
|
|
||||||
vi.mock('../lib/webhook-emitter.js', () => ({
|
|
||||||
emit: (...args: unknown[]) => emitMock(...args),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import type { Tx, Actor } from './types.js';
|
|
||||||
import { create, update, resolveHost } from './fms.js';
|
|
||||||
|
|
||||||
const actor: Actor = { id: 'user-1', username: 'tech', role: 'ADMIN' };
|
|
||||||
|
|
||||||
const host = { id: 'host-1', assetId: 'ASSET-001', name: 'rack-1' };
|
|
||||||
|
|
||||||
function fmRow(overrides: Partial<Record<string, unknown>> = {}) {
|
|
||||||
return {
|
|
||||||
id: 'fm-1',
|
|
||||||
hostId: 'host-1',
|
|
||||||
status: 'OPEN',
|
|
||||||
problem: 'fans failing',
|
|
||||||
openedAt: new Date('2026-04-01T00:00:00Z'),
|
|
||||||
closedAt: null,
|
|
||||||
host: { ...host },
|
|
||||||
problemParts: [] as Array<{
|
|
||||||
partId: string;
|
|
||||||
part: { id: string; serialNumber: string; partModel: { mpn: string } };
|
|
||||||
}>,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
emitMock.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('fms.resolveHost', () => {
|
|
||||||
it('resolves by assetId when hostId is absent', async () => {
|
|
||||||
const findUnique = vi.fn(async (args: { where: { assetId?: string } }) => {
|
|
||||||
if (args.where.assetId === 'ASSET-001') return host;
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
const tx = { host: { findUnique } } as unknown as Tx;
|
|
||||||
|
|
||||||
const r = await resolveHost(tx, { assetId: 'ASSET-001' });
|
|
||||||
expect(r.id).toBe('host-1');
|
|
||||||
expect(findUnique).toHaveBeenCalledWith({ where: { assetId: 'ASSET-001' } });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects when neither hostId nor assetId is provided', async () => {
|
|
||||||
const tx = { host: { findUnique: vi.fn() } } as unknown as Tx;
|
|
||||||
await expect(resolveHost(tx, {})).rejects.toMatchObject({ status: 400 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws 404 when assetId does not match any host', async () => {
|
|
||||||
const tx = {
|
|
||||||
host: { findUnique: vi.fn(async () => null) },
|
|
||||||
} as unknown as Tx;
|
|
||||||
await expect(
|
|
||||||
resolveHost(tx, { assetId: 'MISSING' }),
|
|
||||||
).rejects.toMatchObject({ status: 404 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('fms.create', () => {
|
|
||||||
it('resolves host from assetId and writes the canonical hostId', async () => {
|
|
||||||
const created = fmRow();
|
|
||||||
const fmCreate = vi.fn();
|
|
||||||
fmCreate.mockResolvedValue(created);
|
|
||||||
const tx = {
|
|
||||||
host: {
|
|
||||||
findUnique: async (args: { where: { assetId?: string; id?: string } }) =>
|
|
||||||
args.where.assetId === 'ASSET-001' ? host : null,
|
|
||||||
},
|
|
||||||
fm: { create: fmCreate },
|
|
||||||
part: { findMany: async () => [] },
|
|
||||||
partEvent: { createMany: vi.fn() },
|
|
||||||
} as unknown as Tx;
|
|
||||||
|
|
||||||
await create(tx, { assetId: 'ASSET-001', problem: 'fans failing' }, actor);
|
|
||||||
|
|
||||||
const args = fmCreate.mock.calls[0]![0] as { data: { hostId: string } };
|
|
||||||
expect(args.data.hostId).toBe('host-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fires fm.opened webhook with the resolved host payload', async () => {
|
|
||||||
const created = fmRow();
|
|
||||||
const tx = {
|
|
||||||
host: { findUnique: async () => host },
|
|
||||||
fm: { create: async () => created },
|
|
||||||
part: { findMany: async () => [] },
|
|
||||||
partEvent: { createMany: vi.fn() },
|
|
||||||
} as unknown as Tx;
|
|
||||||
|
|
||||||
await create(tx, { hostId: 'host-1', problem: 'fans failing' }, actor);
|
|
||||||
|
|
||||||
expect(emitMock).toHaveBeenCalledTimes(1);
|
|
||||||
const call = emitMock.mock.calls[0]![0] as {
|
|
||||||
event: string;
|
|
||||||
payload: { fm: { id: string; assetId: string; status: string } };
|
|
||||||
};
|
|
||||||
expect(call.event).toBe('fm.opened');
|
|
||||||
expect(call.payload.fm.id).toBe('fm-1');
|
|
||||||
expect(call.payload.fm.assetId).toBe('ASSET-001');
|
|
||||||
expect(call.payload.fm.status).toBe('OPEN');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects when both hostId and assetId are provided', async () => {
|
|
||||||
// The shared-zod CreateFmRequest refine enforces XOR at the boundary; the service
|
|
||||||
// itself sees hostId first and resolves it. But if a caller passes both at the service
|
|
||||||
// layer (bypassing zod), hostId wins — we guard the boundary case in the shared test
|
|
||||||
// suite. Here we assert the combined-input path still fails cleanly when hostId is
|
|
||||||
// unknown, so the service never silently picks assetId.
|
|
||||||
const tx = {
|
|
||||||
host: { findUnique: async () => null },
|
|
||||||
fm: { create: vi.fn() },
|
|
||||||
partEvent: { createMany: vi.fn() },
|
|
||||||
} as unknown as Tx;
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
create(
|
|
||||||
tx,
|
|
||||||
{ hostId: '00000000-0000-0000-0000-000000000000', assetId: 'ASSET-001', problem: 'x' },
|
|
||||||
actor,
|
|
||||||
),
|
|
||||||
).rejects.toMatchObject({ status: 404 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates FM_OPENED PartEvents for each problem part', async () => {
|
|
||||||
const created = fmRow({
|
|
||||||
problemParts: [
|
|
||||||
{
|
|
||||||
partId: 'p-1',
|
|
||||||
part: { id: 'p-1', serialNumber: 'SN-1', partModel: { mpn: 'WD-1' } },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const partEventCreateMany = vi.fn();
|
|
||||||
const tx = {
|
|
||||||
host: { findUnique: async () => host },
|
|
||||||
part: {
|
|
||||||
findMany: async () => [{ id: 'p-1', hostId: 'host-1' }],
|
|
||||||
},
|
|
||||||
fm: { create: async () => created },
|
|
||||||
partEvent: { createMany: partEventCreateMany },
|
|
||||||
} as unknown as Tx;
|
|
||||||
|
|
||||||
await create(
|
|
||||||
tx,
|
|
||||||
{ hostId: 'host-1', problem: 'fans failing', problemPartIds: ['p-1'] },
|
|
||||||
actor,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(partEventCreateMany).toHaveBeenCalledTimes(1);
|
|
||||||
const args = partEventCreateMany.mock.calls[0]![0] as {
|
|
||||||
data: Array<{ partId: string; type: string; newValue: string }>;
|
|
||||||
};
|
|
||||||
expect(args.data).toEqual([
|
|
||||||
{ partId: 'p-1', userId: 'user-1', type: 'FM_OPENED', newValue: 'fm-1' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a problem part that does not live on the selected host', async () => {
|
|
||||||
const tx = {
|
|
||||||
host: { findUnique: async () => host },
|
|
||||||
part: {
|
|
||||||
findMany: async () => [{ id: 'p-1', hostId: 'other-host' }],
|
|
||||||
},
|
|
||||||
fm: { create: vi.fn() },
|
|
||||||
partEvent: { createMany: vi.fn() },
|
|
||||||
} as unknown as Tx;
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
create(
|
|
||||||
tx,
|
|
||||||
{ hostId: 'host-1', problem: 'x', problemPartIds: ['p-1'] },
|
|
||||||
actor,
|
|
||||||
),
|
|
||||||
).rejects.toMatchObject({ status: 400 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('fms.update — close flips status + sets closedAt + emits webhook', () => {
|
|
||||||
it('closes an OPEN FM and emits FM_CLOSED events per problem part', async () => {
|
|
||||||
const current = {
|
|
||||||
id: 'fm-1',
|
|
||||||
hostId: 'host-1',
|
|
||||||
status: 'OPEN',
|
|
||||||
problemParts: [{ partId: 'p-1' }, { partId: 'p-2' }],
|
|
||||||
host: { ...host },
|
|
||||||
};
|
|
||||||
const updated = fmRow({
|
|
||||||
status: 'CLOSED',
|
|
||||||
closedAt: new Date('2026-04-10T00:00:00Z'),
|
|
||||||
problemParts: [
|
|
||||||
{ partId: 'p-1', part: { id: 'p-1', serialNumber: 'SN-1', partModel: { mpn: 'WD-1' } } },
|
|
||||||
{ partId: 'p-2', part: { id: 'p-2', serialNumber: 'SN-2', partModel: { mpn: 'WD-2' } } },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const fmUpdate = vi.fn();
|
|
||||||
fmUpdate.mockResolvedValue(updated);
|
|
||||||
const partEventCreateMany = vi.fn();
|
|
||||||
const tx = {
|
|
||||||
fm: {
|
|
||||||
findUnique: async () => current,
|
|
||||||
update: fmUpdate,
|
|
||||||
},
|
|
||||||
partEvent: { createMany: partEventCreateMany },
|
|
||||||
} as unknown as Tx;
|
|
||||||
|
|
||||||
await update(tx, 'fm-1', { status: 'CLOSED' }, actor);
|
|
||||||
|
|
||||||
const updateArgs = fmUpdate.mock.calls[0]![0] as {
|
|
||||||
data: { status?: string; closedAt?: unknown };
|
|
||||||
};
|
|
||||||
expect(updateArgs.data.status).toBe('CLOSED');
|
|
||||||
expect(updateArgs.data.closedAt).toBeInstanceOf(Date);
|
|
||||||
|
|
||||||
const eventArgs = partEventCreateMany.mock.calls[0]![0] as {
|
|
||||||
data: Array<{ partId: string; type: string }>;
|
|
||||||
};
|
|
||||||
expect(eventArgs.data).toEqual([
|
|
||||||
{ partId: 'p-1', userId: 'user-1', type: 'FM_CLOSED', newValue: 'fm-1' },
|
|
||||||
{ partId: 'p-2', userId: 'user-1', type: 'FM_CLOSED', newValue: 'fm-1' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(emitMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(emitMock.mock.calls[0]![0]).toMatchObject({
|
|
||||||
event: 'fm.closed',
|
|
||||||
payload: { fm: { id: 'fm-1', status: 'CLOSED' } },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reopening a closed FM clears closedAt and re-emits fm.opened', async () => {
|
|
||||||
const current = {
|
|
||||||
id: 'fm-1',
|
|
||||||
hostId: 'host-1',
|
|
||||||
status: 'CLOSED',
|
|
||||||
problemParts: [],
|
|
||||||
host: { ...host },
|
|
||||||
};
|
|
||||||
const updated = fmRow({ status: 'OPEN', closedAt: null });
|
|
||||||
const fmUpdate = vi.fn();
|
|
||||||
fmUpdate.mockResolvedValue(updated);
|
|
||||||
const tx = {
|
|
||||||
fm: { findUnique: async () => current, update: fmUpdate },
|
|
||||||
partEvent: { createMany: vi.fn() },
|
|
||||||
} as unknown as Tx;
|
|
||||||
|
|
||||||
await update(tx, 'fm-1', { status: 'OPEN' }, actor);
|
|
||||||
|
|
||||||
const args = fmUpdate.mock.calls[0]![0] as {
|
|
||||||
data: { status?: string; closedAt?: unknown };
|
|
||||||
};
|
|
||||||
expect(args.data.status).toBe('OPEN');
|
|
||||||
expect(args.data.closedAt).toBeNull();
|
|
||||||
expect(emitMock.mock.calls[0]![0]).toMatchObject({ event: 'fm.opened' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('status-unchanged updates do not emit webhooks', async () => {
|
|
||||||
const current = {
|
|
||||||
id: 'fm-1',
|
|
||||||
hostId: 'host-1',
|
|
||||||
status: 'OPEN',
|
|
||||||
problemParts: [],
|
|
||||||
host: { ...host },
|
|
||||||
};
|
|
||||||
const tx = {
|
|
||||||
fm: {
|
|
||||||
findUnique: async () => current,
|
|
||||||
update: async () => fmRow({ problem: 'new text' }),
|
|
||||||
},
|
|
||||||
partEvent: { createMany: vi.fn() },
|
|
||||||
} as unknown as Tx;
|
|
||||||
|
|
||||||
await update(tx, 'fm-1', { problem: 'new text' }, actor);
|
|
||||||
|
|
||||||
expect(emitMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
import { Prisma } from '@vector/db';
|
|
||||||
import type { CreateFmRequest, FmListQuery, UpdateFmRequest } from '@vector/shared';
|
|
||||||
import { errors } from '../lib/http-error.js';
|
|
||||||
import { emit } from '../lib/webhook-emitter.js';
|
|
||||||
import type { Actor, Tx } from './types.js';
|
|
||||||
|
|
||||||
const fmInclude = {
|
|
||||||
host: true,
|
|
||||||
problemParts: {
|
|
||||||
include: {
|
|
||||||
part: {
|
|
||||||
include: { partModel: true, manufacturer: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} satisfies Prisma.FmInclude;
|
|
||||||
|
|
||||||
export type FmWithRelations = Prisma.FmGetPayload<{ include: typeof fmInclude }>;
|
|
||||||
|
|
||||||
// Accept either `hostId` (uuid) or `assetId` (string) — callers provide exactly one.
|
|
||||||
// Returns the resolved Host row so downstream writes can use the canonical id.
|
|
||||||
export async function resolveHost(
|
|
||||||
tx: Tx,
|
|
||||||
input: { hostId?: string | null; assetId?: string | null },
|
|
||||||
) {
|
|
||||||
if (input.hostId) {
|
|
||||||
const host = await tx.host.findUnique({ where: { id: input.hostId } });
|
|
||||||
if (!host) throw errors.notFound('Host');
|
|
||||||
return host;
|
|
||||||
}
|
|
||||||
if (input.assetId) {
|
|
||||||
const host = await tx.host.findUnique({ where: { assetId: input.assetId } });
|
|
||||||
if (!host) throw errors.notFound('Host');
|
|
||||||
return host;
|
|
||||||
}
|
|
||||||
throw errors.badRequest('Provide exactly one of hostId or assetId');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function validateProblemParts(tx: Tx, hostId: string, partIds: string[] | undefined) {
|
|
||||||
if (!partIds || partIds.length === 0) return;
|
|
||||||
const uniqueIds = [...new Set(partIds)];
|
|
||||||
const rows = await tx.part.findMany({
|
|
||||||
where: { id: { in: uniqueIds } },
|
|
||||||
select: { id: true, hostId: true },
|
|
||||||
});
|
|
||||||
const found = new Map(rows.map((r) => [r.id, r]));
|
|
||||||
for (const id of uniqueIds) {
|
|
||||||
const row = found.get(id);
|
|
||||||
if (!row) throw errors.badRequest(`Part ${id} does not exist`);
|
|
||||||
if (row.hostId !== hostId) {
|
|
||||||
throw errors.badRequest(`Part ${id} is not on the selected host`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function list(tx: Tx, q: FmListQuery) {
|
|
||||||
const { page, pageSize, status, hostId, openOnly, problemPartId } = q;
|
|
||||||
const where: Prisma.FmWhereInput = {};
|
|
||||||
if (status) where.status = status;
|
|
||||||
if (hostId) where.hostId = hostId;
|
|
||||||
if (openOnly) where.status = 'OPEN';
|
|
||||||
if (problemPartId) where.problemParts = { some: { partId: problemPartId } };
|
|
||||||
|
|
||||||
const [data, total] = await Promise.all([
|
|
||||||
tx.fm.findMany({
|
|
||||||
where,
|
|
||||||
orderBy: [{ status: 'asc' }, { openedAt: 'desc' }],
|
|
||||||
include: fmInclude,
|
|
||||||
skip: (page - 1) * pageSize,
|
|
||||||
take: pageSize,
|
|
||||||
}),
|
|
||||||
tx.fm.count({ where }),
|
|
||||||
]);
|
|
||||||
return { data, page, pageSize, total };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function get(tx: Tx, id: string) {
|
|
||||||
return tx.fm.findUnique({ where: { id }, include: fmInclude });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listForHost(tx: Tx, hostId: string) {
|
|
||||||
return tx.fm.findMany({
|
|
||||||
where: { hostId },
|
|
||||||
orderBy: { openedAt: 'desc' },
|
|
||||||
include: fmInclude,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmPayload(fm: FmWithRelations) {
|
|
||||||
return {
|
|
||||||
id: fm.id,
|
|
||||||
hostId: fm.hostId,
|
|
||||||
assetId: fm.host.assetId,
|
|
||||||
hostName: fm.host.name,
|
|
||||||
status: fm.status,
|
|
||||||
problem: fm.problem,
|
|
||||||
openedAt: fm.openedAt.toISOString(),
|
|
||||||
closedAt: fm.closedAt?.toISOString() ?? null,
|
|
||||||
problemParts: fm.problemParts.map((pp) => ({
|
|
||||||
partId: pp.part.id,
|
|
||||||
serialNumber: pp.part.serialNumber,
|
|
||||||
mpn: pp.part.partModel.mpn,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function create(
|
|
||||||
tx: Tx,
|
|
||||||
input: CreateFmRequest,
|
|
||||||
_actor: Actor | null,
|
|
||||||
) {
|
|
||||||
const host = await resolveHost(tx, input);
|
|
||||||
await validateProblemParts(tx, host.id, input.problemPartIds);
|
|
||||||
|
|
||||||
const fm = await tx.fm.create({
|
|
||||||
data: {
|
|
||||||
hostId: host.id,
|
|
||||||
problem: input.problem,
|
|
||||||
status: 'OPEN',
|
|
||||||
problemParts:
|
|
||||||
input.problemPartIds && input.problemPartIds.length > 0
|
|
||||||
? { create: [...new Set(input.problemPartIds)].map((partId) => ({ partId })) }
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
include: fmInclude,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (input.problemPartIds && input.problemPartIds.length > 0) {
|
|
||||||
await tx.partEvent.createMany({
|
|
||||||
data: [...new Set(input.problemPartIds)].map((partId) => ({
|
|
||||||
partId,
|
|
||||||
userId: _actor?.id ?? null,
|
|
||||||
type: 'FM_OPENED',
|
|
||||||
newValue: fm.id,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fire-and-forget. Emitter owns retries + signing.
|
|
||||||
void emit({ event: 'fm.opened', payload: { fm: fmPayload(fm) } });
|
|
||||||
return fm;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function update(
|
|
||||||
tx: Tx,
|
|
||||||
id: string,
|
|
||||||
input: UpdateFmRequest,
|
|
||||||
actor: Actor | null,
|
|
||||||
) {
|
|
||||||
const current = await tx.fm.findUnique({
|
|
||||||
where: { id },
|
|
||||||
include: { problemParts: { select: { partId: true } }, host: true },
|
|
||||||
});
|
|
||||||
if (!current) throw errors.notFound('FM');
|
|
||||||
|
|
||||||
const data: Prisma.FmUpdateInput = {};
|
|
||||||
let closing = false;
|
|
||||||
let reopening = false;
|
|
||||||
if (input.status !== undefined && input.status !== current.status) {
|
|
||||||
data.status = input.status;
|
|
||||||
if (input.status === 'CLOSED') {
|
|
||||||
data.closedAt = new Date();
|
|
||||||
closing = true;
|
|
||||||
} else {
|
|
||||||
data.closedAt = null;
|
|
||||||
reopening = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (input.problem !== undefined) data.problem = input.problem;
|
|
||||||
|
|
||||||
let addedPartIds: string[] = [];
|
|
||||||
if (input.problemPartIds !== undefined) {
|
|
||||||
await validateProblemParts(tx, current.hostId, input.problemPartIds);
|
|
||||||
const existing = new Set(current.problemParts.map((p) => p.partId));
|
|
||||||
const desired = new Set(input.problemPartIds);
|
|
||||||
addedPartIds = [...desired].filter((p) => !existing.has(p));
|
|
||||||
const removed = [...existing].filter((p) => !desired.has(p));
|
|
||||||
if (removed.length > 0) {
|
|
||||||
await tx.fmPart.deleteMany({ where: { fmId: id, partId: { in: removed } } });
|
|
||||||
}
|
|
||||||
if (addedPartIds.length > 0) {
|
|
||||||
await tx.fmPart.createMany({
|
|
||||||
data: addedPartIds.map((partId) => ({ fmId: id, partId })),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fm = await tx.fm.update({ where: { id }, data, include: fmInclude });
|
|
||||||
|
|
||||||
const userId = actor?.id ?? null;
|
|
||||||
if (addedPartIds.length > 0) {
|
|
||||||
await tx.partEvent.createMany({
|
|
||||||
data: addedPartIds.map((partId) => ({
|
|
||||||
partId,
|
|
||||||
userId,
|
|
||||||
type: 'FM_OPENED',
|
|
||||||
newValue: fm.id,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (closing) {
|
|
||||||
const partIds = fm.problemParts.map((p) => p.partId);
|
|
||||||
if (partIds.length > 0) {
|
|
||||||
await tx.partEvent.createMany({
|
|
||||||
data: partIds.map((partId) => ({
|
|
||||||
partId,
|
|
||||||
userId,
|
|
||||||
type: 'FM_CLOSED',
|
|
||||||
newValue: fm.id,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
void emit({ event: 'fm.closed', payload: { fm: fmPayload(fm) } });
|
|
||||||
}
|
|
||||||
if (reopening) {
|
|
||||||
void emit({ event: 'fm.opened', payload: { fm: fmPayload(fm) } });
|
|
||||||
}
|
|
||||||
|
|
||||||
return fm;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function remove(tx: Tx, id: string) {
|
|
||||||
try {
|
|
||||||
await tx.fm.delete({ where: { id } });
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
|
|
||||||
throw errors.notFound('FM');
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -192,7 +192,7 @@ describe('hosts.update', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('hosts.getTimeline', () => {
|
describe('hosts.getTimeline', () => {
|
||||||
it('merges HostEvents, Fms, Repairs, PartEvents in reverse-chronological order', async () => {
|
it('merges HostEvents, Repairs, PartEvents in reverse-chronological order', async () => {
|
||||||
const hostId = 'host-1';
|
const hostId = 'host-1';
|
||||||
const hostName = 'Vela';
|
const hostName = 'Vela';
|
||||||
|
|
||||||
@@ -216,17 +216,6 @@ describe('hosts.getTimeline', () => {
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
fm: {
|
|
||||||
findMany: vi.fn(async () => [
|
|
||||||
{
|
|
||||||
id: 'fm-1',
|
|
||||||
status: 'OPEN',
|
|
||||||
problem: 'bad disk',
|
|
||||||
openedAt: t(30),
|
|
||||||
closedAt: null,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
repair: {
|
repair: {
|
||||||
findMany: vi.fn(async () => [
|
findMany: vi.fn(async () => [
|
||||||
{
|
{
|
||||||
@@ -253,43 +242,8 @@ describe('hosts.getTimeline', () => {
|
|||||||
|
|
||||||
const result = await getTimeline(tx, hostId, { page: 1, pageSize: 20 });
|
const result = await getTimeline(tx, hostId, { page: 1, pageSize: 20 });
|
||||||
|
|
||||||
expect(result.total).toBe(3);
|
|
||||||
expect(result.data.map((e) => e.type)).toEqual([
|
|
||||||
'HOST_EVENT',
|
|
||||||
'REPAIR',
|
|
||||||
'FM_OPENED',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('emits FM_CLOSED only when closedAt is set', async () => {
|
|
||||||
const hostId = 'host-1';
|
|
||||||
const hostName = 'Vela';
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
const tx = {
|
|
||||||
host: {
|
|
||||||
findUnique: vi.fn(async () => ({ id: hostId, name: hostName })),
|
|
||||||
},
|
|
||||||
hostEvent: { findMany: vi.fn(async () => []) },
|
|
||||||
fm: {
|
|
||||||
findMany: vi.fn(async () => [
|
|
||||||
{
|
|
||||||
id: 'fm-1',
|
|
||||||
status: 'CLOSED',
|
|
||||||
problem: 'p',
|
|
||||||
openedAt: new Date(now - 60_000),
|
|
||||||
closedAt: new Date(now - 30_000),
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
repair: { findMany: vi.fn(async () => []) },
|
|
||||||
partEvent: { findMany: vi.fn(async () => []) },
|
|
||||||
} as unknown as Tx;
|
|
||||||
|
|
||||||
const result = await getTimeline(tx, hostId, { page: 1, pageSize: 20 });
|
|
||||||
|
|
||||||
expect(result.total).toBe(2);
|
expect(result.total).toBe(2);
|
||||||
expect(result.data.map((e) => e.type)).toEqual(['FM_CLOSED', 'FM_OPENED']);
|
expect(result.data.map((e) => e.type)).toEqual(['HOST_EVENT', 'REPAIR']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('classifies PartEvents as ARRIVED/DEPARTED by host name match', async () => {
|
it('classifies PartEvents as ARRIVED/DEPARTED by host name match', async () => {
|
||||||
@@ -302,7 +256,6 @@ describe('hosts.getTimeline', () => {
|
|||||||
findUnique: vi.fn(async () => ({ id: hostId, name: hostName })),
|
findUnique: vi.fn(async () => ({ id: hostId, name: hostName })),
|
||||||
},
|
},
|
||||||
hostEvent: { findMany: vi.fn(async () => []) },
|
hostEvent: { findMany: vi.fn(async () => []) },
|
||||||
fm: { findMany: vi.fn(async () => []) },
|
|
||||||
repair: { findMany: vi.fn(async () => []) },
|
repair: { findMany: vi.fn(async () => []) },
|
||||||
partEvent: {
|
partEvent: {
|
||||||
findMany: vi.fn(async () => [
|
findMany: vi.fn(async () => [
|
||||||
|
|||||||
@@ -13,6 +13,25 @@ function mapUniqueViolation(target: unknown): string {
|
|||||||
return 'Host name already exists';
|
return 'Host name already exists';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Accept either `hostId` (uuid) or `assetId` (string) — callers provide exactly one.
|
||||||
|
// Returns the resolved Host row so downstream writes can use the canonical id.
|
||||||
|
export async function resolveHost(
|
||||||
|
tx: Tx,
|
||||||
|
input: { hostId?: string | null; assetId?: string | null },
|
||||||
|
) {
|
||||||
|
if (input.hostId) {
|
||||||
|
const host = await tx.host.findUnique({ where: { id: input.hostId } });
|
||||||
|
if (!host) throw errors.notFound('Host');
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
if (input.assetId) {
|
||||||
|
const host = await tx.host.findUnique({ where: { assetId: input.assetId } });
|
||||||
|
if (!host) throw errors.notFound('Host');
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
throw errors.badRequest('Provide exactly one of hostId or assetId');
|
||||||
|
}
|
||||||
|
|
||||||
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 = {};
|
const where: Prisma.HostWhereInput = {};
|
||||||
@@ -202,20 +221,16 @@ export async function remove(tx: Tx, id: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unified host timeline. Merges four sources:
|
// Unified host timeline. Merges three sources:
|
||||||
// - HostEvents (state/stack/field changes on the host)
|
// - HostEvents (state/stack/field changes on the host)
|
||||||
// - Fms (FM_OPENED at openedAt, FM_CLOSED at closedAt when present)
|
|
||||||
// - Repairs on this host (captures broken/replacement part swaps)
|
// - Repairs on this host (captures broken/replacement part swaps)
|
||||||
// - PartEvents where a part's host field changed to or from this host
|
// - PartEvents where a part's host field changed to or from this host
|
||||||
// (covers ad-hoc arrivals/departures outside the repair flow).
|
// (covers ad-hoc arrivals/departures outside the repair flow).
|
||||||
//
|
//
|
||||||
// The four sources are merged in memory and paginated after the sort; the resulting page
|
// Sources are merged in memory and paginated after the sort; the resulting page
|
||||||
// will be small because we cap each source fetch at a safe upper bound. This avoids the
|
// will be small because we cap each source fetch at a safe upper bound.
|
||||||
// complexity of a UNION query while still giving correct reverse-chronological ordering.
|
|
||||||
export type HostTimelineEntry =
|
export type HostTimelineEntry =
|
||||||
| { type: 'HOST_EVENT'; at: Date; hostEvent: HostEventPayload }
|
| { type: 'HOST_EVENT'; at: Date; hostEvent: HostEventPayload }
|
||||||
| { type: 'FM_OPENED'; at: Date; fm: FmSummary }
|
|
||||||
| { type: 'FM_CLOSED'; at: Date; fm: FmSummary }
|
|
||||||
| { type: 'REPAIR'; at: Date; repair: RepairSummary }
|
| { type: 'REPAIR'; at: Date; repair: RepairSummary }
|
||||||
| { type: 'PART_ARRIVED'; at: Date; part: PartRef; partEventId: string }
|
| { type: 'PART_ARRIVED'; at: Date; part: PartRef; partEventId: string }
|
||||||
| { type: 'PART_DEPARTED'; at: Date; part: PartRef; partEventId: string };
|
| { type: 'PART_DEPARTED'; at: Date; part: PartRef; partEventId: string };
|
||||||
@@ -230,14 +245,6 @@ interface HostEventPayload {
|
|||||||
user: { username: string } | null;
|
user: { username: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FmSummary {
|
|
||||||
id: string;
|
|
||||||
status: string;
|
|
||||||
problem: string;
|
|
||||||
openedAt: Date;
|
|
||||||
closedAt: Date | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RepairSummary {
|
interface RepairSummary {
|
||||||
id: string;
|
id: string;
|
||||||
performedAt: Date;
|
performedAt: Date;
|
||||||
@@ -264,18 +271,13 @@ export async function getTimeline(tx: Tx, hostId: string, q: HostTimelineQuery)
|
|||||||
if (!host) throw errors.notFound('Host');
|
if (!host) throw errors.notFound('Host');
|
||||||
|
|
||||||
// PartEvent stores the host's name in oldValue/newValue (see parts.ts), not the id.
|
// PartEvent stores the host's name in oldValue/newValue (see parts.ts), not the id.
|
||||||
const [hostEvents, fms, repairs, partEventRows] = await Promise.all([
|
const [hostEvents, repairs, partEventRows] = await Promise.all([
|
||||||
tx.hostEvent.findMany({
|
tx.hostEvent.findMany({
|
||||||
where: { hostId },
|
where: { hostId },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: TIMELINE_SOURCE_CAP,
|
take: TIMELINE_SOURCE_CAP,
|
||||||
include: { user: { select: { username: true } } },
|
include: { user: { select: { username: true } } },
|
||||||
}),
|
}),
|
||||||
tx.fm.findMany({
|
|
||||||
where: { hostId },
|
|
||||||
orderBy: { openedAt: 'desc' },
|
|
||||||
take: TIMELINE_SOURCE_CAP,
|
|
||||||
}),
|
|
||||||
tx.repair.findMany({
|
tx.repair.findMany({
|
||||||
where: { hostId },
|
where: { hostId },
|
||||||
orderBy: { performedAt: 'desc' },
|
orderBy: { performedAt: 'desc' },
|
||||||
@@ -319,17 +321,6 @@ export async function getTimeline(tx: Tx, hostId: string, q: HostTimelineQuery)
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const f of fms) {
|
|
||||||
const summary: FmSummary = {
|
|
||||||
id: f.id,
|
|
||||||
status: f.status,
|
|
||||||
problem: f.problem,
|
|
||||||
openedAt: f.openedAt,
|
|
||||||
closedAt: f.closedAt,
|
|
||||||
};
|
|
||||||
entries.push({ type: 'FM_OPENED', at: f.openedAt, fm: summary });
|
|
||||||
if (f.closedAt) entries.push({ type: 'FM_CLOSED', at: f.closedAt, fm: summary });
|
|
||||||
}
|
|
||||||
for (const r of repairs) {
|
for (const r of repairs) {
|
||||||
entries.push({
|
entries.push({
|
||||||
type: 'REPAIR',
|
type: 'REPAIR',
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ function partRow(overrides: Partial<Record<string, unknown>>) {
|
|||||||
function buildTx(options: {
|
function buildTx(options: {
|
||||||
parts: Array<ReturnType<typeof partRow>>;
|
parts: Array<ReturnType<typeof partRow>>;
|
||||||
hosts: Array<{ id: string; assetId: string; name: string }>;
|
hosts: Array<{ id: string; assetId: string; name: string }>;
|
||||||
fm?: { id: string; hostId: string } | null;
|
|
||||||
existingPartModel?: { id: string; manufacturerId: string; mpn: string } | null;
|
existingPartModel?: { id: string; manufacturerId: string; mpn: string } | null;
|
||||||
}) {
|
}) {
|
||||||
const registry = new Map(options.parts.map((p) => [p.id, p]));
|
const registry = new Map(options.parts.map((p) => [p.id, p]));
|
||||||
@@ -167,12 +166,6 @@ function buildTx(options: {
|
|||||||
eolDate: null,
|
eolDate: null,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
fm: {
|
|
||||||
findUnique: async (args: { where: { id: string } }) => {
|
|
||||||
if (options.fm && options.fm.id === args.where.id) return options.fm;
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
repair: {
|
repair: {
|
||||||
create: vi.fn(async (args: { data: Record<string, unknown> }) => ({
|
create: vi.fn(async (args: { data: Record<string, unknown> }) => ({
|
||||||
id: 'repair-1',
|
id: 'repair-1',
|
||||||
@@ -180,13 +173,11 @@ function buildTx(options: {
|
|||||||
brokenPartId: args.data.brokenPartId,
|
brokenPartId: args.data.brokenPartId,
|
||||||
replacementPartId: args.data.replacementPartId,
|
replacementPartId: args.data.replacementPartId,
|
||||||
performedById: args.data.performedById,
|
performedById: args.data.performedById,
|
||||||
fmId: args.data.fmId ?? null,
|
|
||||||
performedAt: new Date('2026-04-15T00:00:00Z'),
|
performedAt: new Date('2026-04-15T00:00:00Z'),
|
||||||
host: options.hosts.find((h) => h.id === args.data.hostId) ?? host1,
|
host: options.hosts.find((h) => h.id === args.data.hostId) ?? host1,
|
||||||
brokenPart: registry.get(args.data.brokenPartId as string),
|
brokenPart: registry.get(args.data.brokenPartId as string),
|
||||||
replacement: registry.get(args.data.replacementPartId as string),
|
replacement: registry.get(args.data.replacementPartId as string),
|
||||||
performedBy: { id: actor.id, username: actor.username },
|
performedBy: { id: actor.id, username: actor.username },
|
||||||
fm: options.fm ? { id: options.fm.id, status: 'OPEN' } : null,
|
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
partEvent: {
|
partEvent: {
|
||||||
@@ -433,87 +424,6 @@ describe('repairs.log — validation failures', () => {
|
|||||||
).rejects.toMatchObject({ status: 400 });
|
).rejects.toMatchObject({ status: 400 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects when fmId belongs to a different host', async () => {
|
|
||||||
const broken = partRow({
|
|
||||||
id: 'p-broken',
|
|
||||||
serialNumber: 'SN-BROKEN',
|
|
||||||
partModelId: brokenModel.id,
|
|
||||||
state: 'DEPLOYED',
|
|
||||||
hostId: 'host-1',
|
|
||||||
host: host1,
|
|
||||||
partModel: brokenModel,
|
|
||||||
});
|
|
||||||
const replacement = partRow({
|
|
||||||
id: 'p-replacement',
|
|
||||||
serialNumber: 'SN-REPLACE',
|
|
||||||
partModelId: replacementModel.id,
|
|
||||||
state: 'SPARE',
|
|
||||||
partModel: replacementModel,
|
|
||||||
});
|
|
||||||
const { tx } = buildTx({
|
|
||||||
parts: [broken, replacement],
|
|
||||||
hosts: [host1, host2],
|
|
||||||
fm: { id: 'fm-other', hostId: 'host-2' },
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
log(
|
|
||||||
tx,
|
|
||||||
{
|
|
||||||
hostId: 'host-1',
|
|
||||||
brokenSerial: 'SN-BROKEN',
|
|
||||||
brokenMpn: 'WD-BROKEN',
|
|
||||||
brokenManufacturerId: 'mfr-1',
|
|
||||||
replacementSerial: 'SN-REPLACE',
|
|
||||||
fmId: 'fm-other',
|
|
||||||
},
|
|
||||||
actor,
|
|
||||||
),
|
|
||||||
).rejects.toMatchObject({ status: 400 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts a matching fmId and does NOT auto-close the FM', async () => {
|
|
||||||
const broken = partRow({
|
|
||||||
id: 'p-broken',
|
|
||||||
serialNumber: 'SN-BROKEN',
|
|
||||||
partModelId: brokenModel.id,
|
|
||||||
state: 'DEPLOYED',
|
|
||||||
hostId: 'host-1',
|
|
||||||
host: host1,
|
|
||||||
partModel: brokenModel,
|
|
||||||
});
|
|
||||||
const replacement = partRow({
|
|
||||||
id: 'p-replacement',
|
|
||||||
serialNumber: 'SN-REPLACE',
|
|
||||||
partModelId: replacementModel.id,
|
|
||||||
state: 'SPARE',
|
|
||||||
partModel: replacementModel,
|
|
||||||
});
|
|
||||||
const { tx } = buildTx({
|
|
||||||
parts: [broken, replacement],
|
|
||||||
hosts: [host1],
|
|
||||||
fm: { id: 'fm-1', hostId: 'host-1' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const r = await log(
|
|
||||||
tx,
|
|
||||||
{
|
|
||||||
hostId: 'host-1',
|
|
||||||
brokenSerial: 'SN-BROKEN',
|
|
||||||
brokenMpn: 'WD-BROKEN',
|
|
||||||
brokenManufacturerId: 'mfr-1',
|
|
||||||
replacementSerial: 'SN-REPLACE',
|
|
||||||
fmId: 'fm-1',
|
|
||||||
},
|
|
||||||
actor,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(r.fmId).toBe('fm-1');
|
|
||||||
// Only the repair.logged webhook fires — no fm.closed.
|
|
||||||
const events = emitMock.mock.calls.map((c) => (c[0] as { event: string }).event);
|
|
||||||
expect(events).toEqual(['repair.logged']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts a PENDING_REPAIR replacement held by the actor', async () => {
|
it('accepts a PENDING_REPAIR replacement held by the actor', async () => {
|
||||||
const broken = partRow({
|
const broken = partRow({
|
||||||
id: 'p-broken',
|
id: 'p-broken',
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { errors } from '../lib/http-error.js';
|
|||||||
import { emit } from '../lib/webhook-emitter.js';
|
import { emit } from '../lib/webhook-emitter.js';
|
||||||
import * as partsSvc from './parts.js';
|
import * as partsSvc from './parts.js';
|
||||||
import * as partModelsSvc from './part-models.js';
|
import * as partModelsSvc from './part-models.js';
|
||||||
import { resolveHost } from './fms.js';
|
import { resolveHost } from './hosts.js';
|
||||||
import type { Actor, Tx } from './types.js';
|
import type { Actor, Tx } from './types.js';
|
||||||
|
|
||||||
// A Repair is the persistent log of a physical part swap on a host. The tech enters the broken
|
// A Repair is the persistent log of a physical part swap on a host. The tech enters the broken
|
||||||
@@ -15,7 +15,6 @@ const repairInclude = {
|
|||||||
brokenPart: { include: { partModel: true, manufacturer: true } },
|
brokenPart: { include: { partModel: true, manufacturer: true } },
|
||||||
replacement: { include: { partModel: true, manufacturer: true } },
|
replacement: { include: { partModel: true, manufacturer: true } },
|
||||||
performedBy: { select: { id: true, username: true } },
|
performedBy: { select: { id: true, username: true } },
|
||||||
fm: { select: { id: true, status: true } },
|
|
||||||
} satisfies Prisma.RepairInclude;
|
} satisfies Prisma.RepairInclude;
|
||||||
|
|
||||||
export type RepairWithRelations = Prisma.RepairGetPayload<{ include: typeof repairInclude }>;
|
export type RepairWithRelations = Prisma.RepairGetPayload<{ include: typeof repairInclude }>;
|
||||||
@@ -24,7 +23,6 @@ function buildWhere(q: RepairListQuery): Prisma.RepairWhereInput {
|
|||||||
const where: Prisma.RepairWhereInput = {};
|
const where: Prisma.RepairWhereInput = {};
|
||||||
if (q.hostId) where.hostId = q.hostId;
|
if (q.hostId) where.hostId = q.hostId;
|
||||||
if (q.performedById) where.performedById = q.performedById;
|
if (q.performedById) where.performedById = q.performedById;
|
||||||
if (q.fmId) where.fmId = q.fmId;
|
|
||||||
if (q.since) where.performedAt = { gte: new Date(q.since) };
|
if (q.since) where.performedAt = { gte: new Date(q.since) };
|
||||||
return where;
|
return where;
|
||||||
}
|
}
|
||||||
@@ -67,7 +65,6 @@ function repairPayload(r: RepairWithRelations) {
|
|||||||
},
|
},
|
||||||
performedBy: r.performedBy,
|
performedBy: r.performedBy,
|
||||||
performedAt: r.performedAt.toISOString(),
|
performedAt: r.performedAt.toISOString(),
|
||||||
fmId: r.fmId,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,21 +140,12 @@ export async function log(
|
|||||||
broken = created;
|
broken = created;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Optional FM link — must belong to the same host; we do NOT auto-close it.
|
// 3. Custody state is driven by the broken model's destroyOnFail flag.
|
||||||
if (input.fmId) {
|
|
||||||
const fm = await tx.fm.findUnique({ where: { id: input.fmId } });
|
|
||||||
if (!fm) throw errors.badRequest('FM does not exist');
|
|
||||||
if (fm.hostId !== host.id) {
|
|
||||||
throw errors.badRequest('FM is on a different host than the repair');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Custody state is driven by the broken model's destroyOnFail flag.
|
|
||||||
const custodyState = broken.partModel.destroyOnFail
|
const custodyState = broken.partModel.destroyOnFail
|
||||||
? 'PENDING_DESTRUCTION_IN_CUSTODY'
|
? 'PENDING_DESTRUCTION_IN_CUSTODY'
|
||||||
: 'PENDING_DROP_IN_CUSTODY';
|
: 'PENDING_DROP_IN_CUSTODY';
|
||||||
|
|
||||||
// 5. Transition both parts through the standard parts.update machinery so every state
|
// 4. Transition both parts through the standard parts.update machinery so every state
|
||||||
// and location change emits the usual PartEvents. The resolver clears host/bin
|
// and location change emits the usual PartEvents. The resolver clears host/bin
|
||||||
// automatically when entering custody / DEPLOYED.
|
// automatically when entering custody / DEPLOYED.
|
||||||
await partsSvc.update(
|
await partsSvc.update(
|
||||||
@@ -173,19 +161,18 @@ export async function log(
|
|||||||
actor,
|
actor,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 6. Persist the Repair row.
|
// 5. Persist the Repair row.
|
||||||
const repair = await tx.repair.create({
|
const repair = await tx.repair.create({
|
||||||
data: {
|
data: {
|
||||||
hostId: host.id,
|
hostId: host.id,
|
||||||
brokenPartId: broken.id,
|
brokenPartId: broken.id,
|
||||||
replacementPartId: replacement.id,
|
replacementPartId: replacement.id,
|
||||||
performedById: actor.id,
|
performedById: actor.id,
|
||||||
fmId: input.fmId ?? null,
|
|
||||||
},
|
},
|
||||||
include: repairInclude,
|
include: repairInclude,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 7. Swap event on each part — so the part timeline shows the repair link.
|
// 6. Swap event on each part — so the part timeline shows the repair link.
|
||||||
await tx.partEvent.createMany({
|
await tx.partEvent.createMany({
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ import ManufacturerDetail from './pages/ManufacturerDetail.js';
|
|||||||
import PartModels from './pages/PartModels.js';
|
import PartModels from './pages/PartModels.js';
|
||||||
import PartModelDetail from './pages/PartModelDetail.js';
|
import PartModelDetail from './pages/PartModelDetail.js';
|
||||||
import CategoryDetail from './pages/CategoryDetail.js';
|
import CategoryDetail from './pages/CategoryDetail.js';
|
||||||
import Fms from './pages/Fms.js';
|
|
||||||
import FmDetail from './pages/FmDetail.js';
|
|
||||||
import Repairs from './pages/Repairs.js';
|
import Repairs from './pages/Repairs.js';
|
||||||
import MyCustody from './pages/MyCustody.js';
|
import MyCustody from './pages/MyCustody.js';
|
||||||
import Hosts from './pages/Hosts.js';
|
import Hosts from './pages/Hosts.js';
|
||||||
@@ -68,8 +66,6 @@ export default function App() {
|
|||||||
<Route path="/part-models" element={<PartModels />} />
|
<Route path="/part-models" element={<PartModels />} />
|
||||||
<Route path="/part-models/:id" element={<PartModelDetail />} />
|
<Route path="/part-models/:id" element={<PartModelDetail />} />
|
||||||
<Route path="/categories/:id" element={<CategoryDetail />} />
|
<Route path="/categories/:id" element={<CategoryDetail />} />
|
||||||
<Route path="/fms" element={<Fms />} />
|
|
||||||
<Route path="/fms/:id" element={<FmDetail />} />
|
|
||||||
<Route path="/repairs" element={<Repairs />} />
|
<Route path="/repairs" element={<Repairs />} />
|
||||||
<Route path="/custody" element={<MyCustody />} />
|
<Route path="/custody" element={<MyCustody />} />
|
||||||
<Route path="/hosts" element={<Hosts />} />
|
<Route path="/hosts" element={<Hosts />} />
|
||||||
|
|||||||
@@ -1,253 +0,0 @@
|
|||||||
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,
|
|
||||||
Checkbox,
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
Skeleton,
|
|
||||||
Textarea,
|
|
||||||
} from '@vector/ui';
|
|
||||||
import { createFm } from '../../lib/api/fms.js';
|
|
||||||
import { listHosts, listHostDeployedParts } from '../../lib/api/hosts.js';
|
|
||||||
import { ApiRequestError } from '../../lib/api/client.js';
|
|
||||||
import { queryKeys } from '../../lib/queryKeys.js';
|
|
||||||
import type { Fm } from '../../lib/api/types.js';
|
|
||||||
|
|
||||||
const CreateSchema = z.object({
|
|
||||||
hostId: z.string().uuid('Pick a host'),
|
|
||||||
problem: z.string().trim().min(1, 'Describe the problem').max(2000),
|
|
||||||
problemPartIds: z.array(z.string().uuid()).max(100),
|
|
||||||
});
|
|
||||||
type CreateValues = z.infer<typeof CreateSchema>;
|
|
||||||
|
|
||||||
interface FmFormDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
defaultHostId?: string;
|
|
||||||
defaultProblemPartIds?: string[];
|
|
||||||
onCreated?: (fm: Fm) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FmFormDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
defaultHostId,
|
|
||||||
defaultProblemPartIds,
|
|
||||||
onCreated,
|
|
||||||
}: FmFormDialogProps) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const hostsQuery = useQuery({
|
|
||||||
queryKey: queryKeys.hosts.list({ pageSize: 100 }),
|
|
||||||
queryFn: () => listHosts({ pageSize: 100 }),
|
|
||||||
enabled: open,
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = useForm<CreateValues>({
|
|
||||||
resolver: zodResolver(CreateSchema),
|
|
||||||
defaultValues: { hostId: '', problem: '', problemPartIds: [] },
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
form.reset({
|
|
||||||
hostId: defaultHostId ?? '',
|
|
||||||
problem: '',
|
|
||||||
problemPartIds: defaultProblemPartIds ?? [],
|
|
||||||
});
|
|
||||||
}, [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),
|
|
||||||
});
|
|
||||||
|
|
||||||
const createMutation = useMutation({
|
|
||||||
mutationFn: async (values: CreateValues) =>
|
|
||||||
createFm({
|
|
||||||
hostId: values.hostId,
|
|
||||||
problem: values.problem,
|
|
||||||
problemPartIds:
|
|
||||||
values.problemPartIds.length > 0 ? values.problemPartIds : undefined,
|
|
||||||
}),
|
|
||||||
onSuccess: (fm) => {
|
|
||||||
toast.success('FM opened');
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.fms.all });
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
|
|
||||||
onOpenChange(false);
|
|
||||||
onCreated?.(fm);
|
|
||||||
},
|
|
||||||
onError: (err) =>
|
|
||||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Save failed'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const pending = createMutation.isPending;
|
|
||||||
|
|
||||||
function togglePart(partId: string, checked: boolean) {
|
|
||||||
const next = checked
|
|
||||||
? [...new Set([...selectedPartIds, partId])]
|
|
||||||
: selectedPartIds.filter((id) => id !== partId);
|
|
||||||
form.setValue('problemPartIds', next, { shouldValidate: true, shouldDirty: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="max-w-lg">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Open FM</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Open a Future Maintenance item against a host. Select deployed parts involved (optional).
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
onSubmit={form.handleSubmit((v) => createMutation.mutate(v))}
|
|
||||||
className="space-y-3"
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="hostId"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Host</FormLabel>
|
|
||||||
<Select
|
|
||||||
onValueChange={(v) => {
|
|
||||||
field.onChange(v);
|
|
||||||
form.setValue('problemPartIds', [], { shouldValidate: false });
|
|
||||||
}}
|
|
||||||
value={field.value}
|
|
||||||
>
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select host" />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
{hostsQuery.data?.data.map((h) => (
|
|
||||||
<SelectItem key={h.id} value={h.id}>
|
|
||||||
{h.assetId} — {h.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="problem"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Problem</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Textarea
|
|
||||||
rows={3}
|
|
||||||
placeholder="Short description of what's wrong."
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{hostId && (
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="problemPartIds"
|
|
||||||
render={() => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Affected parts (optional)</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
Select deployed parts involved in this problem.
|
|
||||||
</FormDescription>
|
|
||||||
<div className="max-h-40 overflow-y-auto rounded-md border border-border">
|
|
||||||
{deployedQuery.isPending ? (
|
|
||||||
<Skeleton className="m-2 h-12" />
|
|
||||||
) : !deployedQuery.data || deployedQuery.data.length === 0 ? (
|
|
||||||
<p className="p-3 text-xs text-muted-foreground">
|
|
||||||
No deployed parts on this host.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<ul className="divide-y divide-border">
|
|
||||||
{deployedQuery.data.map((part) => {
|
|
||||||
const checked = selectedPartIds.includes(part.id);
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
key={part.id}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 text-sm"
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
id={`pp-${part.id}`}
|
|
||||||
checked={checked}
|
|
||||||
onCheckedChange={(v) => togglePart(part.id, v === true)}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor={`pp-${part.id}`}
|
|
||||||
className="flex-1 cursor-pointer select-none"
|
|
||||||
>
|
|
||||||
<span className="font-mono text-xs">{part.serialNumber}</span>{' '}
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{part.partModel.mpn}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<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" />}
|
|
||||||
Open FM
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,11 +4,9 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
ArrowRightLeft,
|
ArrowRightLeft,
|
||||||
CheckCircle2,
|
|
||||||
LogIn,
|
LogIn,
|
||||||
LogOut,
|
LogOut,
|
||||||
Pencil,
|
Pencil,
|
||||||
Wrench,
|
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button, Skeleton } from '@vector/ui';
|
import { Button, Skeleton } from '@vector/ui';
|
||||||
@@ -18,8 +16,6 @@ import type { HostTimelineEntry } from '../../lib/api/types.js';
|
|||||||
|
|
||||||
const ENTRY_ICON: Record<HostTimelineEntry['type'], LucideIcon> = {
|
const ENTRY_ICON: Record<HostTimelineEntry['type'], LucideIcon> = {
|
||||||
HOST_EVENT: Pencil,
|
HOST_EVENT: Pencil,
|
||||||
FM_OPENED: Wrench,
|
|
||||||
FM_CLOSED: Wrench,
|
|
||||||
REPAIR: ArrowRightLeft,
|
REPAIR: ArrowRightLeft,
|
||||||
PART_ARRIVED: LogIn,
|
PART_ARRIVED: LogIn,
|
||||||
PART_DEPARTED: LogOut,
|
PART_DEPARTED: LogOut,
|
||||||
@@ -70,22 +66,6 @@ function EntryRow({ entry }: { entry: HostTimelineEntry }) {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case 'FM_OPENED':
|
|
||||||
case 'FM_CLOSED': {
|
|
||||||
const { fm } = entry;
|
|
||||||
const label = entry.type === 'FM_OPENED' ? 'FM opened' : 'FM closed';
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-wrap items-center gap-x-2 text-sm">
|
|
||||||
<span className="font-medium text-foreground">{label}</span>
|
|
||||||
<Link to={`/fms/${fm.id}`} className="font-mono text-xs text-muted-foreground hover:underline">
|
|
||||||
{fm.id}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="mt-0.5 text-xs text-muted-foreground">{formatWhen(entry.at)}</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
case 'REPAIR': {
|
case 'REPAIR': {
|
||||||
const { repair } = entry;
|
const { repair } = entry;
|
||||||
return (
|
return (
|
||||||
@@ -144,10 +124,6 @@ function entryKey(entry: HostTimelineEntry): string {
|
|||||||
switch (entry.type) {
|
switch (entry.type) {
|
||||||
case 'HOST_EVENT':
|
case 'HOST_EVENT':
|
||||||
return `he-${entry.hostEvent.id}`;
|
return `he-${entry.hostEvent.id}`;
|
||||||
case 'FM_OPENED':
|
|
||||||
return `fo-${entry.fm.id}`;
|
|
||||||
case 'FM_CLOSED':
|
|
||||||
return `fc-${entry.fm.id}`;
|
|
||||||
case 'REPAIR':
|
case 'REPAIR':
|
||||||
return `r-${entry.repair.id}`;
|
return `r-${entry.repair.id}`;
|
||||||
case 'PART_ARRIVED':
|
case 'PART_ARRIVED':
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
Server,
|
Server,
|
||||||
Users as UsersIcon,
|
Users as UsersIcon,
|
||||||
Webhook,
|
Webhook,
|
||||||
Wrench,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn, Button, Tooltip, TooltipContent, TooltipTrigger } from '@vector/ui';
|
import { cn, Button, Tooltip, TooltipContent, TooltipTrigger } from '@vector/ui';
|
||||||
import { useAuth } from '../../contexts/AuthContext.js';
|
import { useAuth } from '../../contexts/AuthContext.js';
|
||||||
@@ -31,7 +30,6 @@ const NAV_ITEMS: NavItem[] = [
|
|||||||
{ to: '/part-models', label: 'Part models', icon: Layers },
|
{ to: '/part-models', label: 'Part models', icon: Layers },
|
||||||
{ to: '/locations', label: 'Locations', icon: MapPinned },
|
{ to: '/locations', label: 'Locations', icon: MapPinned },
|
||||||
{ to: '/manufacturers', label: 'Manufacturers', icon: Boxes },
|
{ to: '/manufacturers', label: 'Manufacturers', icon: Boxes },
|
||||||
{ to: '/fms', label: 'FMs', icon: Wrench },
|
|
||||||
{ to: '/repairs', label: 'Repairs', icon: ArrowRightLeft },
|
{ to: '/repairs', label: 'Repairs', icon: ArrowRightLeft },
|
||||||
{ to: '/custody', label: 'My Custody', icon: Hand },
|
{ to: '/custody', label: 'My Custody', icon: Hand },
|
||||||
{ to: '/hosts', label: 'Hosts', icon: Server },
|
{ to: '/hosts', label: 'Hosts', icon: Server },
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
Package,
|
Package,
|
||||||
Pencil,
|
Pencil,
|
||||||
Tag,
|
Tag,
|
||||||
Wrench,
|
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { PartEventType } from '@vector/shared';
|
import type { PartEventType } from '@vector/shared';
|
||||||
@@ -21,8 +20,6 @@ const EVENT_ICON: Record<PartEventType, LucideIcon> = {
|
|||||||
STATE_CHANGED: CheckCircle2,
|
STATE_CHANGED: CheckCircle2,
|
||||||
LOCATION_CHANGED: MapPin,
|
LOCATION_CHANGED: MapPin,
|
||||||
FIELD_UPDATED: Pencil,
|
FIELD_UPDATED: Pencil,
|
||||||
FM_OPENED: Wrench,
|
|
||||||
FM_CLOSED: Wrench,
|
|
||||||
PART_SWAPPED: ArrowRightLeft,
|
PART_SWAPPED: ArrowRightLeft,
|
||||||
TAG_ADDED: Tag,
|
TAG_ADDED: Tag,
|
||||||
TAG_REMOVED: Tag,
|
TAG_REMOVED: Tag,
|
||||||
@@ -33,8 +30,6 @@ const EVENT_TITLE: Record<PartEventType, string> = {
|
|||||||
STATE_CHANGED: 'State changed',
|
STATE_CHANGED: 'State changed',
|
||||||
LOCATION_CHANGED: 'Location changed',
|
LOCATION_CHANGED: 'Location changed',
|
||||||
FIELD_UPDATED: 'Field updated',
|
FIELD_UPDATED: 'Field updated',
|
||||||
FM_OPENED: 'FM opened',
|
|
||||||
FM_CLOSED: 'FM closed',
|
|
||||||
PART_SWAPPED: 'Part swapped',
|
PART_SWAPPED: 'Part swapped',
|
||||||
TAG_ADDED: 'Tag added',
|
TAG_ADDED: 'Tag added',
|
||||||
TAG_REMOVED: 'Tag removed',
|
TAG_REMOVED: 'Tag removed',
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import {
|
|||||||
import { logRepair } from '../../lib/api/repairs.js';
|
import { logRepair } from '../../lib/api/repairs.js';
|
||||||
import { listHosts } from '../../lib/api/hosts.js';
|
import { listHosts } from '../../lib/api/hosts.js';
|
||||||
import { listManufacturers } from '../../lib/api/manufacturers.js';
|
import { listManufacturers } from '../../lib/api/manufacturers.js';
|
||||||
import { listFms } from '../../lib/api/fms.js';
|
|
||||||
import { listParts } from '../../lib/api/parts.js';
|
import { listParts } from '../../lib/api/parts.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';
|
||||||
@@ -48,7 +47,6 @@ const Schema = z
|
|||||||
brokenMpn: z.string().trim().max(128).optional(),
|
brokenMpn: z.string().trim().max(128).optional(),
|
||||||
brokenManufacturerId: z.union([z.literal(''), z.string().uuid()]).optional(),
|
brokenManufacturerId: z.union([z.literal(''), z.string().uuid()]).optional(),
|
||||||
replacementSerial: z.string().trim().min(1, 'Required').max(128),
|
replacementSerial: z.string().trim().min(1, 'Required').max(128),
|
||||||
fmId: z.string().optional(),
|
|
||||||
brokenExists: z.boolean().optional(),
|
brokenExists: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.superRefine((v, ctx) => {
|
.superRefine((v, ctx) => {
|
||||||
@@ -72,8 +70,6 @@ const Schema = z
|
|||||||
});
|
});
|
||||||
type Values = z.infer<typeof Schema>;
|
type Values = z.infer<typeof Schema>;
|
||||||
|
|
||||||
const NO_FM = '__none__';
|
|
||||||
|
|
||||||
interface LogRepairDialogProps {
|
interface LogRepairDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
@@ -94,7 +90,6 @@ export function LogRepairDialog({ open, onOpenChange, onLogged }: LogRepairDialo
|
|||||||
brokenMpn: '',
|
brokenMpn: '',
|
||||||
brokenManufacturerId: '',
|
brokenManufacturerId: '',
|
||||||
replacementSerial: '',
|
replacementSerial: '',
|
||||||
fmId: '',
|
|
||||||
brokenExists: false,
|
brokenExists: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -109,12 +104,10 @@ export function LogRepairDialog({ open, onOpenChange, onLogged }: LogRepairDialo
|
|||||||
brokenMpn: '',
|
brokenMpn: '',
|
||||||
brokenManufacturerId: '',
|
brokenManufacturerId: '',
|
||||||
replacementSerial: '',
|
replacementSerial: '',
|
||||||
fmId: '',
|
|
||||||
brokenExists: false,
|
brokenExists: false,
|
||||||
});
|
});
|
||||||
}, [open, form]);
|
}, [open, form]);
|
||||||
|
|
||||||
const hostId = form.watch('hostId');
|
|
||||||
const brokenSerial = form.watch('brokenSerial').trim();
|
const brokenSerial = form.watch('brokenSerial').trim();
|
||||||
|
|
||||||
const hosts = useQuery({
|
const hosts = useQuery({
|
||||||
@@ -129,13 +122,6 @@ export function LogRepairDialog({ open, onOpenChange, onLogged }: LogRepairDialo
|
|||||||
enabled: open,
|
enabled: open,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Open FMs on the chosen host, so the optional linker only shows relevant items.
|
|
||||||
const openFms = useQuery({
|
|
||||||
queryKey: queryKeys.fms.list({ hostId, status: 'OPEN', pageSize: 50 }),
|
|
||||||
queryFn: () => listFms({ hostId, status: 'OPEN', pageSize: 50 }),
|
|
||||||
enabled: open && Boolean(hostId),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Debounced live lookup — as the tech types a broken serial, hint whether Vector
|
// Debounced live lookup — as the tech types a broken serial, hint whether Vector
|
||||||
// already knows that part (existing) or will auto-ingest it (new).
|
// already knows that part (existing) or will auto-ingest it (new).
|
||||||
const brokenLookup = useQuery({
|
const brokenLookup = useQuery({
|
||||||
@@ -160,7 +146,6 @@ export function LogRepairDialog({ open, onOpenChange, onLogged }: LogRepairDialo
|
|||||||
hostId: v.hostId,
|
hostId: v.hostId,
|
||||||
brokenSerial: v.brokenSerial.trim(),
|
brokenSerial: v.brokenSerial.trim(),
|
||||||
replacementSerial: v.replacementSerial.trim(),
|
replacementSerial: v.replacementSerial.trim(),
|
||||||
fmId: v.fmId ? v.fmId : undefined,
|
|
||||||
};
|
};
|
||||||
// If the broken part is already catalogued, the server ignores model fields entirely.
|
// If the broken part is already catalogued, the server ignores model fields entirely.
|
||||||
if (existingBroken) return logRepair(base);
|
if (existingBroken) return logRepair(base);
|
||||||
@@ -327,39 +312,6 @@ export function LogRepairDialog({ open, onOpenChange, onLogged }: LogRepairDialo
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="fmId"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Link to open FM (optional)</FormLabel>
|
|
||||||
<Select
|
|
||||||
value={field.value ? field.value : NO_FM}
|
|
||||||
onValueChange={(v) => field.onChange(v === NO_FM ? '' : v)}
|
|
||||||
disabled={!hostId}
|
|
||||||
>
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder={hostId ? 'No linked FM' : 'Pick a host first'} />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value={NO_FM}>No linked FM</SelectItem>
|
|
||||||
{openFms.data?.data.map((f) => (
|
|
||||||
<SelectItem key={f.id} value={f.id}>
|
|
||||||
{f.problem.slice(0, 80)}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormDescription>
|
|
||||||
Linking doesn't auto-close the FM.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import type { CreateFmRequest, FmStatus, UpdateFmRequest } from '@vector/shared';
|
|
||||||
import { api } from './client.js';
|
|
||||||
import { getList } from './paginated.js';
|
|
||||||
import type { Fm } from './types.js';
|
|
||||||
|
|
||||||
export type FmListFilters = {
|
|
||||||
page?: number;
|
|
||||||
pageSize?: number;
|
|
||||||
status?: FmStatus;
|
|
||||||
hostId?: string;
|
|
||||||
problemPartId?: string;
|
|
||||||
openOnly?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function listFms(filters: FmListFilters = {}) {
|
|
||||||
return getList<Fm>('/fms', filters);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getFm(id: string): Promise<Fm> {
|
|
||||||
const res = await api.get<Fm>(`/fms/${id}`);
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createFm(input: CreateFmRequest): Promise<Fm> {
|
|
||||||
const res = await api.post<Fm>('/fms', input);
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateFm(id: string, input: UpdateFmRequest): Promise<Fm> {
|
|
||||||
const res = await api.patch<Fm>(`/fms/${id}`, input);
|
|
||||||
return res.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteFm(id: string): Promise<void> {
|
|
||||||
await api.delete(`/fms/${id}`);
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,6 @@ export type RepairListFilters = {
|
|||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
hostId?: string;
|
hostId?: string;
|
||||||
performedById?: string;
|
performedById?: string;
|
||||||
fmId?: string;
|
|
||||||
since?: string;
|
since?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type {
|
import type {
|
||||||
FmStatus,
|
|
||||||
HostState,
|
HostState,
|
||||||
HostStack,
|
HostStack,
|
||||||
PartEventType,
|
PartEventType,
|
||||||
@@ -124,14 +123,6 @@ export interface HostEvent {
|
|||||||
user: { username: string } | null;
|
user: { username: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FmTimelineSummary {
|
|
||||||
id: string;
|
|
||||||
status: FmStatus;
|
|
||||||
problem: string;
|
|
||||||
openedAt: string;
|
|
||||||
closedAt: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RepairTimelineSummary {
|
interface RepairTimelineSummary {
|
||||||
id: string;
|
id: string;
|
||||||
performedAt: string;
|
performedAt: string;
|
||||||
@@ -148,8 +139,6 @@ interface PartTimelineRef {
|
|||||||
|
|
||||||
export type HostTimelineEntry =
|
export type HostTimelineEntry =
|
||||||
| { type: 'HOST_EVENT'; at: string; hostEvent: HostEvent }
|
| { type: 'HOST_EVENT'; at: string; hostEvent: HostEvent }
|
||||||
| { type: 'FM_OPENED'; at: string; fm: FmTimelineSummary }
|
|
||||||
| { type: 'FM_CLOSED'; at: string; fm: FmTimelineSummary }
|
|
||||||
| { type: 'REPAIR'; at: string; repair: RepairTimelineSummary }
|
| { type: 'REPAIR'; at: string; repair: RepairTimelineSummary }
|
||||||
| { type: 'PART_ARRIVED'; at: string; part: PartTimelineRef; partEventId: string }
|
| { type: 'PART_ARRIVED'; at: string; part: PartTimelineRef; partEventId: string }
|
||||||
| { type: 'PART_DEPARTED'; at: string; part: PartTimelineRef; partEventId: string };
|
| { type: 'PART_DEPARTED'; at: string; part: PartTimelineRef; partEventId: string };
|
||||||
@@ -171,26 +160,6 @@ export interface Category {
|
|||||||
_count?: { partModels: number };
|
_count?: { partModels: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FmProblemPart {
|
|
||||||
fmId: string;
|
|
||||||
partId: string;
|
|
||||||
createdAt: string;
|
|
||||||
part: Part;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Fm {
|
|
||||||
id: string;
|
|
||||||
hostId: string;
|
|
||||||
status: FmStatus;
|
|
||||||
problem: string;
|
|
||||||
openedAt: string;
|
|
||||||
closedAt: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
host: Host;
|
|
||||||
problemParts: FmProblemPart[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Repair {
|
export interface Repair {
|
||||||
id: string;
|
id: string;
|
||||||
hostId: string;
|
hostId: string;
|
||||||
@@ -198,20 +167,18 @@ export interface Repair {
|
|||||||
replacementPartId: string;
|
replacementPartId: string;
|
||||||
performedById: string;
|
performedById: string;
|
||||||
performedAt: string;
|
performedAt: string;
|
||||||
fmId: string | null;
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
host: Host;
|
host: Host;
|
||||||
brokenPart: Part;
|
brokenPart: Part;
|
||||||
replacement: Part;
|
replacement: Part;
|
||||||
performedBy: Pick<User, 'id' | 'username'>;
|
performedBy: Pick<User, 'id' | 'username'>;
|
||||||
fm: { id: string; status: FmStatus } | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SavedView {
|
export interface SavedView {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
resource: 'parts' | 'fms' | 'repairs' | 'hosts' | 'manufacturers';
|
resource: 'parts' | 'repairs' | 'hosts' | 'manufacturers';
|
||||||
name: string;
|
name: string;
|
||||||
filterJson: unknown;
|
filterJson: unknown;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|||||||
@@ -53,12 +53,6 @@ export const queryKeys = {
|
|||||||
timeline: (id: string, filters?: Record<string, unknown>) =>
|
timeline: (id: string, filters?: Record<string, unknown>) =>
|
||||||
[...queryKeys.hosts.all, 'timeline', id, filters ?? {}] as const,
|
[...queryKeys.hosts.all, 'timeline', id, filters ?? {}] as const,
|
||||||
},
|
},
|
||||||
fms: {
|
|
||||||
all: ['fms'] as const,
|
|
||||||
list: (filters?: Record<string, unknown>) =>
|
|
||||||
[...queryKeys.fms.all, 'list', filters ?? {}] as const,
|
|
||||||
detail: (id: string) => [...queryKeys.fms.all, 'detail', id] as const,
|
|
||||||
},
|
|
||||||
repairs: {
|
repairs: {
|
||||||
all: ['repairs'] as const,
|
all: ['repairs'] as const,
|
||||||
list: (filters?: Record<string, unknown>) =>
|
list: (filters?: Record<string, unknown>) =>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { AlertTriangle, CalendarClock, Download, Package, Wrench } from 'lucide-react';
|
import { AlertTriangle, CalendarClock, Download, Package } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Bar,
|
Bar,
|
||||||
BarChart,
|
BarChart,
|
||||||
@@ -51,7 +51,6 @@ const STATE_COLORS: Record<PartState, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const LINE_BLUE = 'hsl(217 91% 60%)';
|
const LINE_BLUE = 'hsl(217 91% 60%)';
|
||||||
const FAILURE_COLOR = 'hsl(0 84% 60%)';
|
|
||||||
|
|
||||||
const TOOLTIP_CURSOR_FILL = 'color-mix(in oklch, var(--color-foreground) 8%, transparent)';
|
const TOOLTIP_CURSOR_FILL = 'color-mix(in oklch, var(--color-foreground) 8%, transparent)';
|
||||||
const TOOLTIP_CURSOR_STROKE = 'color-mix(in oklch, var(--color-foreground) 24%, transparent)';
|
const TOOLTIP_CURSOR_STROKE = 'color-mix(in oklch, var(--color-foreground) 24%, transparent)';
|
||||||
@@ -75,12 +74,6 @@ function currency(dollars: number): string {
|
|||||||
return dollars.toLocaleString(undefined, { style: 'currency', currency: 'USD' });
|
return dollars.toLocaleString(undefined, { style: 'currency', currency: 'USD' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatHours(h: number): string {
|
|
||||||
if (h < 24) return `${h.toFixed(1)} h`;
|
|
||||||
const days = h / 24;
|
|
||||||
return `${days.toFixed(1)} d`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function shortDate(iso: string): string {
|
function shortDate(iso: string): string {
|
||||||
const d = new Date(`${iso}T00:00:00Z`);
|
const d = new Date(`${iso}T00:00:00Z`);
|
||||||
return `${d.getUTCMonth() + 1}/${d.getUTCDate()}`;
|
return `${d.getUTCMonth() + 1}/${d.getUTCDate()}`;
|
||||||
@@ -122,18 +115,12 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
{data && (
|
{data && (
|
||||||
<>
|
<>
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
||||||
<KpiCard
|
<KpiCard
|
||||||
icon={<Package className="h-4 w-4" />}
|
icon={<Package className="h-4 w-4" />}
|
||||||
label="Total parts"
|
label="Total parts"
|
||||||
value={data.totalParts.toLocaleString()}
|
value={data.totalParts.toLocaleString()}
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
|
||||||
icon={<Wrench className="h-4 w-4" />}
|
|
||||||
label="Open FMs"
|
|
||||||
value={data.openFms.toLocaleString()}
|
|
||||||
href="/fms"
|
|
||||||
/>
|
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label="Deployed value"
|
label="Deployed value"
|
||||||
value={currency(
|
value={currency(
|
||||||
@@ -164,7 +151,7 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data.operations && (
|
{data.operations && (
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-2">
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label="Repairs (7d)"
|
label="Repairs (7d)"
|
||||||
value={data.operations.repairs7d.toLocaleString()}
|
value={data.operations.repairs7d.toLocaleString()}
|
||||||
@@ -175,19 +162,6 @@ export default function Dashboard() {
|
|||||||
value={data.operations.repairs30d.toLocaleString()}
|
value={data.operations.repairs30d.toLocaleString()}
|
||||||
href="/repairs"
|
href="/repairs"
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
|
||||||
label="FMs opened (7d)"
|
|
||||||
value={data.operations.newFms7d.toLocaleString()}
|
|
||||||
href="/fms"
|
|
||||||
/>
|
|
||||||
<KpiCard
|
|
||||||
label="Avg FM close (30d)"
|
|
||||||
value={
|
|
||||||
data.operations.avgFmCloseHours30d == null
|
|
||||||
? '—'
|
|
||||||
: formatHours(data.operations.avgFmCloseHours30d)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -343,72 +317,38 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data.operations && (
|
{data.operations && (
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
<Card>
|
||||||
<Card>
|
<CardHeader>
|
||||||
<CardHeader>
|
<CardTitle>Repairs (last 30 days)</CardTitle>
|
||||||
<CardTitle>Repairs (last 30 days)</CardTitle>
|
<CardDescription>Daily count of logged part swaps.</CardDescription>
|
||||||
<CardDescription>Daily count of logged part swaps.</CardDescription>
|
</CardHeader>
|
||||||
</CardHeader>
|
<CardContent className="h-72">
|
||||||
<CardContent className="h-72">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<LineChart
|
||||||
<LineChart
|
data={data.operations.repairsTrend30d.map((d) => ({
|
||||||
data={data.operations.repairsTrend30d.map((d) => ({
|
label: shortDate(d.date),
|
||||||
label: shortDate(d.date),
|
count: d.count,
|
||||||
count: d.count,
|
}))}
|
||||||
}))}
|
>
|
||||||
>
|
<XAxis dataKey="label" tick={{ fontSize: 11 }} interval={3} />
|
||||||
<XAxis dataKey="label" tick={{ fontSize: 11 }} interval={3} />
|
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} />
|
||||||
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} />
|
<Tooltip
|
||||||
<Tooltip
|
cursor={{ stroke: TOOLTIP_CURSOR_STROKE }}
|
||||||
cursor={{ stroke: TOOLTIP_CURSOR_STROKE }}
|
contentStyle={TOOLTIP_CONTENT_STYLE}
|
||||||
contentStyle={TOOLTIP_CONTENT_STYLE}
|
itemStyle={TOOLTIP_ITEM_STYLE}
|
||||||
itemStyle={TOOLTIP_ITEM_STYLE}
|
labelStyle={TOOLTIP_LABEL_STYLE}
|
||||||
labelStyle={TOOLTIP_LABEL_STYLE}
|
/>
|
||||||
/>
|
<Line
|
||||||
<Line
|
type="monotone"
|
||||||
type="monotone"
|
dataKey="count"
|
||||||
dataKey="count"
|
stroke={LINE_BLUE}
|
||||||
stroke={LINE_BLUE}
|
strokeWidth={2}
|
||||||
strokeWidth={2}
|
dot={false}
|
||||||
dot={false}
|
/>
|
||||||
/>
|
</LineChart>
|
||||||
</LineChart>
|
</ResponsiveContainer>
|
||||||
</ResponsiveContainer>
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Open FMs by host</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Where the active field-maintenance load is concentrated.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-2 pb-5">
|
|
||||||
{data.operations.openFmsByHost.length === 0 ? (
|
|
||||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
|
||||||
No open FMs.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
data.operations.openFmsByHost.map((h) => (
|
|
||||||
<Link
|
|
||||||
key={h.hostId}
|
|
||||||
to={`/hosts/${h.hostId}`}
|
|
||||||
className="flex items-center justify-between rounded-md border border-border bg-background px-3 py-2 text-sm transition-colors hover:bg-accent/40"
|
|
||||||
>
|
|
||||||
<span className="truncate font-medium">{h.hostName}</span>
|
|
||||||
<span
|
|
||||||
className="tabular-nums font-semibold"
|
|
||||||
style={{ color: FAILURE_COLOR }}
|
|
||||||
>
|
|
||||||
{h.count}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{data.operations && data.operations.custodyBacklog.length > 0 && (
|
{data.operations && data.operations.custodyBacklog.length > 0 && (
|
||||||
@@ -554,8 +494,8 @@ function EolBanner({
|
|||||||
function DashboardSkeleton() {
|
function DashboardSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
<Skeleton key={i} className="h-20" />
|
<Skeleton key={i} className="h-20" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,438 +0,0 @@
|
|||||||
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 {
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
Checkbox,
|
|
||||||
Separator,
|
|
||||||
Skeleton,
|
|
||||||
Textarea,
|
|
||||||
} from '@vector/ui';
|
|
||||||
import { getFm, updateFm } from '../lib/api/fms.js';
|
|
||||||
import { listHostDeployedParts } from '../lib/api/hosts.js';
|
|
||||||
import { ApiRequestError } from '../lib/api/client.js';
|
|
||||||
import { queryKeys } from '../lib/queryKeys.js';
|
|
||||||
import type { Fm } from '../lib/api/types.js';
|
|
||||||
import { PartStateBadge } from '../components/parts/PartStateBadge.js';
|
|
||||||
import { HostStackBadge, HostStateBadge } from '../components/hosts/HostStateBadge.js';
|
|
||||||
|
|
||||||
export default function FmDetail() {
|
|
||||||
const { id } = useParams<{ id: string }>();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const { data: fm, isPending, isError, error } = useQuery({
|
|
||||||
queryKey: queryKeys.fms.detail(id!),
|
|
||||||
queryFn: () => getFm(id!),
|
|
||||||
enabled: Boolean(id),
|
|
||||||
});
|
|
||||||
|
|
||||||
const invalidate = () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.fms.detail(id!) });
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.fms.list() });
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleMutation = useMutation({
|
|
||||||
mutationFn: () => updateFm(id!, { status: fm?.status === 'OPEN' ? 'CLOSED' : 'OPEN' }),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success(fm?.status === 'OPEN' ? 'FM closed' : 'FM reopened');
|
|
||||||
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 || !fm) {
|
|
||||||
const msg = error instanceof ApiRequestError ? error.body.message : 'FM not found.';
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>FM unavailable</CardTitle>
|
|
||||||
<CardDescription>{msg}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Button variant="outline" onClick={() => navigate('/fms')}>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
Back to FMs
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const closed = fm.status === 'CLOSED';
|
|
||||||
|
|
||||||
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('/fms')}
|
|
||||||
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>
|
|
||||||
<Link
|
|
||||||
to={`/hosts/${fm.host.id}`}
|
|
||||||
className="font-mono text-2xl font-semibold tracking-tight text-foreground hover:underline"
|
|
||||||
>
|
|
||||||
{fm.host.assetId}
|
|
||||||
</Link>
|
|
||||||
<HostStateBadge state={fm.host.state} />
|
|
||||||
<HostStackBadge stack={fm.host.stack} />
|
|
||||||
<Badge variant={closed ? 'secondary' : 'warning'}>
|
|
||||||
{closed ? 'Closed' : 'Open'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
|
||||||
<Server className="h-3 w-3" />
|
|
||||||
<Link to={`/hosts/${fm.host.id}`} className="hover:underline">
|
|
||||||
{fm.host.name}
|
|
||||||
</Link>
|
|
||||||
{fm.host.location && <span>· {fm.host.location}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant={closed ? 'outline' : 'default'}
|
|
||||||
onClick={() => toggleMutation.mutate()}
|
|
||||||
disabled={toggleMutation.isPending}
|
|
||||||
>
|
|
||||||
{toggleMutation.isPending ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : closed ? (
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{closed ? 'Reopen' : 'Close FM'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-[1.3fr_1fr]">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<ProblemCard fm={fm} onSaved={invalidate} disabled={closed} />
|
|
||||||
<ProblemPartsCard fm={fm} onSaved={invalidate} disabled={closed} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">Timeline</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
The actual repair work lives in the external ticketing system.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<dl className="grid grid-cols-2 gap-2 text-sm">
|
|
||||||
<Field label="Opened" value={new Date(fm.openedAt).toLocaleString()} />
|
|
||||||
<Field
|
|
||||||
label="Closed"
|
|
||||||
value={fm.closedAt ? new Date(fm.closedAt).toLocaleString() : '—'}
|
|
||||||
/>
|
|
||||||
<Field label="Updated" value={new Date(fm.updatedAt).toLocaleString()} />
|
|
||||||
</dl>
|
|
||||||
<Separator className="my-3" />
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Opened and closed events fire <code className="font-mono">fm.opened</code> and{' '}
|
|
||||||
<code className="font-mono">fm.closed</code> webhooks.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</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({
|
|
||||||
fm,
|
|
||||||
onSaved,
|
|
||||||
disabled,
|
|
||||||
}: {
|
|
||||||
fm: { id: string; problem: string };
|
|
||||||
onSaved: () => void;
|
|
||||||
disabled: boolean;
|
|
||||||
}) {
|
|
||||||
const [editing, setEditing] = useState(false);
|
|
||||||
const [value, setValue] = useState(fm.problem);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setValue(fm.problem);
|
|
||||||
}, [fm.problem]);
|
|
||||||
|
|
||||||
const mutation = useMutation({
|
|
||||||
mutationFn: (problem: string) => updateFm(fm.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(fm.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() === fm.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">{fm.problem}</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProblemPartsCard({
|
|
||||||
fm,
|
|
||||||
onSaved,
|
|
||||||
disabled,
|
|
||||||
}: {
|
|
||||||
fm: Fm;
|
|
||||||
onSaved: () => void;
|
|
||||||
disabled: boolean;
|
|
||||||
}) {
|
|
||||||
const [picking, setPicking] = useState(false);
|
|
||||||
const [draft, setDraft] = useState<string[]>([]);
|
|
||||||
|
|
||||||
const deployedQuery = useQuery({
|
|
||||||
queryKey: queryKeys.hosts.deployedParts(fm.hostId),
|
|
||||||
queryFn: () => listHostDeployedParts(fm.hostId),
|
|
||||||
enabled: picking,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (picking) {
|
|
||||||
setDraft(fm.problemParts.map((pp) => pp.partId));
|
|
||||||
}
|
|
||||||
}, [picking, fm.problemParts]);
|
|
||||||
|
|
||||||
const mutation = useMutation({
|
|
||||||
mutationFn: (problemPartIds: string[]) => updateFm(fm.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 = fm.problemParts.map((pp) => pp.partId).filter((pid) => pid !== partId);
|
|
||||||
return updateFm(fm.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={`fd-pp-${part.id}`}
|
|
||||||
checked={checked}
|
|
||||||
onCheckedChange={(v) => toggle(part.id, v === true)}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor={`fd-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>
|
|
||||||
) : fm.problemParts.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
No specific parts tagged — the FM is against the host itself.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<ul className="divide-y divide-border rounded-md border border-border">
|
|
||||||
{fm.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,244 +0,0 @@
|
|||||||
import { useMemo, useState } from 'react';
|
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { parseAsString } from 'nuqs';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { MoreHorizontal, Plus, Trash2, Wrench } from 'lucide-react';
|
|
||||||
import type { FmStatus } from '@vector/shared';
|
|
||||||
import {
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@vector/ui';
|
|
||||||
import { PageHeader } from '../components/layout/PageHeader.js';
|
|
||||||
import { DataTable } from '../components/data-table/DataTable.js';
|
|
||||||
import { FmFormDialog } from '../components/fms/FmFormDialog.js';
|
|
||||||
import { HostStackBadge, HostStateBadge } from '../components/hosts/HostStateBadge.js';
|
|
||||||
import { ConfirmDialog } from '../components/ConfirmDialog.js';
|
|
||||||
import { deleteFm, listFms } from '../lib/api/fms.js';
|
|
||||||
import { ApiRequestError } from '../lib/api/client.js';
|
|
||||||
import type { Fm } from '../lib/api/types.js';
|
|
||||||
import { queryKeys } from '../lib/queryKeys.js';
|
|
||||||
|
|
||||||
type FmFilters = {
|
|
||||||
status: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const filterParsers = {
|
|
||||||
status: parseAsString,
|
|
||||||
};
|
|
||||||
|
|
||||||
const ALL = '__all__';
|
|
||||||
const STATUS_OPTIONS: { value: FmStatus; label: string }[] = [
|
|
||||||
{ value: 'OPEN', label: 'Open' },
|
|
||||||
{ value: 'CLOSED', label: 'Closed' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function FmStatusBadge({ status }: { status: FmStatus }) {
|
|
||||||
return (
|
|
||||||
<Badge variant={status === 'OPEN' ? 'warning' : 'secondary'}>
|
|
||||||
{status === 'OPEN' ? 'Open' : 'Closed'}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Fms() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
|
||||||
const [deleting, setDeleting] = useState<Fm | null>(null);
|
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
|
||||||
mutationFn: (id: string) => deleteFm(id),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success('FM removed');
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.fms.all });
|
|
||||||
setDeleting(null);
|
|
||||||
},
|
|
||||||
onError: (err) =>
|
|
||||||
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<Fm>[]>(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
accessorKey: 'status',
|
|
||||||
header: 'Status',
|
|
||||||
cell: ({ row }) => <FmStatusBadge status={row.original.status} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'assetId',
|
|
||||||
header: 'Asset ID',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<Link
|
|
||||||
to={`/fms/${row.original.id}`}
|
|
||||||
className="font-mono text-xs font-medium text-foreground hover:underline"
|
|
||||||
>
|
|
||||||
{row.original.host.assetId}
|
|
||||||
</Link>
|
|
||||||
<HostStateBadge state={row.original.host.state} />
|
|
||||||
<HostStackBadge stack={row.original.host.stack} />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'host',
|
|
||||||
header: 'Host',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Link
|
|
||||||
to={`/hosts/${row.original.host.id}`}
|
|
||||||
className="text-sm hover:underline"
|
|
||||||
>
|
|
||||||
{row.original.host.name}
|
|
||||||
</Link>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'problem',
|
|
||||||
header: 'Problem',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Link
|
|
||||||
to={`/fms/${row.original.id}`}
|
|
||||||
className="line-clamp-1 max-w-sm text-sm text-foreground hover:underline"
|
|
||||||
>
|
|
||||||
{row.original.problem}
|
|
||||||
</Link>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'openedAt',
|
|
||||||
header: 'Opened',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{new Date(row.original.openedAt).toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'closedAt',
|
|
||||||
header: 'Closed',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{row.original.closedAt
|
|
||||||
? new Date(row.original.closedAt).toLocaleDateString()
|
|
||||||
: '—'}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'actions',
|
|
||||||
header: () => <span className="sr-only">Actions</span>,
|
|
||||||
size: 40,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-36">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => setDeleting(row.original)}
|
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<PageHeader
|
|
||||||
title="FMs"
|
|
||||||
description="Open future-maintenance items logged against hosts."
|
|
||||||
actions={
|
|
||||||
<Button onClick={() => setCreateOpen(true)}>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
Open FM
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DataTable<Fm, FmFilters>
|
|
||||||
columns={columns}
|
|
||||||
getRowId={(r) => r.id}
|
|
||||||
filterParsers={filterParsers}
|
|
||||||
queryKey={(params) =>
|
|
||||||
queryKeys.fms.list({
|
|
||||||
page: params.page,
|
|
||||||
pageSize: params.pageSize,
|
|
||||||
status: params.filters.status,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
queryFn={(params) =>
|
|
||||||
listFms({
|
|
||||||
page: params.page,
|
|
||||||
pageSize: params.pageSize,
|
|
||||||
status: (params.filters.status ?? undefined) as FmStatus | undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
enableSearch={false}
|
|
||||||
toolbar={({ filters, setFilter }) => (
|
|
||||||
<Select
|
|
||||||
value={filters.status ?? ALL}
|
|
||||||
onValueChange={(v) => setFilter('status', v === ALL ? null : v)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-40">
|
|
||||||
<SelectValue placeholder="Any status" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value={ALL}>Any status</SelectItem>
|
|
||||||
{STATUS_OPTIONS.map((o) => (
|
|
||||||
<SelectItem key={o.value} value={o.value}>
|
|
||||||
{o.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
emptyState={
|
|
||||||
<div className="flex flex-col items-center gap-1 py-8 text-muted-foreground">
|
|
||||||
<Wrench className="h-6 w-6" />
|
|
||||||
<span className="text-sm">No FMs yet.</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FmFormDialog
|
|
||||||
open={createOpen}
|
|
||||||
onOpenChange={setCreateOpen}
|
|
||||||
onCreated={(fm) => navigate(`/fms/${fm.id}`)}
|
|
||||||
/>
|
|
||||||
<ConfirmDialog
|
|
||||||
open={Boolean(deleting)}
|
|
||||||
onOpenChange={(o) => !o && setDeleting(null)}
|
|
||||||
title="Delete FM?"
|
|
||||||
description={
|
|
||||||
deleting
|
|
||||||
? `Remove FM "${deleting.problem.slice(0, 60)}" on ${deleting.host.name}. This cannot be undone.`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
confirmLabel="Delete"
|
|
||||||
destructive
|
|
||||||
pending={deleteMutation.isPending}
|
|
||||||
onConfirm={() => deleting && deleteMutation.mutate(deleting.id)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -65,21 +65,6 @@ export default function Repairs() {
|
|||||||
<span className="text-xs">{row.original.performedBy.username}</span>
|
<span className="text-xs">{row.original.performedBy.username}</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'fm',
|
|
||||||
header: 'FM',
|
|
||||||
cell: ({ row }) =>
|
|
||||||
row.original.fmId ? (
|
|
||||||
<Link
|
|
||||||
to={`/fms/${row.original.fmId}`}
|
|
||||||
className="text-xs text-foreground hover:underline"
|
|
||||||
>
|
|
||||||
View FM
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-muted-foreground">—</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the `fm_parts` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- You are about to drop the `fms` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- You are about to drop the column `fmId` on the `repairs` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- Drop orphan part_events referencing the retired FM event types.
|
||||||
|
DELETE FROM "part_events" WHERE "type" IN ('FM_OPENED', 'FM_CLOSED');
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "fm_parts_partId_idx";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "fms_status_openedAt_idx";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "fms_hostId_idx";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "fms_status_idx";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
PRAGMA foreign_keys=off;
|
||||||
|
DROP TABLE "fm_parts";
|
||||||
|
PRAGMA foreign_keys=on;
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
PRAGMA foreign_keys=off;
|
||||||
|
DROP TABLE "fms";
|
||||||
|
PRAGMA foreign_keys=on;
|
||||||
|
|
||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_repairs" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"hostId" TEXT NOT NULL,
|
||||||
|
"brokenPartId" TEXT NOT NULL,
|
||||||
|
"replacementPartId" TEXT NOT NULL,
|
||||||
|
"performedById" TEXT NOT NULL,
|
||||||
|
"performedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "repairs_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "repairs_brokenPartId_fkey" FOREIGN KEY ("brokenPartId") REFERENCES "Part" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "repairs_replacementPartId_fkey" FOREIGN KEY ("replacementPartId") REFERENCES "Part" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "repairs_performedById_fkey" FOREIGN KEY ("performedById") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO "new_repairs" ("brokenPartId", "createdAt", "hostId", "id", "performedAt", "performedById", "replacementPartId", "updatedAt") SELECT "brokenPartId", "createdAt", "hostId", "id", "performedAt", "performedById", "replacementPartId", "updatedAt" FROM "repairs";
|
||||||
|
DROP TABLE "repairs";
|
||||||
|
ALTER TABLE "new_repairs" RENAME TO "repairs";
|
||||||
|
CREATE INDEX "repairs_hostId_performedAt_idx" ON "repairs"("hostId", "performedAt" DESC);
|
||||||
|
CREATE INDEX "repairs_performedById_performedAt_idx" ON "repairs"("performedById", "performedAt" DESC);
|
||||||
|
CREATE INDEX "repairs_brokenPartId_idx" ON "repairs"("brokenPartId");
|
||||||
|
CREATE INDEX "repairs_replacementPartId_idx" ON "repairs"("replacementPartId");
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
@@ -138,7 +138,6 @@ model Part {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
events PartEvent[]
|
events PartEvent[]
|
||||||
tags PartTag[]
|
tags PartTag[]
|
||||||
problemInFms FmPart[]
|
|
||||||
brokenRepairs Repair[] @relation("BrokenRepairs")
|
brokenRepairs Repair[] @relation("BrokenRepairs")
|
||||||
replacementRepairs Repair[] @relation("ReplacementRepairs")
|
replacementRepairs Repair[] @relation("ReplacementRepairs")
|
||||||
|
|
||||||
@@ -197,7 +196,6 @@ model Host {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
parts Part[]
|
parts Part[]
|
||||||
fms Fm[]
|
|
||||||
repairs Repair[]
|
repairs Repair[]
|
||||||
events HostEvent[]
|
events HostEvent[]
|
||||||
|
|
||||||
@@ -221,37 +219,6 @@ model HostEvent {
|
|||||||
@@index([userId])
|
@@index([userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Fm {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
hostId String
|
|
||||||
host Host @relation(fields: [hostId], references: [id], onDelete: Restrict)
|
|
||||||
status String @default("OPEN")
|
|
||||||
problem String
|
|
||||||
openedAt DateTime @default(now())
|
|
||||||
closedAt DateTime?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
problemParts FmPart[]
|
|
||||||
repairs Repair[]
|
|
||||||
|
|
||||||
@@index([status])
|
|
||||||
@@index([hostId])
|
|
||||||
@@index([status, openedAt(sort: Desc)])
|
|
||||||
@@map("fms")
|
|
||||||
}
|
|
||||||
|
|
||||||
model FmPart {
|
|
||||||
fmId String
|
|
||||||
partId String
|
|
||||||
fm Fm @relation(fields: [fmId], references: [id], onDelete: Cascade)
|
|
||||||
part Part @relation(fields: [partId], references: [id], onDelete: Restrict)
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
@@id([fmId, partId])
|
|
||||||
@@index([partId])
|
|
||||||
@@map("fm_parts")
|
|
||||||
}
|
|
||||||
|
|
||||||
model Repair {
|
model Repair {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
hostId String
|
hostId String
|
||||||
@@ -263,13 +230,10 @@ model Repair {
|
|||||||
performedById String
|
performedById String
|
||||||
performedBy User @relation(fields: [performedById], references: [id], onDelete: Restrict)
|
performedBy User @relation(fields: [performedById], references: [id], onDelete: Restrict)
|
||||||
performedAt DateTime @default(now())
|
performedAt DateTime @default(now())
|
||||||
fmId String?
|
|
||||||
fm Fm? @relation(fields: [fmId], references: [id], onDelete: SetNull)
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([hostId, performedAt(sort: Desc)])
|
@@index([hostId, performedAt(sort: Desc)])
|
||||||
@@index([fmId])
|
|
||||||
@@index([performedById, performedAt(sort: Desc)])
|
@@index([performedById, performedAt(sort: Desc)])
|
||||||
@@index([brokenPartId])
|
@@index([brokenPartId])
|
||||||
@@index([replacementPartId])
|
@@index([replacementPartId])
|
||||||
|
|||||||
@@ -30,10 +30,7 @@ export interface PartModelEolSummary {
|
|||||||
export interface OperationsAnalytics {
|
export interface OperationsAnalytics {
|
||||||
repairs7d: number;
|
repairs7d: number;
|
||||||
repairs30d: number;
|
repairs30d: number;
|
||||||
newFms7d: number;
|
|
||||||
avgFmCloseHours30d: number | null;
|
|
||||||
repairsTrend30d: { date: string; count: number }[];
|
repairsTrend30d: { date: string; count: number }[];
|
||||||
openFmsByHost: { hostId: string; hostName: string; count: number }[];
|
|
||||||
custodyBacklog: { userId: string; username: string; count: number }[];
|
custodyBacklog: { userId: string; username: string; count: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +41,5 @@ export interface DashboardAnalytics {
|
|||||||
topBins: BinCount[];
|
topBins: BinCount[];
|
||||||
deployedPastEol: PartModelEolSummary[];
|
deployedPastEol: PartModelEolSummary[];
|
||||||
upcomingEol: PartModelEolSummary[];
|
upcomingEol: PartModelEolSummary[];
|
||||||
openFms: number;
|
|
||||||
operations?: OperationsAnalytics;
|
operations?: OperationsAnalytics;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ export const PartEventType = z.enum([
|
|||||||
'STATE_CHANGED',
|
'STATE_CHANGED',
|
||||||
'LOCATION_CHANGED',
|
'LOCATION_CHANGED',
|
||||||
'FIELD_UPDATED',
|
'FIELD_UPDATED',
|
||||||
'FM_OPENED',
|
|
||||||
'FM_CLOSED',
|
|
||||||
'PART_SWAPPED',
|
'PART_SWAPPED',
|
||||||
'TAG_ADDED',
|
'TAG_ADDED',
|
||||||
'TAG_REMOVED',
|
'TAG_REMOVED',
|
||||||
@@ -41,9 +39,6 @@ export const HostEventType = z.enum([
|
|||||||
]);
|
]);
|
||||||
export type HostEventType = z.infer<typeof HostEventType>;
|
export type HostEventType = z.infer<typeof HostEventType>;
|
||||||
|
|
||||||
export const FmStatus = z.enum(['OPEN', 'CLOSED']);
|
|
||||||
export type FmStatus = z.infer<typeof FmStatus>;
|
|
||||||
|
|
||||||
export const CsvImportStatus = z.enum([
|
export const CsvImportStatus = z.enum([
|
||||||
'PENDING',
|
'PENDING',
|
||||||
'STAGED',
|
'STAGED',
|
||||||
@@ -60,8 +55,6 @@ export const WebhookEventName = z.enum([
|
|||||||
'part.deleted',
|
'part.deleted',
|
||||||
'part.state_changed',
|
'part.state_changed',
|
||||||
'part.location_changed',
|
'part.location_changed',
|
||||||
'fm.opened',
|
|
||||||
'fm.closed',
|
|
||||||
'repair.logged',
|
'repair.logged',
|
||||||
'tag.assigned',
|
'tag.assigned',
|
||||||
'tag.removed',
|
'tag.removed',
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
import { FmStatus } from './enums.js';
|
|
||||||
import { PaginationQuery } from './pagination.js';
|
|
||||||
|
|
||||||
// Host lookup accepts either a uuid `hostId` or a string `assetId` — exactly one.
|
|
||||||
const hostSelector = {
|
|
||||||
hostId: z.string().uuid().optional(),
|
|
||||||
assetId: z.string().trim().min(1).max(128).optional(),
|
|
||||||
};
|
|
||||||
|
|
||||||
function hostSelectorRefine<T extends { hostId?: string; assetId?: string }>(
|
|
||||||
v: T,
|
|
||||||
ctx: z.RefinementCtx,
|
|
||||||
) {
|
|
||||||
const has = [v.hostId, v.assetId].filter((x) => x !== undefined && x !== '').length;
|
|
||||||
if (has !== 1) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: 'Provide exactly one of hostId or assetId',
|
|
||||||
path: ['hostId'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CreateFmRequest = z
|
|
||||||
.object({
|
|
||||||
...hostSelector,
|
|
||||||
problem: z.string().trim().min(1, 'Problem is required').max(2000),
|
|
||||||
problemPartIds: z.array(z.string().uuid()).max(100).optional(),
|
|
||||||
})
|
|
||||||
.superRefine(hostSelectorRefine);
|
|
||||||
export type CreateFmRequest = z.infer<typeof CreateFmRequest>;
|
|
||||||
|
|
||||||
export const UpdateFmRequest = z
|
|
||||||
.object({
|
|
||||||
status: FmStatus.optional(),
|
|
||||||
problem: z.string().trim().min(1).max(2000).optional(),
|
|
||||||
problemPartIds: z.array(z.string().uuid()).max(100).optional(),
|
|
||||||
})
|
|
||||||
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
|
|
||||||
export type UpdateFmRequest = z.infer<typeof UpdateFmRequest>;
|
|
||||||
|
|
||||||
export const FmListQuery = PaginationQuery.extend({
|
|
||||||
status: FmStatus.optional(),
|
|
||||||
hostId: z.string().uuid().optional(),
|
|
||||||
problemPartId: z.string().uuid().optional(),
|
|
||||||
openOnly: z
|
|
||||||
.union([z.literal('true'), z.literal('false'), z.boolean()])
|
|
||||||
.transform((v) => v === true || v === 'true')
|
|
||||||
.optional(),
|
|
||||||
});
|
|
||||||
export type FmListQuery = z.infer<typeof FmListQuery>;
|
|
||||||
@@ -9,7 +9,6 @@ export * from './env.js';
|
|||||||
export * from './pagination.js';
|
export * from './pagination.js';
|
||||||
export * from './hosts.js';
|
export * from './hosts.js';
|
||||||
export * from './host-events.js';
|
export * from './host-events.js';
|
||||||
export * from './fms.js';
|
|
||||||
export * from './repairs.js';
|
export * from './repairs.js';
|
||||||
export * from './custody.js';
|
export * from './custody.js';
|
||||||
export * from './tags.js';
|
export * from './tags.js';
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ export const LogRepairRequest = z
|
|||||||
brokenMpn: z.string().trim().min(1).max(128).optional(),
|
brokenMpn: z.string().trim().min(1).max(128).optional(),
|
||||||
brokenManufacturerId: z.string().uuid().optional(),
|
brokenManufacturerId: z.string().uuid().optional(),
|
||||||
replacementSerial: z.string().trim().min(1).max(128),
|
replacementSerial: z.string().trim().min(1).max(128),
|
||||||
fmId: z.string().uuid().optional(),
|
|
||||||
})
|
})
|
||||||
.superRefine((v, ctx) => {
|
.superRefine((v, ctx) => {
|
||||||
const hostHas = [v.hostId, v.assetId].filter((x) => x !== undefined && x !== '').length;
|
const hostHas = [v.hostId, v.assetId].filter((x) => x !== undefined && x !== '').length;
|
||||||
@@ -33,7 +32,6 @@ export type LogRepairRequest = z.infer<typeof LogRepairRequest>;
|
|||||||
export const RepairListQuery = PaginationQuery.extend({
|
export const RepairListQuery = PaginationQuery.extend({
|
||||||
hostId: z.string().uuid().optional(),
|
hostId: z.string().uuid().optional(),
|
||||||
performedById: z.string().uuid().optional(),
|
performedById: z.string().uuid().optional(),
|
||||||
fmId: z.string().uuid().optional(),
|
|
||||||
since: z.string().datetime().optional(),
|
since: z.string().datetime().optional(),
|
||||||
});
|
});
|
||||||
export type RepairListQuery = z.infer<typeof RepairListQuery>;
|
export type RepairListQuery = z.infer<typeof RepairListQuery>;
|
||||||
|
|||||||
Reference in New Issue
Block a user