feat: split Repairs into FM, Repair, and Custody workflows
The old Repairs module had grown ticketing-system features (status lifecycle, comments, assignee, notes) that duplicate what the external ticketing tool already owns. Vector only needs to track whether maintenance is open or closed. - Rename RepairJob -> Fm (OPEN/CLOSED only), drop RepairComment, assignee, notes - New Repair table: persistent log of physical part swaps, with ingest on unknown broken MPN via partModels.upsertByMpn - New custody model: PENDING_DROP_IN_CUSTODY / PENDING_DESTRUCTION_IN_CUSTODY states + Part.custodianId, with a "My Custody" page for drop-off - PartModel.destroyOnFail routes broken parts to the destruction path - Host lookup on /fms and /repairs accepts hostId XOR assetId - Wire the dormant webhook emitter: fm.opened, fm.closed, repair.logged - Single fresh Prisma migration (dev DB was wiped, no backfill) Tests: 60 passing (custody transitions in parts.test.ts; new fms.test.ts, repairs.test.ts, custody.test.ts covering happy paths, validation failures, webhook emissions, and ingest-on-unknown-MPN). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
import type { DropOffRequest } from '@vector/shared';
|
||||
import { api } from './client.js';
|
||||
import { getList } from './paginated.js';
|
||||
import type { Part } from './types.js';
|
||||
|
||||
export function listMyCustody(filters: { page?: number; pageSize?: number } = {}) {
|
||||
return getList<Part>('/custody/mine', filters);
|
||||
}
|
||||
|
||||
export async function dropOff(partId: string, input: DropOffRequest): Promise<Part> {
|
||||
const res = await api.post<Part>(`/custody/${partId}/drop-off`, input);
|
||||
return res.data;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { CreateFmRequest, FmStatus, UpdateFmRequest } from '@vector/shared';
|
||||
import { api } from './client.js';
|
||||
import { getList } from './paginated.js';
|
||||
import type { Fm } from './types.js';
|
||||
|
||||
export type FmListFilters = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: FmStatus;
|
||||
hostId?: string;
|
||||
problemPartId?: string;
|
||||
openOnly?: boolean;
|
||||
};
|
||||
|
||||
export function listFms(filters: FmListFilters = {}) {
|
||||
return getList<Fm>('/fms', filters);
|
||||
}
|
||||
|
||||
export async function getFm(id: string): Promise<Fm> {
|
||||
const res = await api.get<Fm>(`/fms/${id}`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createFm(input: CreateFmRequest): Promise<Fm> {
|
||||
const res = await api.post<Fm>('/fms', input);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function updateFm(id: string, input: UpdateFmRequest): Promise<Fm> {
|
||||
const res = await api.patch<Fm>(`/fms/${id}`, input);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteFm(id: string): Promise<void> {
|
||||
await api.delete(`/fms/${id}`);
|
||||
}
|
||||
@@ -18,6 +18,8 @@ export type PartListFilters = {
|
||||
binId?: string;
|
||||
tagId?: string;
|
||||
eolOnly?: boolean;
|
||||
serialNumber?: string;
|
||||
custodianId?: string;
|
||||
};
|
||||
|
||||
export function listParts(filters: PartListFilters) {
|
||||
|
||||
@@ -1,60 +1,27 @@
|
||||
import type {
|
||||
CreateRepairCommentRequest,
|
||||
CreateRepairJobRequest,
|
||||
RepairStatus,
|
||||
UpdateRepairJobRequest,
|
||||
} from '@vector/shared';
|
||||
import type { LogRepairRequest } from '@vector/shared';
|
||||
import { api } from './client.js';
|
||||
import { getList } from './paginated.js';
|
||||
import type { RepairComment, RepairJob } from './types.js';
|
||||
import type { Repair } from './types.js';
|
||||
|
||||
export type RepairListFilters = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: RepairStatus;
|
||||
hostId?: string;
|
||||
problemPartId?: string;
|
||||
assigneeId?: string;
|
||||
openOnly?: boolean;
|
||||
performedById?: string;
|
||||
fmId?: string;
|
||||
since?: string;
|
||||
};
|
||||
|
||||
export function listRepairs(filters: RepairListFilters = {}) {
|
||||
return getList<RepairJob>('/repairs', filters);
|
||||
return getList<Repair>('/repairs', filters);
|
||||
}
|
||||
|
||||
export async function getRepair(id: string): Promise<RepairJob> {
|
||||
const res = await api.get<RepairJob>(`/repairs/${id}`);
|
||||
export async function getRepair(id: string): Promise<Repair> {
|
||||
const res = await api.get<Repair>(`/repairs/${id}`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createRepair(input: CreateRepairJobRequest): Promise<RepairJob> {
|
||||
const res = await api.post<RepairJob>('/repairs', input);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function updateRepair(
|
||||
id: string,
|
||||
input: UpdateRepairJobRequest,
|
||||
): Promise<RepairJob> {
|
||||
const res = await api.patch<RepairJob>(`/repairs/${id}`, input);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteRepair(id: string): Promise<void> {
|
||||
await api.delete(`/repairs/${id}`);
|
||||
}
|
||||
|
||||
export function listRepairComments(
|
||||
id: string,
|
||||
filters: { page?: number; pageSize?: number } = {},
|
||||
) {
|
||||
return getList<RepairComment>(`/repairs/${id}/comments`, filters);
|
||||
}
|
||||
|
||||
export async function addRepairComment(
|
||||
id: string,
|
||||
input: CreateRepairCommentRequest,
|
||||
): Promise<RepairComment> {
|
||||
const res = await api.post<RepairComment>(`/repairs/${id}/comments`, input);
|
||||
export async function logRepair(input: LogRepairRequest): Promise<Repair> {
|
||||
const res = await api.post<Repair>('/repairs', input);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PartEventType, PartState, RepairStatus, Role } from '@vector/shared';
|
||||
import type { FmStatus, PartEventType, PartState, Role } from '@vector/shared';
|
||||
|
||||
// Shapes mirror Prisma rows the API returns (dates serialized as ISO strings).
|
||||
// Keep these in sync with apps/api/src/services responses.
|
||||
@@ -15,6 +15,7 @@ export interface PartModel {
|
||||
manufacturerId: string;
|
||||
mpn: string;
|
||||
eolDate: string | null;
|
||||
destroyOnFail: boolean;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -60,6 +61,7 @@ export interface Part {
|
||||
binId: string | null;
|
||||
categoryId: string | null;
|
||||
hostId: string | null;
|
||||
custodianId: string | null;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -67,6 +69,7 @@ export interface Part {
|
||||
partModel: PartModel;
|
||||
bin: BinWithPath | null;
|
||||
host: Host | null;
|
||||
custodian: Pick<User, 'id' | 'username'> | null;
|
||||
}
|
||||
|
||||
export interface PartEvent {
|
||||
@@ -115,42 +118,47 @@ export interface Category {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface RepairJobProblemPart {
|
||||
repairJobId: string;
|
||||
export interface FmProblemPart {
|
||||
fmId: string;
|
||||
partId: string;
|
||||
createdAt: string;
|
||||
part: Part;
|
||||
}
|
||||
|
||||
export interface RepairJob {
|
||||
export interface Fm {
|
||||
id: string;
|
||||
hostId: string;
|
||||
assigneeId: string | null;
|
||||
status: RepairStatus;
|
||||
status: FmStatus;
|
||||
problem: string;
|
||||
notes: string | null;
|
||||
openedAt: string;
|
||||
closedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
host: Host;
|
||||
assignee: Pick<User, 'id' | 'username' | 'email' | 'role'> | null;
|
||||
problemParts: RepairJobProblemPart[];
|
||||
problemParts: FmProblemPart[];
|
||||
}
|
||||
|
||||
export interface RepairComment {
|
||||
export interface Repair {
|
||||
id: string;
|
||||
repairJobId: string;
|
||||
userId: string | null;
|
||||
content: string;
|
||||
hostId: string;
|
||||
brokenPartId: string;
|
||||
replacementPartId: string;
|
||||
performedById: string;
|
||||
performedAt: string;
|
||||
fmId: string | null;
|
||||
createdAt: string;
|
||||
user: Pick<User, 'id' | 'username'> | null;
|
||||
updatedAt: string;
|
||||
host: Host;
|
||||
brokenPart: Part;
|
||||
replacement: Part;
|
||||
performedBy: Pick<User, 'id' | 'username'>;
|
||||
fm: { id: string; status: FmStatus } | null;
|
||||
}
|
||||
|
||||
export interface SavedView {
|
||||
id: string;
|
||||
userId: string;
|
||||
resource: 'parts' | 'repairs' | 'hosts' | 'manufacturers';
|
||||
resource: 'parts' | 'fms' | 'repairs' | 'hosts' | 'manufacturers';
|
||||
name: string;
|
||||
filterJson: unknown;
|
||||
createdAt: string;
|
||||
|
||||
@@ -49,12 +49,22 @@ export const queryKeys = {
|
||||
detail: (id: string) => [...queryKeys.hosts.all, 'detail', id] as const,
|
||||
deployedParts: (id: string) => [...queryKeys.hosts.all, 'deployed-parts', id] as const,
|
||||
},
|
||||
fms: {
|
||||
all: ['fms'] as const,
|
||||
list: (filters?: Record<string, unknown>) =>
|
||||
[...queryKeys.fms.all, 'list', filters ?? {}] as const,
|
||||
detail: (id: string) => [...queryKeys.fms.all, 'detail', id] as const,
|
||||
},
|
||||
repairs: {
|
||||
all: ['repairs'] as const,
|
||||
list: (filters?: Record<string, unknown>) =>
|
||||
[...queryKeys.repairs.all, 'list', filters ?? {}] as const,
|
||||
detail: (id: string) => [...queryKeys.repairs.all, 'detail', id] as const,
|
||||
comments: (id: string) => [...queryKeys.repairs.all, 'comments', id] as const,
|
||||
},
|
||||
custody: {
|
||||
all: ['custody'] as const,
|
||||
mine: (filters?: Record<string, unknown>) =>
|
||||
[...queryKeys.custody.all, 'mine', filters ?? {}] as const,
|
||||
},
|
||||
partModels: {
|
||||
all: ['part-models'] as const,
|
||||
|
||||
Reference in New Issue
Block a user