diff --git a/apps/api/src/controllers/part-models.ts b/apps/api/src/controllers/part-models.ts index 8de9cac..47d7594 100644 --- a/apps/api/src/controllers/part-models.ts +++ b/apps/api/src/controllers/part-models.ts @@ -28,6 +28,20 @@ export async function get(req: Request<{ id: string }>, res: Response, next: Nex } } +export async function getInsights( + req: Request<{ id: string }>, + res: Response, + next: NextFunction, +) { + try { + const insights = await prisma.$transaction((tx) => svc.getInsights(tx, req.params.id)); + if (!insights) throw errors.notFound('Part model'); + res.json(insights); + } catch (err) { + next(err); + } +} + export async function create(req: Request, res: Response, next: NextFunction) { try { const input = req.validated!.body as CreatePartModelRequest; diff --git a/apps/api/src/routes/part-models.ts b/apps/api/src/routes/part-models.ts index 8ab7298..8746ca3 100644 --- a/apps/api/src/routes/part-models.ts +++ b/apps/api/src/routes/part-models.ts @@ -12,6 +12,7 @@ const router = Router(); router.get('/', requireAuth, validate('query', PartModelListQuery), ctrl.list); router.get('/:id', requireAuth, ctrl.get); +router.get('/:id/insights', requireAuth, ctrl.getInsights); router.post( '/', requireAuth, diff --git a/apps/api/src/services/part-models.test.ts b/apps/api/src/services/part-models.test.ts new file mode 100644 index 0000000..d705331 --- /dev/null +++ b/apps/api/src/services/part-models.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest'; +import type { Tx } from './types.js'; +import { getInsights } from './part-models.js'; + +// Minimal in-memory tx double exercising getInsights(). We only stub the +// calls that getInsights actually makes. +function makeTx(args: { + modelExists: boolean; + partCount: number; + stateRows: { state: string; count: number; totalPrice: number }[]; + priceAgg: { + sum: number | null; + avg: number | null; + min: number | null; + max: number | null; + count: number; + }; + repairCount: number; + distinctFailedBrokenPartIds: string[]; + fmCount: number; +}): Tx { + const tx = { + partModel: { + findUnique: async () => (args.modelExists ? { id: 'pm' } : null), + }, + part: { + count: async () => args.partCount, + groupBy: async () => + args.stateRows.map((s) => ({ + state: s.state, + _count: { _all: s.count }, + _sum: { price: s.totalPrice }, + })), + aggregate: async () => ({ + _sum: { price: args.priceAgg.sum }, + _avg: { price: args.priceAgg.avg }, + _min: { price: args.priceAgg.min }, + _max: { price: args.priceAgg.max }, + _count: { _all: args.priceAgg.count }, + }), + }, + repair: { + count: async () => args.repairCount, + findMany: async () => + args.distinctFailedBrokenPartIds.map((brokenPartId) => ({ brokenPartId })), + }, + fm: { + count: async () => args.fmCount, + }, + }; + return tx as unknown as Tx; +} + +describe('part-models.getInsights', () => { + it('returns null when model does not exist', async () => { + const tx = makeTx({ + modelExists: false, + partCount: 0, + stateRows: [], + priceAgg: { sum: null, avg: null, min: null, max: null, count: 0 }, + repairCount: 0, + distinctFailedBrokenPartIds: [], + fmCount: 0, + }); + const r = await getInsights(tx, 'nope'); + expect(r).toBeNull(); + }); + + it('aggregates totalParts and groups by state', async () => { + const tx = makeTx({ + modelExists: true, + partCount: 3, + stateRows: [ + { state: 'SPARE', count: 1, totalPrice: 100 }, + { state: 'DEPLOYED', count: 2, totalPrice: 800 }, + ], + priceAgg: { sum: 900, avg: 450, min: 100, max: 500, count: 2 }, + repairCount: 0, + distinctFailedBrokenPartIds: [], + fmCount: 0, + }); + + const r = await getInsights(tx, 'pm'); + expect(r).not.toBeNull(); + expect(r!.totalParts).toBe(3); + expect(r!.byState).toEqual([ + { state: 'SPARE', count: 1, totalPrice: 100 }, + { state: 'DEPLOYED', count: 2, totalPrice: 800 }, + ]); + }); + + it('computes price stats from aggregate (countWithPrice drives average)', async () => { + const tx = makeTx({ + modelExists: true, + partCount: 3, + stateRows: [], + // 2 priced parts ($100 + $500), 1 null-priced part + priceAgg: { sum: 600, avg: 300, min: 100, max: 500, count: 2 }, + repairCount: 0, + distinctFailedBrokenPartIds: [], + fmCount: 0, + }); + + const r = await getInsights(tx, 'pm'); + expect(r!.priceStats).toEqual({ + total: 600, + average: 300, + min: 100, + max: 500, + countWithPrice: 2, + }); + }); + + it('zeros price stats when no parts are priced', async () => { + const tx = makeTx({ + modelExists: true, + partCount: 2, + stateRows: [], + priceAgg: { sum: null, avg: null, min: null, max: null, count: 0 }, + repairCount: 0, + distinctFailedBrokenPartIds: [], + fmCount: 0, + }); + + const r = await getInsights(tx, 'pm'); + expect(r!.priceStats).toEqual({ + total: 0, + average: 0, + min: null, + max: null, + countWithPrice: 0, + }); + }); + + it('counts repairs, distinct failed parts, and FMs implicating the model', async () => { + const tx = makeTx({ + modelExists: true, + partCount: 5, + stateRows: [], + priceAgg: { sum: 0, avg: null, min: null, max: null, count: 0 }, + repairCount: 3, + // one part failed twice → 2 distinct broken parts + distinctFailedBrokenPartIds: ['part-a', 'part-b'], + fmCount: 2, + }); + + const r = await getInsights(tx, 'pm'); + expect(r!.failures).toEqual({ + repairs: 3, + distinctFailedParts: 2, + fmsImplicating: 2, + }); + }); +}); diff --git a/apps/api/src/services/part-models.ts b/apps/api/src/services/part-models.ts index 52a08fa..d061cef 100644 --- a/apps/api/src/services/part-models.ts +++ b/apps/api/src/services/part-models.ts @@ -1,6 +1,7 @@ import { Prisma } from '@vector/db'; import type { CreatePartModelRequest, + PartModelInsights, PartModelListQuery, UpdatePartModelRequest, } from '@vector/shared'; @@ -38,6 +39,59 @@ export function get(tx: Tx, id: string) { return tx.partModel.findUnique({ where: { id }, include: partModelInclude }); } +export async function getInsights(tx: Tx, id: string): Promise { + const model = await tx.partModel.findUnique({ where: { id }, select: { id: true } }); + if (!model) return null; + + const [totalParts, stateRows, priceAgg, repairs, failedParts, fmsImplicating] = await Promise.all([ + tx.part.count({ where: { partModelId: id } }), + tx.part.groupBy({ + by: ['state'], + where: { partModelId: id }, + _count: { _all: true }, + _sum: { price: true }, + }), + tx.part.aggregate({ + where: { partModelId: id, price: { not: null } }, + _sum: { price: true }, + _avg: { price: true }, + _min: { price: true }, + _max: { price: true }, + _count: { _all: true }, + }), + tx.repair.count({ where: { brokenPart: { partModelId: id } } }), + tx.repair.findMany({ + where: { brokenPart: { partModelId: id } }, + select: { brokenPartId: true }, + distinct: ['brokenPartId'], + }), + tx.fm.count({ where: { problemParts: { some: { part: { partModelId: id } } } } }), + ]); + + const byState = stateRows.map((row) => ({ + state: row.state as PartModelInsights['byState'][number]['state'], + count: row._count._all, + totalPrice: row._sum.price ?? 0, + })); + + return { + totalParts, + byState, + priceStats: { + total: priceAgg._sum.price ?? 0, + average: priceAgg._avg.price ?? 0, + min: priceAgg._min.price, + max: priceAgg._max.price, + countWithPrice: priceAgg._count._all, + }, + failures: { + repairs, + distinctFailedParts: failedParts.length, + fmsImplicating, + }, + }; +} + export async function create(tx: Tx, input: CreatePartModelRequest) { try { return await tx.partModel.create({ diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 6f21d0f..8814378 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -13,6 +13,7 @@ import PartDetail from './pages/PartDetail.js'; import Locations from './pages/Locations.js'; import Manufacturers from './pages/Manufacturers.js'; import PartModels from './pages/PartModels.js'; +import PartModelDetail from './pages/PartModelDetail.js'; import Fms from './pages/Fms.js'; import FmDetail from './pages/FmDetail.js'; import Repairs from './pages/Repairs.js'; @@ -60,6 +61,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/web/src/components/StatCard.tsx b/apps/web/src/components/StatCard.tsx new file mode 100644 index 0000000..43ee82f --- /dev/null +++ b/apps/web/src/components/StatCard.tsx @@ -0,0 +1,21 @@ +import { Card, CardContent } from '@vector/ui'; + +interface StatCardProps { + label: string; + value: string; + sub?: string; +} + +export function StatCard({ label, value, sub }: StatCardProps) { + return ( + + +
+ {label} +
+
{value}
+ {sub &&
{sub}
} +
+
+ ); +} diff --git a/apps/web/src/lib/api/part-models.ts b/apps/web/src/lib/api/part-models.ts index 033d349..a120fc3 100644 --- a/apps/web/src/lib/api/part-models.ts +++ b/apps/web/src/lib/api/part-models.ts @@ -1,5 +1,6 @@ import type { CreatePartModelRequest, + PartModelInsights, UpdatePartModelRequest, } from '@vector/shared'; import { api } from './client.js'; @@ -40,3 +41,8 @@ export async function updatePartModel( export async function deletePartModel(id: string): Promise { await api.delete(`/part-models/${id}`); } + +export async function getPartModelInsights(id: string): Promise { + const res = await api.get(`/part-models/${id}/insights`); + return res.data; +} diff --git a/apps/web/src/lib/api/parts.ts b/apps/web/src/lib/api/parts.ts index 5167f37..e79ca7d 100644 --- a/apps/web/src/lib/api/parts.ts +++ b/apps/web/src/lib/api/parts.ts @@ -15,6 +15,7 @@ export type PartListFilters = { state?: string; manufacturerId?: string; categoryId?: string; + partModelId?: string; binId?: string; tagId?: string; eolOnly?: boolean; diff --git a/apps/web/src/lib/queryKeys.ts b/apps/web/src/lib/queryKeys.ts index 74f475a..517f094 100644 --- a/apps/web/src/lib/queryKeys.ts +++ b/apps/web/src/lib/queryKeys.ts @@ -73,6 +73,7 @@ export const queryKeys = { list: (filters?: Record) => [...queryKeys.partModels.all, 'list', filters ?? {}] as const, detail: (id: string) => [...queryKeys.partModels.all, 'detail', id] as const, + insights: (id: string) => [...queryKeys.partModels.all, 'insights', id] as const, }, tags: { all: ['tags'] as const, diff --git a/apps/web/src/pages/PartModelDetail.tsx b/apps/web/src/pages/PartModelDetail.tsx new file mode 100644 index 0000000..3067b83 --- /dev/null +++ b/apps/web/src/pages/PartModelDetail.tsx @@ -0,0 +1,413 @@ +import { useMemo, useState } from 'react'; +import { Link, useNavigate, useParams } from 'react-router-dom'; +import type { ColumnDef } from '@tanstack/react-table'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { ArrowLeft, Edit, Trash2 } from 'lucide-react'; +import { Bar, BarChart, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; +import { toast } from 'sonner'; +import type { PartState } from '@vector/shared'; +import { + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Separator, + Skeleton, +} from '@vector/ui'; +import { deletePartModel, getPartModel, getPartModelInsights } from '../lib/api/part-models.js'; +import { listParts } from '../lib/api/parts.js'; +import { ApiRequestError } from '../lib/api/client.js'; +import { queryKeys } from '../lib/queryKeys.js'; +import { useAuth } from '../contexts/AuthContext.js'; +import { DataTable } from '../components/data-table/DataTable.js'; +import { PartStateBadge } from '../components/parts/PartStateBadge.js'; +import { PartModelFormDialog } from '../components/part-models/PartModelFormDialog.js'; +import { ConfirmDialog } from '../components/ConfirmDialog.js'; +import { StatCard } from '../components/StatCard.js'; +import type { Part } from '../lib/api/types.js'; + +const STATE_LABELS: Record = { + SPARE: 'Spare', + DEPLOYED: 'Deployed', + BROKEN: 'Broken', + PENDING_DESTRUCTION: 'Pending destruction', + PENDING_DROP_IN_CUSTODY: 'In custody', + PENDING_DESTRUCTION_IN_CUSTODY: 'In custody (destroy)', + PENDING_REPAIR: 'Held for repair', +}; + +const STATE_FILL: Record = { + SPARE: 'hsl(217 91% 60%)', + DEPLOYED: 'hsl(142 71% 45%)', + BROKEN: 'hsl(0 84% 60%)', + PENDING_DESTRUCTION: 'hsl(38 92% 50%)', + PENDING_DROP_IN_CUSTODY: 'hsl(262 83% 58%)', + PENDING_DESTRUCTION_IN_CUSTODY: 'hsl(340 82% 52%)', + PENDING_REPAIR: 'hsl(197 80% 50%)', +}; + +function currency(dollars: number): string { + return dollars.toLocaleString(undefined, { style: 'currency', currency: 'USD' }); +} + +function DetailRow({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +export default function PartModelDetail() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { user } = useAuth(); + const isAdmin = user?.role === 'ADMIN'; + + const [editOpen, setEditOpen] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + + const modelQuery = useQuery({ + queryKey: queryKeys.partModels.detail(id!), + queryFn: () => getPartModel(id!), + enabled: Boolean(id), + }); + + const insightsQuery = useQuery({ + queryKey: queryKeys.partModels.insights(id!), + queryFn: () => getPartModelInsights(id!), + enabled: Boolean(id), + }); + + const deleteMutation = useMutation({ + mutationFn: () => deletePartModel(id!), + onSuccess: () => { + toast.success('Part model deleted'); + queryClient.invalidateQueries({ queryKey: queryKeys.partModels.all }); + navigate('/part-models', { replace: true }); + }, + onError: (err) => { + toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed'); + }, + }); + + const partColumns = useMemo[]>( + () => [ + { + accessorKey: 'serialNumber', + header: 'Serial', + cell: ({ row }) => ( + + {row.original.serialNumber} + + ), + }, + { + accessorKey: 'state', + header: 'State', + cell: ({ row }) => , + }, + { + id: 'location', + header: 'Location', + cell: ({ row }) => { + const p = row.original; + if (p.host) { + return ( + + {p.host.assetId} / {p.host.name} + + ); + } + if (p.custodian) { + return Custody: {p.custodian.username}; + } + if (p.bin?.fullPath) { + return {p.bin.fullPath}; + } + return Unassigned; + }, + }, + { + accessorKey: 'price', + header: 'Price', + cell: ({ row }) => + row.original.price != null ? ( + {currency(row.original.price)} + ) : ( + + ), + }, + ], + [], + ); + + if (modelQuery.isPending) { + return ( +
+ + + +
+ ); + } + + if (modelQuery.isError || !modelQuery.data) { + const msg = + modelQuery.error instanceof ApiRequestError + ? modelQuery.error.body.message + : 'Part model not found.'; + return ( + + + Part model unavailable + {msg} + + + + + + ); + } + + const model = modelQuery.data; + const insights = insightsQuery.data; + const eolDate = model.eolDate ? new Date(model.eolDate) : null; + const pastEol = eolDate ? eolDate.getTime() <= Date.now() : false; + + const failureRate = + insights && insights.totalParts > 0 + ? Math.round((insights.failures.repairs / insights.totalParts) * 100) + : null; + + const chartData = + insights?.byState.map((s) => ({ + name: STATE_LABELS[s.state], + state: s.state, + count: s.count, + })) ?? []; + + return ( +
+
+
+ +
+

{model.mpn}

+

+ {model.manufacturer?.name ?? '—'} + {' · '} + {model.category?.name ?? 'Uncategorized'} + {eolDate && ( + <> + {' · EOL '} + + {eolDate.toLocaleDateString()} + + + )} +

+
+
+
+ {isAdmin && ( + + )} + {isAdmin && ( + + )} +
+
+ +
+ {insightsQuery.isPending || !insights ? ( + Array.from({ length: 5 }).map((_, i) => ) + ) : ( + <> + + + 0 + ? currency(insights.priceStats.average) + : '—' + } + sub={ + insights.priceStats.countWithPrice > 0 + ? `${insights.priceStats.countWithPrice} priced` + : 'No priced units' + } + /> + + + + )} +
+ +
+ + + Summary + + +
+ + {model.mpn}} + /> + Uncategorized + ) + } + /> + + {eolDate.toLocaleDateString()} + + ) : ( + + ) + } + /> + + + + +
+ {model.notes && ( + <> + +
+

Notes

+

{model.notes}

+
+ + )} +
+
+ + + + State breakdown + How the fleet is distributed across lifecycle states. + + + {insightsQuery.isPending ? ( + + ) : chartData.length === 0 ? ( +
+ No units of this model yet. +
+ ) : ( + + + + + + + {chartData.map((s) => ( + + ))} + + + + )} +
+
+
+ + + + Parts of this model + Every unit tracked under {model.mpn}. + + + > + columns={partColumns} + getRowId={(p) => p.id} + queryKey={(params) => + queryKeys.parts.list({ + partModelId: id, + page: params.page, + pageSize: params.pageSize, + q: params.q, + sort: params.sort, + }) + } + queryFn={(params) => + listParts({ + partModelId: id, + page: params.page, + pageSize: params.pageSize, + q: params.q, + sort: params.sort, + }) + } + searchPlaceholder="Search serial..." + emptyState={ +
+ No parts of this model yet. +
+ } + /> +
+
+ + + deleteMutation.mutate()} + /> +
+ ); +} diff --git a/apps/web/src/pages/PartModels.tsx b/apps/web/src/pages/PartModels.tsx index cd52fad..19dd2c2 100644 --- a/apps/web/src/pages/PartModels.tsx +++ b/apps/web/src/pages/PartModels.tsx @@ -1,7 +1,8 @@ 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 { Check, Edit, Layers, MoreHorizontal, Plus, Trash2 } from 'lucide-react'; +import { Check, Edit, Eye, Layers, MoreHorizontal, Plus, Trash2 } from 'lucide-react'; import { toast } from 'sonner'; import { Badge, @@ -25,6 +26,7 @@ import { useAuth } from '../contexts/AuthContext.js'; export default function PartModels() { const { user } = useAuth(); const isAdmin = user?.role === 'ADMIN'; + const navigate = useNavigate(); const queryClient = useQueryClient(); const [createOpen, setCreateOpen] = useState(false); @@ -55,7 +57,12 @@ export default function PartModels() { accessorKey: 'mpn', header: 'MPN', cell: ({ row }) => ( - {row.original.mpn} + + {row.original.mpn} + ), }, { @@ -104,33 +111,40 @@ export default function PartModels() { id: 'actions', header: () => Actions, size: 40, - cell: ({ row }) => - isAdmin ? ( - - - - - - setEditing(row.original)}> - - Edit - - - setDeleting(row.original)} - className="text-destructive focus:text-destructive" - > - - Delete - - - - ) : null, + cell: ({ row }) => ( + + + + + + navigate(`/part-models/${row.original.id}`)}> + + View + + {isAdmin && ( + <> + setEditing(row.original)}> + + Edit + + + setDeleting(row.original)} + className="text-destructive focus:text-destructive" + > + + Delete + + + )} + + + ), }, ], - [isAdmin], + [isAdmin, navigate], ); return ( diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 359f474..4a1e7ae 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -18,3 +18,4 @@ export * from './webhooks.js'; export * from './saved-views.js'; export * from './csv-imports.js'; export * from './analytics.js'; +export * from './part-model-insights.js'; diff --git a/packages/shared/src/part-model-insights.ts b/packages/shared/src/part-model-insights.ts new file mode 100644 index 0000000..caa66fc --- /dev/null +++ b/packages/shared/src/part-model-insights.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; +import { PartState } from './enums.js'; + +export interface PartModelStateCount { + state: z.infer; + count: number; + totalPrice: number; +} + +export interface PartModelPriceStats { + total: number; + average: number; + min: number | null; + max: number | null; + countWithPrice: number; +} + +export interface PartModelFailureStats { + repairs: number; + distinctFailedParts: number; + fmsImplicating: number; +} + +export interface PartModelInsights { + totalParts: number; + byState: PartModelStateCount[]; + priceStats: PartModelPriceStats; + failures: PartModelFailureStats; +}