From 1d53e81d5ebb36df28021197d6c7e8c2a84d35a2 Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 17 Apr 2026 15:10:37 -0400 Subject: [PATCH] feat(manufacturers): detail page with MPN-level insights Adds /manufacturers/:id with vendor-wide KPIs, top MPNs by units, failures by MPN, category mix, past-EOL exposure, and a filtered PartModels table. Wires upstream links from PartDetail and PartModelDetail so the manufacturer name is a navigable anchor. Co-Authored-By: Claude Opus 4.7 --- apps/api/src/controllers/manufacturers.ts | 24 + apps/api/src/routes/manufacturers.ts | 2 + apps/api/src/services/manufacturers.test.ts | 227 +++++++++ apps/api/src/services/manufacturers.ts | 145 ++++++ apps/web/src/App.tsx | 2 + apps/web/src/lib/api/manufacturers.ts | 11 + apps/web/src/lib/api/types.ts | 1 + apps/web/src/lib/queryKeys.ts | 1 + apps/web/src/pages/ManufacturerDetail.tsx | 495 +++++++++++++++++++ apps/web/src/pages/Manufacturers.tsx | 70 ++- apps/web/src/pages/PartDetail.tsx | 2 +- apps/web/src/pages/PartModelDetail.tsx | 27 +- packages/shared/src/index.ts | 1 + packages/shared/src/manufacturer-insights.ts | 49 ++ 14 files changed, 1027 insertions(+), 30 deletions(-) create mode 100644 apps/api/src/services/manufacturers.test.ts create mode 100644 apps/web/src/pages/ManufacturerDetail.tsx create mode 100644 packages/shared/src/manufacturer-insights.ts diff --git a/apps/api/src/controllers/manufacturers.ts b/apps/api/src/controllers/manufacturers.ts index d777aad..4331a24 100644 --- a/apps/api/src/controllers/manufacturers.ts +++ b/apps/api/src/controllers/manufacturers.ts @@ -18,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 m = await prisma.$transaction((tx) => svc.get(tx, req.params.id)); + if (!m) throw errors.notFound('Manufacturer'); + res.json(m); + } 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('Manufacturer'); + res.json(insights); + } catch (err) { + next(err); + } +} + export async function create(req: Request, res: Response, next: NextFunction) { try { const input = req.validated!.body as CreateManufacturerRequest; diff --git a/apps/api/src/routes/manufacturers.ts b/apps/api/src/routes/manufacturers.ts index 9ee8190..7d97165 100644 --- a/apps/api/src/routes/manufacturers.ts +++ b/apps/api/src/routes/manufacturers.ts @@ -11,6 +11,8 @@ import { validate } from '../middleware/validate.js'; const router = Router(); router.get('/', requireAuth, validate('query', PaginationQuery), ctrl.list); +router.get('/:id', requireAuth, ctrl.get); +router.get('/:id/insights', requireAuth, ctrl.getInsights); router.post('/', requireAuth, requireRole('ADMIN'), validate('body', CreateManufacturerRequest), ctrl.create); router.patch('/:id', requireAuth, requireRole('ADMIN'), validate('body', UpdateManufacturerRequest), ctrl.update); router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove); diff --git a/apps/api/src/services/manufacturers.test.ts b/apps/api/src/services/manufacturers.test.ts new file mode 100644 index 0000000..1fa4772 --- /dev/null +++ b/apps/api/src/services/manufacturers.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from 'vitest'; +import type { Tx } from './types.js'; +import { getInsights } from './manufacturers.js'; + +// Minimal in-memory tx double exercising manufacturers.getInsights(). +// Only the calls the function makes are stubbed. +interface FakeArgs { + mfrExists: 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; + // rows used by part.groupBy({ by: ['partModelId','state'] }) + modelStateGroups: { partModelId: string; state: string; count: number }[]; + // rows for the partModel.findMany(select: { id, mpn, category }) call + allModels: { + id: string; + mpn: string; + category: { id: string; name: string } | null; + }[]; + // rows for the EOL partModel.findMany call + eolModels: { id: string; mpn: string; eolDate: Date | null }[]; + // rows for repair.findMany(select: { brokenPart: { partModelId } }) call + repairsWithModel: { brokenPart: { partModelId: string } }[]; +} + +function makeTx(args: FakeArgs): Tx { + const tx = { + manufacturer: { + findUnique: async () => (args.mfrExists ? { id: 'mfr' } : null), + }, + partModel: { + count: async () => args.totalPartModels, + findMany: async (opts: { where?: { eolDate?: unknown } }) => { + // Distinguish the two findMany calls by whether `where.eolDate` is set + 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 } }) => { + // distinguish the two findMany calls by selected fields + 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 = { + mfrExists: 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('manufacturers.getInsights', () => { + it('returns null when manufacturer does not exist', async () => { + const tx = makeTx({ ...empty, mfrExists: 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, 'mfr'); + 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, 'mfr'); + expect(r!.priceStats).toEqual({ + total: 0, + average: 0, + min: null, + max: null, + countWithPrice: 0, + }); + }); + + it('counts repairs, distinct failed parts, and FMs implicating the manufacturer', async () => { + const tx = makeTx({ + ...empty, + repairCount: 5, + distinctFailedBrokenPartIds: ['p1', 'p2', 'p3'], + fmCount: 4, + }); + const r = await getInsights(tx, 'mfr'); + expect(r!.failures).toEqual({ repairs: 5, distinctFailedParts: 3, fmsImplicating: 4 }); + }); + + it('derives topModelsByUnits from modelStateGroups, sorted desc, truncated to 8', async () => { + // 9 MPNs to confirm truncation. Count matches index+1 so pm9 is biggest. + 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}`, + category: null, + })); + const tx = makeTx({ ...empty, modelStateGroups, allModels }); + const r = await getInsights(tx, 'mfr'); + 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 }); + // pm1 (count=1) should be truncated out + 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', category: null }, + { id: 'pmB', mpn: 'BBB', category: null }, + ], + // pmA has 3 repairs, pmB has 1 + repairsWithModel: [ + { brokenPart: { partModelId: 'pmA' } }, + { brokenPart: { partModelId: 'pmA' } }, + { brokenPart: { partModelId: 'pmA' } }, + { brokenPart: { partModelId: 'pmB' } }, + ], + }); + const r = await getInsights(tx, 'mfr'); + expect(r!.failuresByModel).toEqual([ + { partModelId: 'pmA', mpn: 'AAA', repairs: 3 }, + { partModelId: 'pmB', mpn: 'BBB', repairs: 1 }, + ]); + }); + + it('groups byCategory with Uncategorized fallback for null categories', async () => { + const tx = makeTx({ + ...empty, + allModels: [ + { id: '1', mpn: 'A', category: { id: 'c1', name: 'Network' } }, + { id: '2', mpn: 'B', category: { id: 'c1', name: 'Network' } }, + { id: '3', mpn: 'C', category: null }, + ], + }); + const r = await getInsights(tx, 'mfr'); + expect(r!.byCategory).toEqual([ + { categoryId: 'c1', categoryName: 'Network', count: 2 }, + { categoryId: null, categoryName: 'Uncategorized', 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 }, + // pmEOL2 is past EOL but has NO deployed parts → should be filtered out + { 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, 'mfr'); + 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/manufacturers.ts b/apps/api/src/services/manufacturers.ts index 82cc95e..4987ebf 100644 --- a/apps/api/src/services/manufacturers.ts +++ b/apps/api/src/services/manufacturers.ts @@ -1,12 +1,157 @@ import { Prisma } from '@vector/db'; import type { CreateManufacturerRequest, + ManufacturerInsights, UpdateManufacturerRequest, PaginationQuery, } from '@vector/shared'; import { errors } from '../lib/http-error.js'; import type { Tx } from './types.js'; +export function get(tx: Tx, id: string) { + return tx.manufacturer.findUnique({ + where: { id }, + include: { _count: { select: { parts: true, partModels: true } } }, + }); +} + +export async function getInsights(tx: Tx, id: string): Promise { + const model = await tx.manufacturer.findUnique({ where: { id }, select: { id: true } }); + if (!model) return null; + + const now = new Date(); + + const [ + totalPartModels, + totalParts, + priceAgg, + repairsCount, + distinctFailedParts, + fmsImplicating, + modelStateGroups, + allModels, + eolModels, + repairsWithModel, + ] = await Promise.all([ + tx.partModel.count({ where: { manufacturerId: id } }), + tx.part.count({ where: { manufacturerId: id } }), + tx.part.aggregate({ + where: { manufacturerId: id, price: { not: null } }, + _sum: { price: true }, + _avg: { price: true }, + _min: { price: true }, + _max: { price: true }, + _count: { _all: true }, + }), + tx.repair.count({ where: { brokenPart: { manufacturerId: id } } }), + tx.repair.findMany({ + where: { brokenPart: { manufacturerId: id } }, + select: { brokenPartId: true }, + distinct: ['brokenPartId'], + }), + tx.fm.count({ where: { problemParts: { some: { part: { manufacturerId: id } } } } }), + tx.part.groupBy({ + by: ['partModelId', 'state'], + where: { manufacturerId: id }, + _count: { _all: true }, + }), + tx.partModel.findMany({ + where: { manufacturerId: id }, + select: { + id: true, + mpn: true, + category: { select: { id: true, name: true } }, + }, + }), + tx.partModel.findMany({ + where: { manufacturerId: id, eolDate: { not: null, lte: now } }, + select: { id: true, mpn: true, eolDate: true }, + }), + tx.repair.findMany({ + where: { brokenPart: { manufacturerId: id } }, + 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 categoryCounts = new Map(); + for (const m of allModels) { + const key = m.category?.id ?? 'uncategorized'; + const name = m.category?.name ?? 'Uncategorized'; + const entry = categoryCounts.get(key); + if (entry) entry.count += 1; + else categoryCounts.set(key, { id: m.category?.id ?? null, name, count: 1 }); + } + const byCategory = [...categoryCounts.values()] + .map((c) => ({ categoryId: c.id, categoryName: c.name, count: c.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, + }, + byCategory, + topModelsByUnits, + failuresByModel, + pastEolModels, + }; +} + export async function list(tx: Tx, q: PaginationQuery) { 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 8814378..d3ba64b 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -12,6 +12,7 @@ import Parts from './pages/Parts.js'; import PartDetail from './pages/PartDetail.js'; import Locations from './pages/Locations.js'; 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 Fms from './pages/Fms.js'; @@ -60,6 +61,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/web/src/lib/api/manufacturers.ts b/apps/web/src/lib/api/manufacturers.ts index 44a819b..d20264e 100644 --- a/apps/web/src/lib/api/manufacturers.ts +++ b/apps/web/src/lib/api/manufacturers.ts @@ -1,5 +1,6 @@ import type { CreateManufacturerRequest, + ManufacturerInsights, UpdateManufacturerRequest, } from '@vector/shared'; import { api } from './client.js'; @@ -15,6 +16,16 @@ export function listManufacturers(filters: ManufacturerListFilters = {}) { return getList('/manufacturers', filters); } +export async function getManufacturer(id: string): Promise { + const res = await api.get(`/manufacturers/${id}`); + return res.data; +} + +export async function getManufacturerInsights(id: string): Promise { + const res = await api.get(`/manufacturers/${id}/insights`); + return res.data; +} + export async function createManufacturer(input: CreateManufacturerRequest): Promise { const res = await api.post('/manufacturers', input); return res.data; diff --git a/apps/web/src/lib/api/types.ts b/apps/web/src/lib/api/types.ts index f00bacb..86bac1c 100644 --- a/apps/web/src/lib/api/types.ts +++ b/apps/web/src/lib/api/types.ts @@ -15,6 +15,7 @@ export interface Manufacturer { name: string; createdAt: string; updatedAt: string; + _count?: { parts: number; partModels: number }; } export interface PartModel { diff --git a/apps/web/src/lib/queryKeys.ts b/apps/web/src/lib/queryKeys.ts index 517f094..5ad18b2 100644 --- a/apps/web/src/lib/queryKeys.ts +++ b/apps/web/src/lib/queryKeys.ts @@ -20,6 +20,7 @@ export const queryKeys = { list: (filters?: Record) => [...queryKeys.manufacturers.all, 'list', filters ?? {}] as const, detail: (id: string) => [...queryKeys.manufacturers.all, 'detail', id] as const, + insights: (id: string) => [...queryKeys.manufacturers.all, 'insights', id] as const, }, sites: { all: ['sites'] as const, diff --git a/apps/web/src/pages/ManufacturerDetail.tsx b/apps/web/src/pages/ManufacturerDetail.tsx new file mode 100644 index 0000000..201eb67 --- /dev/null +++ b/apps/web/src/pages/ManufacturerDetail.tsx @@ -0,0 +1,495 @@ +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 { + deleteManufacturer, + getManufacturer, + getManufacturerInsights, +} from '../lib/api/manufacturers.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 { ManufacturerFormDialog } from '../components/manufacturers/ManufacturerFormDialog.js'; +import { ConfirmDialog } from '../components/ConfirmDialog.js'; +import { StatCard } from '../components/StatCard.js'; +import type { PartModel } from '../lib/api/types.js'; + +const CATEGORY_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 ManufacturerDetail() { + 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 mfrQuery = useQuery({ + queryKey: queryKeys.manufacturers.detail(id!), + queryFn: () => getManufacturer(id!), + enabled: Boolean(id), + }); + + const insightsQuery = useQuery({ + queryKey: queryKeys.manufacturers.insights(id!), + queryFn: () => getManufacturerInsights(id!), + enabled: Boolean(id), + }); + + const deleteMutation = useMutation({ + mutationFn: () => deleteManufacturer(id!), + onSuccess: () => { + toast.success('Manufacturer deleted'); + queryClient.invalidateQueries({ queryKey: queryKeys.manufacturers.all }); + navigate('/manufacturers', { 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: 'category', + header: 'Category', + cell: ({ row }) => + row.original.category ? ( + {row.original.category.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 (mfrQuery.isPending) { + return ( +
+ + + +
+ ); + } + + if (mfrQuery.isError || !mfrQuery.data) { + const msg = + mfrQuery.error instanceof ApiRequestError + ? mfrQuery.error.body.message + : 'Manufacturer not found.'; + return ( + + + Manufacturer unavailable + {msg} + + + + + + ); + } + + const mfr = mfrQuery.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 categoryData = + insights?.byCategory.map((c) => ({ + name: c.categoryName, + count: c.count, + })) ?? []; + + return ( +
+
+
+ +
+

{mfr.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 vendor's inventory is concentrated. + + + {insightsQuery.isPending ? ( + + ) : topModelsData.length === 0 ? ( +
+ No parts from this manufacturer yet. +
+ ) : ( + + + + + + + + + )} +
+
+ + + + Category mix + What kinds of parts this vendor supplies. + + + {insightsQuery.isPending ? ( + + ) : categoryData.length === 0 ? ( +
+ No MPNs yet. +
+ ) : ( + + + + {categoryData.map((_c, i) => ( + + ))} + + + + + + )} +
+
+ + + + Failures by MPN + + Which of this vendor's models have failed most — the "stop buying the X" signal. + + + + {insightsQuery.isPending ? ( + + ) : failuresData.length === 0 ? ( +
+ No failures recorded for this vendor. +
+ ) : ( + + + + + + + + + )} +
+
+ + + + Summary + + +
+ + {mfr._count?.partModels ?? '—'}} + /> + {mfr._count?.parts ?? '—'}} + /> + + + +
+
+
+
+ + {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 this manufacturer supplies. + + + > + columns={modelColumns} + getRowId={(m) => m.id} + queryKey={(params) => + queryKeys.partModels.list({ + manufacturerId: id, + page: params.page, + pageSize: params.pageSize, + q: params.q, + }) + } + queryFn={(params) => + listPartModels({ + manufacturerId: id, + page: params.page, + pageSize: params.pageSize, + q: params.q, + }) + } + searchPlaceholder="Search MPN..." + emptyState={ +
+ No MPNs from this manufacturer yet. +
+ } + /> +
+
+ + + deleteMutation.mutate()} + /> +
+ ); +} diff --git a/apps/web/src/pages/Manufacturers.tsx b/apps/web/src/pages/Manufacturers.tsx index 92477fb..66945ab 100644 --- a/apps/web/src/pages/Manufacturers.tsx +++ b/apps/web/src/pages/Manufacturers.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 { Building, Edit, MoreHorizontal, Plus, Trash2 } from 'lucide-react'; +import { Building, Edit, Eye, MoreHorizontal, Plus, Trash2 } from 'lucide-react'; import { toast } from 'sonner'; import { Button, @@ -24,6 +25,7 @@ import { useAuth } from '../contexts/AuthContext.js'; export default function Manufacturers() { const { user } = useAuth(); const isAdmin = user?.role === 'ADMIN'; + const navigate = useNavigate(); const queryClient = useQueryClient(); const [createOpen, setCreateOpen] = useState(false); @@ -46,7 +48,14 @@ export default function Manufacturers() { { accessorKey: 'name', header: 'Name', - cell: ({ row }) => {row.original.name}, + cell: ({ row }) => ( + + {row.original.name} + + ), }, { accessorKey: 'createdAt', @@ -61,33 +70,40 @@ export default function Manufacturers() { 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(`/manufacturers/${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/apps/web/src/pages/PartDetail.tsx b/apps/web/src/pages/PartDetail.tsx index f9ad199..5fa0676 100644 --- a/apps/web/src/pages/PartDetail.tsx +++ b/apps/web/src/pages/PartDetail.tsx @@ -154,7 +154,7 @@ export default function PartDetail() { label="Manufacturer" value={ {part.manufacturer.name} diff --git a/apps/web/src/pages/PartModelDetail.tsx b/apps/web/src/pages/PartModelDetail.tsx index 3067b83..4e0e830 100644 --- a/apps/web/src/pages/PartModelDetail.tsx +++ b/apps/web/src/pages/PartModelDetail.tsx @@ -209,7 +209,16 @@ export default function PartModelDetail() {

{model.mpn}

- {model.manufacturer?.name ?? '—'} + {model.manufacturer ? ( + + {model.manufacturer.name} + + ) : ( + '—' + )} {' · '} {model.category?.name ?? 'Uncategorized'} {eolDate && ( @@ -288,7 +297,21 @@ export default function PartModelDetail() {

- + + {model.manufacturer.name} + + ) : ( + '—' + ) + } + /> {model.mpn}} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 4a1e7ae..fa68cf1 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -19,3 +19,4 @@ export * from './saved-views.js'; export * from './csv-imports.js'; export * from './analytics.js'; export * from './part-model-insights.js'; +export * from './manufacturer-insights.js'; diff --git a/packages/shared/src/manufacturer-insights.ts b/packages/shared/src/manufacturer-insights.ts new file mode 100644 index 0000000..61cf687 --- /dev/null +++ b/packages/shared/src/manufacturer-insights.ts @@ -0,0 +1,49 @@ +export interface ManufacturerPriceStats { + total: number; + average: number; + min: number | null; + max: number | null; + countWithPrice: number; +} + +export interface ManufacturerFailureStats { + repairs: number; + distinctFailedParts: number; + fmsImplicating: number; +} + +export interface ManufacturerCategoryCount { + categoryId: string | null; + categoryName: string; + count: number; +} + +export interface ManufacturerModelCount { + partModelId: string; + mpn: string; + count: number; +} + +export interface ManufacturerModelFailureCount { + partModelId: string; + mpn: string; + repairs: number; +} + +export interface ManufacturerPastEolModel { + partModelId: string; + mpn: string; + eolDate: string; + deployedCount: number; +} + +export interface ManufacturerInsights { + totalPartModels: number; + totalParts: number; + priceStats: ManufacturerPriceStats; + failures: ManufacturerFailureStats; + byCategory: ManufacturerCategoryCount[]; + topModelsByUnits: ManufacturerModelCount[]; + failuresByModel: ManufacturerModelFailureCount[]; + pastEolModels: ManufacturerPastEolModel[]; +}