diff --git a/apps/api/src/controllers/categories.ts b/apps/api/src/controllers/categories.ts index 589e786..ed65642 100644 --- a/apps/api/src/controllers/categories.ts +++ b/apps/api/src/controllers/categories.ts @@ -6,6 +6,7 @@ import type { UpdateCategoryRequest, } from '@vector/shared'; import * as svc from '../services/categories.js'; +import { errors } from '../lib/http-error.js'; export async function list(req: Request, res: Response, next: NextFunction) { try { @@ -17,6 +18,30 @@ export async function list(req: Request, res: Response, next: NextFunction) { } } +export async function get(req: Request<{ id: string }>, res: Response, next: NextFunction) { + try { + const category = await prisma.$transaction((tx) => svc.get(tx, req.params.id)); + if (!category) throw errors.notFound('Category'); + res.json(category); + } catch (err) { + next(err); + } +} + +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('Category'); + res.json(insights); + } catch (err) { + next(err); + } +} + export async function create(req: Request, res: Response, next: NextFunction) { try { const input = req.validated!.body as CreateCategoryRequest; diff --git a/apps/api/src/routes/categories.ts b/apps/api/src/routes/categories.ts index 07749b5..1f37438 100644 --- a/apps/api/src/routes/categories.ts +++ b/apps/api/src/routes/categories.ts @@ -11,6 +11,8 @@ import { validate } from '../middleware/validate.js'; const router = Router(); router.get('/', requireAuth, validate('query', CategoryListQuery), ctrl.list); +router.get('/:id', requireAuth, ctrl.get); +router.get('/:id/insights', requireAuth, ctrl.getInsights); router.post('/', requireAuth, requireRole('ADMIN'), validate('body', CreateCategoryRequest), ctrl.create); router.patch('/:id', requireAuth, requireRole('ADMIN'), validate('body', UpdateCategoryRequest), ctrl.update); router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove); diff --git a/apps/api/src/services/categories.test.ts b/apps/api/src/services/categories.test.ts new file mode 100644 index 0000000..c0cda32 --- /dev/null +++ b/apps/api/src/services/categories.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from 'vitest'; +import type { Tx } from './types.js'; +import { getInsights } from './categories.js'; + +// Minimal in-memory tx double exercising categories.getInsights(). +// Only the calls the function makes are stubbed. +interface FakeArgs { + categoryExists: boolean; + totalPartModels: number; + totalParts: number; + priceAgg: { + sum: number | null; + avg: number | null; + min: number | null; + max: number | null; + count: number; + }; + repairCount: number; + distinctFailedBrokenPartIds: string[]; + fmCount: number; + modelStateGroups: { partModelId: string; state: string; count: number }[]; + allModels: { + id: string; + mpn: string; + manufacturer: { id: string; name: string }; + }[]; + eolModels: { id: string; mpn: string; eolDate: Date | null }[]; + repairsWithModel: { brokenPart: { partModelId: string } }[]; +} + +function makeTx(args: FakeArgs): Tx { + const tx = { + category: { + findUnique: async () => (args.categoryExists ? { id: 'cat' } : null), + }, + partModel: { + count: async () => args.totalPartModels, + findMany: async (opts: { where?: { eolDate?: unknown } }) => { + if (opts?.where && 'eolDate' in opts.where) return args.eolModels; + return args.allModels; + }, + }, + part: { + count: async () => args.totalParts, + groupBy: async () => + args.modelStateGroups.map((g) => ({ + partModelId: g.partModelId, + state: g.state, + _count: { _all: g.count }, + })), + 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 (opts: { select?: { brokenPartId?: boolean } }) => { + if (opts?.select && 'brokenPartId' in opts.select) { + return args.distinctFailedBrokenPartIds.map((brokenPartId) => ({ brokenPartId })); + } + return args.repairsWithModel; + }, + }, + fm: { + count: async () => args.fmCount, + }, + }; + return tx as unknown as Tx; +} + +const empty: FakeArgs = { + categoryExists: true, + totalPartModels: 0, + totalParts: 0, + priceAgg: { sum: null, avg: null, min: null, max: null, count: 0 }, + repairCount: 0, + distinctFailedBrokenPartIds: [], + fmCount: 0, + modelStateGroups: [], + allModels: [], + eolModels: [], + repairsWithModel: [], +}; + +describe('categories.getInsights', () => { + it('returns null when category does not exist', async () => { + const tx = makeTx({ ...empty, categoryExists: false }); + const r = await getInsights(tx, 'nope'); + expect(r).toBeNull(); + }); + + it('aggregates totals and price stats', async () => { + const tx = makeTx({ + ...empty, + totalPartModels: 3, + totalParts: 12, + priceAgg: { sum: 1200, avg: 300, min: 100, max: 500, count: 4 }, + }); + const r = await getInsights(tx, 'cat'); + expect(r!.totalPartModels).toBe(3); + expect(r!.totalParts).toBe(12); + expect(r!.priceStats).toEqual({ + total: 1200, + average: 300, + min: 100, + max: 500, + countWithPrice: 4, + }); + }); + + it('zeros price stats when no parts priced', async () => { + const tx = makeTx({ ...empty, totalParts: 5 }); + const r = await getInsights(tx, 'cat'); + expect(r!.priceStats).toEqual({ + total: 0, + average: 0, + min: null, + max: null, + countWithPrice: 0, + }); + }); + + it('counts repairs, distinct failed parts, and FMs implicating the category', async () => { + const tx = makeTx({ + ...empty, + repairCount: 5, + distinctFailedBrokenPartIds: ['p1', 'p2', 'p3'], + fmCount: 4, + }); + const r = await getInsights(tx, 'cat'); + expect(r!.failures).toEqual({ repairs: 5, distinctFailedParts: 3, fmsImplicating: 4 }); + }); + + it('derives topModelsByUnits from modelStateGroups, sorted desc, truncated to 8', async () => { + const modelStateGroups = Array.from({ length: 9 }).map((_, i) => ({ + partModelId: `pm${i + 1}`, + state: 'SPARE', + count: i + 1, + })); + const allModels = modelStateGroups.map((g) => ({ + id: g.partModelId, + mpn: `MPN-${g.partModelId}`, + manufacturer: { id: 'mfr1', name: 'Acme' }, + })); + const tx = makeTx({ ...empty, modelStateGroups, allModels }); + const r = await getInsights(tx, 'cat'); + expect(r!.topModelsByUnits).toHaveLength(8); + expect(r!.topModelsByUnits[0]).toEqual({ partModelId: 'pm9', mpn: 'MPN-pm9', count: 9 }); + expect(r!.topModelsByUnits[7]).toEqual({ partModelId: 'pm2', mpn: 'MPN-pm2', count: 2 }); + expect(r!.topModelsByUnits.find((m) => m.partModelId === 'pm1')).toBeUndefined(); + }); + + it('groups failuresByModel, joins MPN, and sorts desc', async () => { + const tx = makeTx({ + ...empty, + allModels: [ + { id: 'pmA', mpn: 'AAA', manufacturer: { id: 'm1', name: 'Acme' } }, + { id: 'pmB', mpn: 'BBB', manufacturer: { id: 'm1', name: 'Acme' } }, + ], + repairsWithModel: [ + { brokenPart: { partModelId: 'pmA' } }, + { brokenPart: { partModelId: 'pmA' } }, + { brokenPart: { partModelId: 'pmA' } }, + { brokenPart: { partModelId: 'pmB' } }, + ], + }); + const r = await getInsights(tx, 'cat'); + expect(r!.failuresByModel).toEqual([ + { partModelId: 'pmA', mpn: 'AAA', repairs: 3 }, + { partModelId: 'pmB', mpn: 'BBB', repairs: 1 }, + ]); + }); + + it('groups byManufacturer from allModels, sorted desc', async () => { + const tx = makeTx({ + ...empty, + allModels: [ + { id: '1', mpn: 'A', manufacturer: { id: 'm1', name: 'Acme' } }, + { id: '2', mpn: 'B', manufacturer: { id: 'm1', name: 'Acme' } }, + { id: '3', mpn: 'C', manufacturer: { id: 'm2', name: 'Beta' } }, + ], + }); + const r = await getInsights(tx, 'cat'); + expect(r!.byManufacturer).toEqual([ + { manufacturerId: 'm1', manufacturerName: 'Acme', count: 2 }, + { manufacturerId: 'm2', manufacturerName: 'Beta', count: 1 }, + ]); + }); + + it('pastEolModels includes only models with deployedCount > 0', async () => { + const tx = makeTx({ + ...empty, + modelStateGroups: [ + { partModelId: 'pmEOL1', state: 'DEPLOYED', count: 2 }, + { partModelId: 'pmEOL1', state: 'SPARE', count: 1 }, + { partModelId: 'pmEOL2', state: 'SPARE', count: 5 }, + ], + eolModels: [ + { id: 'pmEOL1', mpn: 'OLD-1', eolDate: new Date('2024-01-01') }, + { id: 'pmEOL2', mpn: 'OLD-2', eolDate: new Date('2024-01-01') }, + ], + }); + const r = await getInsights(tx, 'cat'); + expect(r!.pastEolModels).toEqual([ + { + partModelId: 'pmEOL1', + mpn: 'OLD-1', + eolDate: new Date('2024-01-01').toISOString(), + deployedCount: 2, + }, + ]); + }); +}); diff --git a/apps/api/src/services/categories.ts b/apps/api/src/services/categories.ts index 4d8699e..5aa6b50 100644 --- a/apps/api/src/services/categories.ts +++ b/apps/api/src/services/categories.ts @@ -1,5 +1,6 @@ import { Prisma } from '@vector/db'; import type { + CategoryInsights, CategoryListQuery, CreateCategoryRequest, UpdateCategoryRequest, @@ -7,6 +8,152 @@ import type { import { errors } from '../lib/http-error.js'; import type { Tx } from './types.js'; +export function get(tx: Tx, id: string) { + return tx.category.findUnique({ + where: { id }, + include: { _count: { select: { partModels: true } } }, + }); +} + +export async function getInsights(tx: Tx, id: string): Promise { + const category = await tx.category.findUnique({ where: { id }, select: { id: true } }); + if (!category) return null; + + const now = new Date(); + const modelWhere = { partModel: { categoryId: id } }; + + const [ + totalPartModels, + totalParts, + priceAgg, + repairsCount, + distinctFailedParts, + fmsImplicating, + modelStateGroups, + allModels, + eolModels, + repairsWithModel, + ] = await Promise.all([ + tx.partModel.count({ where: { categoryId: id } }), + tx.part.count({ where: modelWhere }), + tx.part.aggregate({ + where: { ...modelWhere, price: { not: null } }, + _sum: { price: true }, + _avg: { price: true }, + _min: { price: true }, + _max: { price: true }, + _count: { _all: true }, + }), + tx.repair.count({ where: { brokenPart: modelWhere } }), + tx.repair.findMany({ + where: { brokenPart: modelWhere }, + select: { brokenPartId: true }, + distinct: ['brokenPartId'], + }), + tx.fm.count({ + where: { problemParts: { some: { part: modelWhere } } }, + }), + tx.part.groupBy({ + by: ['partModelId', 'state'], + where: modelWhere, + _count: { _all: true }, + }), + tx.partModel.findMany({ + where: { categoryId: id }, + select: { + id: true, + mpn: true, + manufacturer: { select: { id: true, name: true } }, + }, + }), + tx.partModel.findMany({ + where: { categoryId: id, eolDate: { not: null, lte: now } }, + select: { id: true, mpn: true, eolDate: true }, + }), + tx.repair.findMany({ + where: { brokenPart: modelWhere }, + select: { brokenPart: { select: { partModelId: true } } }, + }), + ]); + + const mpnById = new Map(allModels.map((m) => [m.id, m.mpn])); + + const unitsByModel = new Map(); + const deployedByModel = new Map(); + for (const row of modelStateGroups) { + const n = row._count._all; + unitsByModel.set(row.partModelId, (unitsByModel.get(row.partModelId) ?? 0) + n); + if (row.state === 'DEPLOYED') { + deployedByModel.set(row.partModelId, (deployedByModel.get(row.partModelId) ?? 0) + n); + } + } + + const topModelsByUnits = [...unitsByModel.entries()] + .map(([partModelId, count]) => ({ + partModelId, + mpn: mpnById.get(partModelId) ?? '', + count, + })) + .sort((a, b) => b.count - a.count) + .slice(0, 8); + + const failuresByModelMap = new Map(); + for (const r of repairsWithModel) { + const pmId = r.brokenPart.partModelId; + failuresByModelMap.set(pmId, (failuresByModelMap.get(pmId) ?? 0) + 1); + } + const failuresByModel = [...failuresByModelMap.entries()] + .map(([partModelId, repairs]) => ({ + partModelId, + mpn: mpnById.get(partModelId) ?? '', + repairs, + })) + .sort((a, b) => b.repairs - a.repairs) + .slice(0, 8); + + const manufacturerCounts = new Map(); + for (const m of allModels) { + const key = m.manufacturer.id; + const entry = manufacturerCounts.get(key); + if (entry) entry.count += 1; + else manufacturerCounts.set(key, { id: key, name: m.manufacturer.name, count: 1 }); + } + const byManufacturer = [...manufacturerCounts.values()] + .map((m) => ({ manufacturerId: m.id, manufacturerName: m.name, count: m.count })) + .sort((a, b) => b.count - a.count); + + const pastEolModels = eolModels + .map((m) => ({ + partModelId: m.id, + mpn: m.mpn, + eolDate: m.eolDate ? m.eolDate.toISOString() : '', + deployedCount: deployedByModel.get(m.id) ?? 0, + })) + .filter((m) => m.deployedCount > 0) + .sort((a, b) => b.deployedCount - a.deployedCount); + + return { + totalPartModels, + totalParts, + 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: repairsCount, + distinctFailedParts: distinctFailedParts.length, + fmsImplicating, + }, + byManufacturer, + topModelsByUnits, + failuresByModel, + pastEolModels, + }; +} + export async function list(tx: Tx, q: CategoryListQuery) { const { page, pageSize } = q; const [data, total] = await Promise.all([ diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index bd8e342..c894c15 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -16,6 +16,7 @@ import Manufacturers from './pages/Manufacturers.js'; 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'; @@ -66,6 +67,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/web/src/lib/api/categories.ts b/apps/web/src/lib/api/categories.ts index 5327f7c..f150059 100644 --- a/apps/web/src/lib/api/categories.ts +++ b/apps/web/src/lib/api/categories.ts @@ -1,4 +1,8 @@ -import type { CreateCategoryRequest, UpdateCategoryRequest } from '@vector/shared'; +import type { + CategoryInsights, + CreateCategoryRequest, + UpdateCategoryRequest, +} from '@vector/shared'; import { api } from './client.js'; import { getList } from './paginated.js'; import type { Category } from './types.js'; @@ -7,6 +11,16 @@ export function listCategories(filters: { page?: number; pageSize?: number } = { return getList('/categories', filters); } +export async function getCategory(id: string): Promise { + const res = await api.get(`/categories/${id}`); + return res.data; +} + +export async function getCategoryInsights(id: string): Promise { + const res = await api.get(`/categories/${id}/insights`); + return res.data; +} + export async function createCategory(input: CreateCategoryRequest): Promise { const res = await api.post('/categories', input); return res.data; diff --git a/apps/web/src/lib/api/types.ts b/apps/web/src/lib/api/types.ts index 86bac1c..9f4619e 100644 --- a/apps/web/src/lib/api/types.ts +++ b/apps/web/src/lib/api/types.ts @@ -165,8 +165,10 @@ export interface Tag { export interface Category { id: string; name: string; + description?: string | null; createdAt: string; updatedAt: string; + _count?: { partModels: number }; } export interface FmProblemPart { diff --git a/apps/web/src/lib/queryKeys.ts b/apps/web/src/lib/queryKeys.ts index 2d4e5b6..84c348f 100644 --- a/apps/web/src/lib/queryKeys.ts +++ b/apps/web/src/lib/queryKeys.ts @@ -86,6 +86,8 @@ export const queryKeys = { all: ['categories'] as const, list: (filters?: Record) => [...queryKeys.categories.all, 'list', filters ?? {}] as const, + detail: (id: string) => [...queryKeys.categories.all, 'detail', id] as const, + insights: (id: string) => [...queryKeys.categories.all, 'insights', id] as const, }, webhooks: { all: ['webhooks'] as const, diff --git a/apps/web/src/pages/CategoryDetail.tsx b/apps/web/src/pages/CategoryDetail.tsx new file mode 100644 index 0000000..2b2316a --- /dev/null +++ b/apps/web/src/pages/CategoryDetail.tsx @@ -0,0 +1,521 @@ +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 { AlertTriangle, ArrowLeft, Edit, Trash2 } from 'lucide-react'; +import { + Bar, + BarChart, + Cell, + Legend, + Pie, + PieChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import { toast } from 'sonner'; +import { + Badge, + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Separator, + Skeleton, +} from '@vector/ui'; +import { + deleteCategory, + getCategory, + getCategoryInsights, + updateCategory, +} from '../lib/api/categories.js'; +import { listPartModels } from '../lib/api/part-models.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 { NamePromptDialog } from '../components/NamePromptDialog.js'; +import { ConfirmDialog } from '../components/ConfirmDialog.js'; +import { StatCard } from '../components/StatCard.js'; +import type { PartModel } from '../lib/api/types.js'; + +const MANUFACTURER_COLORS = [ + 'hsl(217 91% 60%)', + 'hsl(142 71% 45%)', + 'hsl(262 83% 58%)', + 'hsl(38 92% 50%)', + 'hsl(340 82% 52%)', + 'hsl(197 80% 50%)', + 'hsl(0 84% 60%)', + 'hsl(160 60% 40%)', +]; + +const BAR_COLOR = 'hsl(217 91% 60%)'; +const FAILURE_COLOR = 'hsl(0 84% 60%)'; + +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 CategoryDetail() { + 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 categoryQuery = useQuery({ + queryKey: queryKeys.categories.detail(id!), + queryFn: () => getCategory(id!), + enabled: Boolean(id), + }); + + const insightsQuery = useQuery({ + queryKey: queryKeys.categories.insights(id!), + queryFn: () => getCategoryInsights(id!), + enabled: Boolean(id), + }); + + const renameMutation = useMutation({ + mutationFn: (name: string) => updateCategory(id!, { name }), + onSuccess: () => { + toast.success('Category renamed'); + queryClient.invalidateQueries({ queryKey: queryKeys.categories.all }); + setEditOpen(false); + }, + onError: (err) => { + toast.error(err instanceof ApiRequestError ? err.body.message : 'Rename failed'); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: () => deleteCategory(id!), + onSuccess: () => { + toast.success('Category deleted'); + queryClient.invalidateQueries({ queryKey: queryKeys.categories.all }); + navigate('/parts', { replace: true }); + }, + onError: (err) => { + toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed'); + }, + }); + + const modelColumns = useMemo[]>( + () => [ + { + accessorKey: 'mpn', + header: 'MPN', + cell: ({ row }) => ( + + {row.original.mpn} + + ), + }, + { + id: 'manufacturer', + header: 'Manufacturer', + cell: ({ row }) => + row.original.manufacturer ? ( + + {row.original.manufacturer.name} + + ) : ( + + ), + }, + { + accessorKey: 'eolDate', + header: 'EOL', + cell: ({ row }) => { + const iso = row.original.eolDate; + if (!iso) return ; + const pastEol = new Date(iso).getTime() <= Date.now(); + return ( +
+ {new Date(iso).toLocaleDateString()} + {pastEol && Past EOL} +
+ ); + }, + }, + { + id: 'deployedCount', + header: 'Parts', + cell: ({ row }) => ( + {row.original._count?.parts ?? 0} + ), + }, + ], + [], + ); + + if (categoryQuery.isPending) { + return ( +
+ + + +
+ ); + } + + if (categoryQuery.isError || !categoryQuery.data) { + const msg = + categoryQuery.error instanceof ApiRequestError + ? categoryQuery.error.body.message + : 'Category not found.'; + return ( + + + Category unavailable + {msg} + + + + + + ); + } + + const category = categoryQuery.data; + const insights = insightsQuery.data; + + const failureRate = + insights && insights.totalParts > 0 + ? Math.round((insights.failures.repairs / insights.totalParts) * 100) + : null; + + const topModelsData = + insights?.topModelsByUnits.map((m) => ({ + name: m.mpn, + count: m.count, + })) ?? []; + + const failuresData = + insights?.failuresByModel.map((m) => ({ + name: m.mpn, + repairs: m.repairs, + })) ?? []; + + const manufacturerData = + insights?.byManufacturer.map((m) => ({ + name: m.manufacturerName, + count: m.count, + })) ?? []; + + return ( +
+
+
+ +
+

{category.name}

+

+ {insights + ? `${insights.totalPartModels} MPNs · ${insights.totalParts} parts` + : '—'} +

+
+
+
+ {isAdmin && ( + + )} + {isAdmin && ( + + )} +
+
+ +
+ {insightsQuery.isPending || !insights ? ( + Array.from({ length: 6 }).map((_, i) => ) + ) : ( + <> + + + + 0 + ? currency(insights.priceStats.average) + : '—' + } + sub={ + insights.priceStats.countWithPrice > 0 + ? `${insights.priceStats.countWithPrice} priced` + : 'No priced parts' + } + /> + + + + )} +
+ +
+ + + Top MPNs by units + + Where this category's inventory is concentrated. + + + + {insightsQuery.isPending ? ( + + ) : topModelsData.length === 0 ? ( +
+ No parts in this category yet. +
+ ) : ( + + + + + + + + + )} +
+
+ + + + Manufacturer mix + Which vendors supply this category. + + + {insightsQuery.isPending ? ( + + ) : manufacturerData.length === 0 ? ( +
+ No MPNs yet. +
+ ) : ( + + + + {manufacturerData.map((_m, i) => ( + + ))} + + + + + + )} +
+
+ + + + Failures by MPN + + Which models in this category have failed most. + + + + {insightsQuery.isPending ? ( + + ) : failuresData.length === 0 ? ( +
+ No failures recorded in this category. +
+ ) : ( + + + + + + + + + )} +
+
+ + + + Summary + + +
+ + {category.description && ( + + )} + {category._count?.partModels ?? '—'} + } + /> + + + +
+
+
+
+ + {insights && insights.pastEolModels.length > 0 && ( + + + + + Past-EOL MPNs with deployed parts + + + These models have passed their end-of-life date — plan replacements. + + + + {insights.pastEolModels.map((m) => ( +
+
+
{m.mpn}
+ {m.eolDate && ( +
+ EOL {new Date(m.eolDate).toLocaleDateString()} +
+ )} +
+
+ + {m.deployedCount} deployed + + +
+
+ ))} +
+
+ )} + + + + Part models + Every MPN in this category. + + + > + columns={modelColumns} + getRowId={(m) => m.id} + queryKey={(params) => + queryKeys.partModels.list({ + categoryId: id, + page: params.page, + pageSize: params.pageSize, + q: params.q, + }) + } + queryFn={(params) => + listPartModels({ + categoryId: id, + page: params.page, + pageSize: params.pageSize, + q: params.q, + }) + } + searchPlaceholder="Search MPN..." + emptyState={ +
+ No MPNs in this category yet. +
+ } + /> +
+
+ + renameMutation.mutate(name)} + /> + deleteMutation.mutate()} + /> +
+ ); +} diff --git a/apps/web/src/pages/ManufacturerDetail.tsx b/apps/web/src/pages/ManufacturerDetail.tsx index 201eb67..c550839 100644 --- a/apps/web/src/pages/ManufacturerDetail.tsx +++ b/apps/web/src/pages/ManufacturerDetail.tsx @@ -122,7 +122,11 @@ export default function ManufacturerDetail() { header: 'Category', cell: ({ row }) => row.original.category ? ( - {row.original.category.name} + + + {row.original.category.name} + + ) : ( ), diff --git a/apps/web/src/pages/PartModelDetail.tsx b/apps/web/src/pages/PartModelDetail.tsx index 4e0e830..5a70d0e 100644 --- a/apps/web/src/pages/PartModelDetail.tsx +++ b/apps/web/src/pages/PartModelDetail.tsx @@ -220,7 +220,16 @@ export default function PartModelDetail() { '—' )} {' · '} - {model.category?.name ?? 'Uncategorized'} + {model.category ? ( + + {model.category.name} + + ) : ( + 'Uncategorized' + )} {eolDate && ( <> {' · EOL '} @@ -319,7 +328,14 @@ export default function PartModelDetail() { + {model.category.name} + + ) : ( Uncategorized ) } diff --git a/apps/web/src/pages/PartModels.tsx b/apps/web/src/pages/PartModels.tsx index 86d437a..ec2483b 100644 --- a/apps/web/src/pages/PartModels.tsx +++ b/apps/web/src/pages/PartModels.tsx @@ -70,7 +70,11 @@ export default function PartModels() { header: 'Category', cell: ({ row }) => row.original.category ? ( - {row.original.category.name} + + + {row.original.category.name} + + ) : ( ), diff --git a/apps/web/src/pages/Parts.tsx b/apps/web/src/pages/Parts.tsx index eae6653..070f4f3 100644 --- a/apps/web/src/pages/Parts.tsx +++ b/apps/web/src/pages/Parts.tsx @@ -141,9 +141,12 @@ export default function Parts() { header: 'Category', cell: ({ row }) => row.original.partModel.category ? ( - + {row.original.partModel.category.name} - + ) : ( ), diff --git a/packages/shared/src/category-insights.ts b/packages/shared/src/category-insights.ts new file mode 100644 index 0000000..332fcee --- /dev/null +++ b/packages/shared/src/category-insights.ts @@ -0,0 +1,49 @@ +export interface CategoryPriceStats { + total: number; + average: number; + min: number | null; + max: number | null; + countWithPrice: number; +} + +export interface CategoryFailureStats { + repairs: number; + distinctFailedParts: number; + fmsImplicating: number; +} + +export interface CategoryManufacturerCount { + manufacturerId: string; + manufacturerName: string; + count: number; +} + +export interface CategoryModelCount { + partModelId: string; + mpn: string; + count: number; +} + +export interface CategoryModelFailureCount { + partModelId: string; + mpn: string; + repairs: number; +} + +export interface CategoryPastEolModel { + partModelId: string; + mpn: string; + eolDate: string; + deployedCount: number; +} + +export interface CategoryInsights { + totalPartModels: number; + totalParts: number; + priceStats: CategoryPriceStats; + failures: CategoryFailureStats; + byManufacturer: CategoryManufacturerCount[]; + topModelsByUnits: CategoryModelCount[]; + failuresByModel: CategoryModelFailureCount[]; + pastEolModels: CategoryPastEolModel[]; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index fa68cf1..9a4e2a9 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -20,3 +20,4 @@ export * from './csv-imports.js'; export * from './analytics.js'; export * from './part-model-insights.js'; export * from './manufacturer-insights.js'; +export * from './category-insights.js';