diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 7b6847c..c4e2e3a 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -22,7 +22,6 @@ import partRoutes from './routes/parts.js'; import tagRoutes from './routes/tags.js'; import categoryRoutes from './routes/categories.js'; import hostRoutes from './routes/hosts.js'; -import fmRoutes from './routes/fms.js'; import repairRoutes from './routes/repairs.js'; import custodyRoutes from './routes/custody.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/categories', categoryRoutes); app.use('/api/hosts', hostRoutes); -app.use('/api/fms', fmRoutes); app.use('/api/repairs', repairRoutes); app.use('/api/custody', custodyRoutes); app.use('/api/saved-views', savedViewRoutes); diff --git a/apps/api/src/controllers/fms.ts b/apps/api/src/controllers/fms.ts deleted file mode 100644 index 726c98a..0000000 --- a/apps/api/src/controllers/fms.ts +++ /dev/null @@ -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); - } -} diff --git a/apps/api/src/routes/fms.ts b/apps/api/src/routes/fms.ts deleted file mode 100644 index bb79cec..0000000 --- a/apps/api/src/routes/fms.ts +++ /dev/null @@ -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; diff --git a/apps/api/src/services/analytics.test.ts b/apps/api/src/services/analytics.test.ts index de84f13..a74e8ad 100644 --- a/apps/api/src/services/analytics.test.ts +++ b/apps/api/src/services/analytics.test.ts @@ -20,17 +20,12 @@ type FakeArgs = { createdAt: Date; partModelId: string; }[]; - openFms: number; pastEolModels: EolPartModel[]; upcomingEolModels: EolPartModel[]; bins: { id: string; name: string; room: { name: string; site: { name: string } } }[]; // Admin-only inputs. Ignored when isAdmin=false path is exercised. repairs?: { performedAt: Date }[]; - fmsClosed?: { openedAt: Date; closedAt: Date | null }[]; - newFms7d?: number; - openFmGroups?: { hostId: string; count: number }[]; custodyGroups?: { custodianId: string | null; count: number }[]; - hosts?: { id: string; name: string }[]; users?: { id: string; username: string }[]; }; @@ -53,18 +48,6 @@ function makeTx(args: FakeArgs): Tx { }, 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: { findMany: async (q: { where?: { eolDate?: { gt?: Date; lte?: Date; not?: unknown } } }) => { const gt = q.where?.eolDate?.gt; @@ -87,9 +70,6 @@ function makeTx(args: FakeArgs): Tx { return (args.repairs ?? []).filter((r) => r.performedAt >= gte); }, }, - host: { - findMany: async () => args.hosts ?? [], - }, user: { findMany: async () => args.users ?? [], }, @@ -100,20 +80,18 @@ function makeTx(args: FakeArgs): Tx { const now = new Date('2026-04-16T00:00:00.000Z'); 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 HOUR_MS = 60 * 60 * 1000; const EMPTY: FakeArgs = { partCount: 0, stateRows: [], parts: [], - openFms: 0, pastEolModels: [], upcomingEolModels: [], bins: [], }; describe('analytics.dashboard — base fields', () => { - it('aggregates totals, state counts and open FMs', async () => { + it('aggregates totals and state counts', async () => { const tx = makeTx({ ...EMPTY, partCount: 5, @@ -121,12 +99,10 @@ describe('analytics.dashboard — base fields', () => { { state: 'SPARE', count: 3, totalPrice: 1500 }, { state: 'DEPLOYED', count: 2, totalPrice: 8000 }, ], - openFms: 4, }); const r = await dashboard(tx, { isAdmin: false }); expect(r.totalParts).toBe(5); - expect(r.openFms).toBe(4); expect(r.byState).toEqual([ { state: 'SPARE', count: 3, totalPrice: 1500 }, { state: 'DEPLOYED', count: 2, totalPrice: 8000 }, @@ -279,11 +255,7 @@ describe('analytics.dashboard — isAdmin gating', () => { const tx = makeTx({ ...EMPTY, repairs: [{ performedAt: daysAgo(1) }], - fmsClosed: [{ openedAt: daysAgo(2), closedAt: daysAgo(1) }], - newFms7d: 3, - openFmGroups: [{ hostId: 'h1', count: 2 }], custodyGroups: [{ custodianId: 'u1', count: 1 }], - hosts: [{ id: 'h1', name: 'host-1' }], users: [{ id: 'u1', username: 'alice' }], }); @@ -292,10 +264,8 @@ describe('analytics.dashboard — isAdmin gating', () => { expect(r.operations).toMatchObject({ repairs7d: 1, repairs30d: 1, - newFms7d: 3, }); expect(r.operations!.repairsTrend30d).toHaveLength(30); - expect(r.operations!.openFmsByHost).toEqual([{ hostId: 'h1', hostName: 'host-1', count: 2 }]); expect(r.operations!.custodyBacklog).toEqual([ { userId: 'u1', username: 'alice', count: 1 }, ]); @@ -304,43 +274,23 @@ describe('analytics.dashboard — isAdmin gating', () => { describe('analytics.dashboard — operations fields', () => { 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({ ...EMPTY, - repairs: [{ performedAt: daysAgo(5) }, { performedAt: daysAgo(28) }], + repairs: [{ performedAt: realDaysAgo(2) }, { performedAt: realDaysAgo(10) }], }); const r = await dashboard(tx, { isAdmin: true }); const trend = r.operations!.repairsTrend30d; expect(trend).toHaveLength(30); - expect(trend.filter((d) => d.count === 0)).toHaveLength(28); - expect(trend.filter((d) => d.count === 1)).toHaveLength(2); + const totalCount = trend.reduce((s, d) => s + d.count, 0); + expect(totalCount).toBe(2); // Chronological order: earliest first, today last for (let i = 1; i < trend.length; i++) { 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); - }); }); diff --git a/apps/api/src/services/analytics.ts b/apps/api/src/services/analytics.ts index 9c4fc57..bc498f0 100644 --- a/apps/api/src/services/analytics.ts +++ b/apps/api/src/services/analytics.ts @@ -2,7 +2,6 @@ import type { DashboardAnalytics, OperationsAnalytics } from '@vector/shared'; import type { Tx } from './types.js'; const DAY = 24 * 60 * 60 * 1000; -const HOUR = 60 * 60 * 1000; const AGE_BUCKETS: { label: string; maxDays: number | null }[] = [ { label: '0–30d', maxDays: 30 }, @@ -30,7 +29,7 @@ export async function dashboard( const now = new Date(); const upcomingEolCutoff = new Date(now.getTime() + 180 * DAY); - const [totalParts, stateRows, parts, openFms, pastEolModels, upcomingEolModels] = + const [totalParts, stateRows, parts, pastEolModels, upcomingEolModels] = await Promise.all([ tx.part.count(), tx.part.groupBy({ @@ -41,7 +40,6 @@ export async function dashboard( tx.part.findMany({ select: { id: true, state: true, binId: true, createdAt: true, partModelId: true }, }), - tx.fm.count({ where: { status: 'OPEN' } }), tx.partModel.findMany({ where: { eolDate: { not: null, lte: now } }, select: { @@ -139,7 +137,6 @@ export async function dashboard( topBins, deployedPastEol, upcomingEol, - openFms, }; if (!opts.isAdmin) return base; @@ -147,31 +144,13 @@ export async function dashboard( const sevenDaysAgo = new Date(nowMs - 7 * DAY); const thirtyDaysAgo = new Date(nowMs - 30 * DAY); - const [ - repairs7d, - repairs30d, - newFms7d, - closedFms, - recentRepairs, - openFmGroups, - custodyGroups, - ] = await Promise.all([ + const [repairs7d, repairs30d, recentRepairs, custodyGroups] = await Promise.all([ tx.repair.count({ where: { performedAt: { gte: sevenDaysAgo } } }), 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({ where: { performedAt: { gte: thirtyDaysAgo } }, select: { performedAt: true }, }), - tx.fm.groupBy({ - by: ['hostId'], - where: { status: 'OPEN' }, - _count: { _all: true }, - }), tx.part.groupBy({ by: ['custodianId'], 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(); for (const r of recentRepairs) { const key = utcDateKey(r.performedAt); @@ -205,22 +173,6 @@ export async function dashboard( 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] .filter((g): g is typeof g & { custodianId: string } => g.custodianId !== null) .sort((a, b) => b._count._all - a._count._all) @@ -241,10 +193,7 @@ export async function dashboard( const operations: OperationsAnalytics = { repairs7d, repairs30d, - newFms7d, - avgFmCloseHours30d, repairsTrend30d, - openFmsByHost, custodyBacklog, }; diff --git a/apps/api/src/services/fms.test.ts b/apps/api/src/services/fms.test.ts deleted file mode 100644 index 1ffa0c9..0000000 --- a/apps/api/src/services/fms.test.ts +++ /dev/null @@ -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> = {}) { - 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(); - }); -}); diff --git a/apps/api/src/services/fms.ts b/apps/api/src/services/fms.ts deleted file mode 100644 index 7c6c07b..0000000 --- a/apps/api/src/services/fms.ts +++ /dev/null @@ -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; - } -} diff --git a/apps/api/src/services/hosts.test.ts b/apps/api/src/services/hosts.test.ts index c305ca4..6e66dd4 100644 --- a/apps/api/src/services/hosts.test.ts +++ b/apps/api/src/services/hosts.test.ts @@ -192,7 +192,7 @@ describe('hosts.update', () => { }); 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 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: { findMany: vi.fn(async () => [ { @@ -253,43 +242,8 @@ describe('hosts.getTimeline', () => { 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.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 () => { @@ -302,7 +256,6 @@ describe('hosts.getTimeline', () => { findUnique: vi.fn(async () => ({ id: hostId, name: hostName })), }, hostEvent: { findMany: vi.fn(async () => []) }, - fm: { findMany: vi.fn(async () => []) }, repair: { findMany: vi.fn(async () => []) }, partEvent: { findMany: vi.fn(async () => [ diff --git a/apps/api/src/services/hosts.ts b/apps/api/src/services/hosts.ts index 136c130..3e62ecb 100644 --- a/apps/api/src/services/hosts.ts +++ b/apps/api/src/services/hosts.ts @@ -13,6 +13,25 @@ function mapUniqueViolation(target: unknown): string { 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) { const { page, pageSize, q: search } = q; 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) -// - Fms (FM_OPENED at openedAt, FM_CLOSED at closedAt when present) // - Repairs on this host (captures broken/replacement part swaps) // - PartEvents where a part's host field changed to or from this host // (covers ad-hoc arrivals/departures outside the repair flow). // -// The four 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 -// complexity of a UNION query while still giving correct reverse-chronological ordering. +// 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. export type HostTimelineEntry = | { 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: 'PART_ARRIVED'; 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; } -interface FmSummary { - id: string; - status: string; - problem: string; - openedAt: Date; - closedAt: Date | null; -} - interface RepairSummary { id: string; performedAt: Date; @@ -264,18 +271,13 @@ export async function getTimeline(tx: Tx, hostId: string, q: HostTimelineQuery) if (!host) throw errors.notFound('Host'); // 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({ where: { hostId }, orderBy: { createdAt: 'desc' }, take: TIMELINE_SOURCE_CAP, include: { user: { select: { username: true } } }, }), - tx.fm.findMany({ - where: { hostId }, - orderBy: { openedAt: 'desc' }, - take: TIMELINE_SOURCE_CAP, - }), tx.repair.findMany({ where: { hostId }, 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) { entries.push({ type: 'REPAIR', diff --git a/apps/api/src/services/repairs.test.ts b/apps/api/src/services/repairs.test.ts index f02575e..28c47ca 100644 --- a/apps/api/src/services/repairs.test.ts +++ b/apps/api/src/services/repairs.test.ts @@ -65,7 +65,6 @@ function partRow(overrides: Partial>) { function buildTx(options: { parts: Array>; hosts: Array<{ id: string; assetId: string; name: string }>; - fm?: { id: string; hostId: string } | null; existingPartModel?: { id: string; manufacturerId: string; mpn: string } | null; }) { const registry = new Map(options.parts.map((p) => [p.id, p])); @@ -167,12 +166,6 @@ function buildTx(options: { eolDate: null, })), }, - fm: { - findUnique: async (args: { where: { id: string } }) => { - if (options.fm && options.fm.id === args.where.id) return options.fm; - return null; - }, - }, repair: { create: vi.fn(async (args: { data: Record }) => ({ id: 'repair-1', @@ -180,13 +173,11 @@ function buildTx(options: { brokenPartId: args.data.brokenPartId, replacementPartId: args.data.replacementPartId, performedById: args.data.performedById, - fmId: args.data.fmId ?? null, performedAt: new Date('2026-04-15T00:00:00Z'), host: options.hosts.find((h) => h.id === args.data.hostId) ?? host1, brokenPart: registry.get(args.data.brokenPartId as string), replacement: registry.get(args.data.replacementPartId as string), performedBy: { id: actor.id, username: actor.username }, - fm: options.fm ? { id: options.fm.id, status: 'OPEN' } : null, })), }, partEvent: { @@ -433,87 +424,6 @@ describe('repairs.log — validation failures', () => { ).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 () => { const broken = partRow({ id: 'p-broken', diff --git a/apps/api/src/services/repairs.ts b/apps/api/src/services/repairs.ts index 93f2e78..8510575 100644 --- a/apps/api/src/services/repairs.ts +++ b/apps/api/src/services/repairs.ts @@ -4,7 +4,7 @@ import { errors } from '../lib/http-error.js'; import { emit } from '../lib/webhook-emitter.js'; import * as partsSvc from './parts.js'; import * as partModelsSvc from './part-models.js'; -import { resolveHost } from './fms.js'; +import { resolveHost } from './hosts.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 @@ -15,7 +15,6 @@ const repairInclude = { brokenPart: { include: { partModel: true, manufacturer: true } }, replacement: { include: { partModel: true, manufacturer: true } }, performedBy: { select: { id: true, username: true } }, - fm: { select: { id: true, status: true } }, } satisfies Prisma.RepairInclude; export type RepairWithRelations = Prisma.RepairGetPayload<{ include: typeof repairInclude }>; @@ -24,7 +23,6 @@ function buildWhere(q: RepairListQuery): Prisma.RepairWhereInput { const where: Prisma.RepairWhereInput = {}; if (q.hostId) where.hostId = q.hostId; if (q.performedById) where.performedById = q.performedById; - if (q.fmId) where.fmId = q.fmId; if (q.since) where.performedAt = { gte: new Date(q.since) }; return where; } @@ -67,7 +65,6 @@ function repairPayload(r: RepairWithRelations) { }, performedBy: r.performedBy, performedAt: r.performedAt.toISOString(), - fmId: r.fmId, }; } @@ -143,21 +140,12 @@ export async function log( broken = created; } - // 3. Optional FM link — must belong to the same host; we do NOT auto-close it. - 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. + // 3. Custody state is driven by the broken model's destroyOnFail flag. const custodyState = broken.partModel.destroyOnFail ? 'PENDING_DESTRUCTION_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 // automatically when entering custody / DEPLOYED. await partsSvc.update( @@ -173,19 +161,18 @@ export async function log( actor, ); - // 6. Persist the Repair row. + // 5. Persist the Repair row. const repair = await tx.repair.create({ data: { hostId: host.id, brokenPartId: broken.id, replacementPartId: replacement.id, performedById: actor.id, - fmId: input.fmId ?? null, }, include: repairInclude, }); - // 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({ data: [ { diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index c894c15..fa0a804 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -17,8 +17,6 @@ import ManufacturerDetail from './pages/ManufacturerDetail.js'; import PartModels from './pages/PartModels.js'; import PartModelDetail from './pages/PartModelDetail.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 MyCustody from './pages/MyCustody.js'; import Hosts from './pages/Hosts.js'; @@ -68,8 +66,6 @@ export default function App() { } /> } /> } /> - } /> - } /> } /> } /> } /> diff --git a/apps/web/src/components/fms/FmFormDialog.tsx b/apps/web/src/components/fms/FmFormDialog.tsx deleted file mode 100644 index f9b3ea2..0000000 --- a/apps/web/src/components/fms/FmFormDialog.tsx +++ /dev/null @@ -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; - -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({ - 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 ( - - - - Open FM - - Open a Future Maintenance item against a host. Select deployed parts involved (optional). - - - -
- createMutation.mutate(v))} - className="space-y-3" - > - ( - - Host - - - - )} - /> - - ( - - Problem - -