feat(categories): detail page with fleet insights
Clicking a category anywhere in the app now opens /categories/:id with MPN breakdown, manufacturer mix, failures by MPN, and past-EOL exposure — a dual of the manufacturer detail page. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import type {
|
|||||||
UpdateCategoryRequest,
|
UpdateCategoryRequest,
|
||||||
} from '@vector/shared';
|
} from '@vector/shared';
|
||||||
import * as svc from '../services/categories.js';
|
import * as svc from '../services/categories.js';
|
||||||
|
import { errors } from '../lib/http-error.js';
|
||||||
|
|
||||||
export async function list(req: Request, res: Response, next: NextFunction) {
|
export async function list(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
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) {
|
export async function create(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const input = req.validated!.body as CreateCategoryRequest;
|
const input = req.validated!.body as CreateCategoryRequest;
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { validate } from '../middleware/validate.js';
|
|||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.get('/', requireAuth, validate('query', CategoryListQuery), ctrl.list);
|
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.post('/', requireAuth, requireRole('ADMIN'), validate('body', CreateCategoryRequest), ctrl.create);
|
||||||
router.patch('/:id', requireAuth, requireRole('ADMIN'), validate('body', UpdateCategoryRequest), ctrl.update);
|
router.patch('/:id', requireAuth, requireRole('ADMIN'), validate('body', UpdateCategoryRequest), ctrl.update);
|
||||||
router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove);
|
router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Prisma } from '@vector/db';
|
import { Prisma } from '@vector/db';
|
||||||
import type {
|
import type {
|
||||||
|
CategoryInsights,
|
||||||
CategoryListQuery,
|
CategoryListQuery,
|
||||||
CreateCategoryRequest,
|
CreateCategoryRequest,
|
||||||
UpdateCategoryRequest,
|
UpdateCategoryRequest,
|
||||||
@@ -7,6 +8,152 @@ import type {
|
|||||||
import { errors } from '../lib/http-error.js';
|
import { errors } from '../lib/http-error.js';
|
||||||
import type { Tx } from './types.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<CategoryInsights | null> {
|
||||||
|
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<string, number>();
|
||||||
|
const deployedByModel = new Map<string, number>();
|
||||||
|
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<string, number>();
|
||||||
|
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<string, { id: string; name: string; count: number }>();
|
||||||
|
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) {
|
export async function list(tx: Tx, q: CategoryListQuery) {
|
||||||
const { page, pageSize } = q;
|
const { page, pageSize } = q;
|
||||||
const [data, total] = await Promise.all([
|
const [data, total] = await Promise.all([
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import Manufacturers from './pages/Manufacturers.js';
|
|||||||
import ManufacturerDetail from './pages/ManufacturerDetail.js';
|
import ManufacturerDetail from './pages/ManufacturerDetail.js';
|
||||||
import PartModels from './pages/PartModels.js';
|
import PartModels from './pages/PartModels.js';
|
||||||
import PartModelDetail from './pages/PartModelDetail.js';
|
import PartModelDetail from './pages/PartModelDetail.js';
|
||||||
|
import CategoryDetail from './pages/CategoryDetail.js';
|
||||||
import Fms from './pages/Fms.js';
|
import Fms from './pages/Fms.js';
|
||||||
import FmDetail from './pages/FmDetail.js';
|
import FmDetail from './pages/FmDetail.js';
|
||||||
import Repairs from './pages/Repairs.js';
|
import Repairs from './pages/Repairs.js';
|
||||||
@@ -66,6 +67,7 @@ export default function App() {
|
|||||||
<Route path="/manufacturers/:id" element={<ManufacturerDetail />} />
|
<Route path="/manufacturers/:id" element={<ManufacturerDetail />} />
|
||||||
<Route path="/part-models" element={<PartModels />} />
|
<Route path="/part-models" element={<PartModels />} />
|
||||||
<Route path="/part-models/:id" element={<PartModelDetail />} />
|
<Route path="/part-models/:id" element={<PartModelDetail />} />
|
||||||
|
<Route path="/categories/:id" element={<CategoryDetail />} />
|
||||||
<Route path="/fms" element={<Fms />} />
|
<Route path="/fms" element={<Fms />} />
|
||||||
<Route path="/fms/:id" element={<FmDetail />} />
|
<Route path="/fms/:id" element={<FmDetail />} />
|
||||||
<Route path="/repairs" element={<Repairs />} />
|
<Route path="/repairs" element={<Repairs />} />
|
||||||
|
|||||||
@@ -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 { api } from './client.js';
|
||||||
import { getList } from './paginated.js';
|
import { getList } from './paginated.js';
|
||||||
import type { Category } from './types.js';
|
import type { Category } from './types.js';
|
||||||
@@ -7,6 +11,16 @@ export function listCategories(filters: { page?: number; pageSize?: number } = {
|
|||||||
return getList<Category>('/categories', filters);
|
return getList<Category>('/categories', filters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getCategory(id: string): Promise<Category> {
|
||||||
|
const res = await api.get<Category>(`/categories/${id}`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCategoryInsights(id: string): Promise<CategoryInsights> {
|
||||||
|
const res = await api.get<CategoryInsights>(`/categories/${id}/insights`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function createCategory(input: CreateCategoryRequest): Promise<Category> {
|
export async function createCategory(input: CreateCategoryRequest): Promise<Category> {
|
||||||
const res = await api.post<Category>('/categories', input);
|
const res = await api.post<Category>('/categories', input);
|
||||||
return res.data;
|
return res.data;
|
||||||
|
|||||||
@@ -165,8 +165,10 @@ export interface Tag {
|
|||||||
export interface Category {
|
export interface Category {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
_count?: { partModels: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FmProblemPart {
|
export interface FmProblemPart {
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ export const queryKeys = {
|
|||||||
all: ['categories'] as const,
|
all: ['categories'] as const,
|
||||||
list: (filters?: Record<string, unknown>) =>
|
list: (filters?: Record<string, unknown>) =>
|
||||||
[...queryKeys.categories.all, 'list', filters ?? {}] as const,
|
[...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: {
|
webhooks: {
|
||||||
all: ['webhooks'] as const,
|
all: ['webhooks'] as const,
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="grid grid-cols-[10rem_1fr] gap-2 text-sm">
|
||||||
|
<dt className="text-muted-foreground">{label}</dt>
|
||||||
|
<dd className="text-foreground">{value}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ColumnDef<PartModel>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'mpn',
|
||||||
|
header: 'MPN',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Link
|
||||||
|
to={`/part-models/${row.original.id}`}
|
||||||
|
className="font-mono text-xs font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{row.original.mpn}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'manufacturer',
|
||||||
|
header: 'Manufacturer',
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.manufacturer ? (
|
||||||
|
<Link
|
||||||
|
to={`/manufacturers/${row.original.manufacturer.id}`}
|
||||||
|
className="text-sm hover:underline"
|
||||||
|
>
|
||||||
|
{row.original.manufacturer.name}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'eolDate',
|
||||||
|
header: 'EOL',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const iso = row.original.eolDate;
|
||||||
|
if (!iso) return <span className="text-sm text-muted-foreground">—</span>;
|
||||||
|
const pastEol = new Date(iso).getTime() <= Date.now();
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm">{new Date(iso).toLocaleDateString()}</span>
|
||||||
|
{pastEol && <Badge variant="destructive">Past EOL</Badge>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'deployedCount',
|
||||||
|
header: 'Parts',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-sm tabular-nums">{row.original._count?.parts ?? 0}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (categoryQuery.isPending) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-8 w-64" />
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
<Skeleton className="h-48 w-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (categoryQuery.isError || !categoryQuery.data) {
|
||||||
|
const msg =
|
||||||
|
categoryQuery.error instanceof ApiRequestError
|
||||||
|
? categoryQuery.error.body.message
|
||||||
|
: 'Category not found.';
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Category unavailable</CardTitle>
|
||||||
|
<CardDescription>{msg}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button variant="outline" onClick={() => navigate('/parts')}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Back to parts
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
aria-label="Back"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold tracking-tight">{category.name}</h1>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{insights
|
||||||
|
? `${insights.totalPartModels} MPNs · ${insights.totalParts} parts`
|
||||||
|
: '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isAdmin && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
|
||||||
|
<Edit className="h-3.5 w-3.5" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
onClick={() => setConfirmDelete(true)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||||
|
{insightsQuery.isPending || !insights ? (
|
||||||
|
Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-20" />)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<StatCard label="MPNs" value={insights.totalPartModels.toLocaleString()} />
|
||||||
|
<StatCard label="Parts" value={insights.totalParts.toLocaleString()} />
|
||||||
|
<StatCard label="Total spent" value={currency(insights.priceStats.total)} />
|
||||||
|
<StatCard
|
||||||
|
label="Avg price"
|
||||||
|
value={
|
||||||
|
insights.priceStats.countWithPrice > 0
|
||||||
|
? currency(insights.priceStats.average)
|
||||||
|
: '—'
|
||||||
|
}
|
||||||
|
sub={
|
||||||
|
insights.priceStats.countWithPrice > 0
|
||||||
|
? `${insights.priceStats.countWithPrice} priced`
|
||||||
|
: 'No priced parts'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Failures"
|
||||||
|
value={insights.failures.repairs.toLocaleString()}
|
||||||
|
sub={
|
||||||
|
failureRate != null
|
||||||
|
? `${failureRate}% of parts · ${insights.failures.distinctFailedParts} distinct`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="FMs implicated"
|
||||||
|
value={insights.failures.fmsImplicating.toLocaleString()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Top MPNs by units</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Where this category's inventory is concentrated.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="h-72">
|
||||||
|
{insightsQuery.isPending ? (
|
||||||
|
<Skeleton className="h-full w-full" />
|
||||||
|
) : topModelsData.length === 0 ? (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
No parts in this category yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={topModelsData} layout="vertical" margin={{ left: 16 }}>
|
||||||
|
<XAxis type="number" tick={{ fontSize: 12 }} allowDecimals={false} />
|
||||||
|
<YAxis type="category" dataKey="name" tick={{ fontSize: 11 }} width={120} />
|
||||||
|
<Tooltip cursor={{ fill: 'hsl(var(--accent) / 0.2)' }} />
|
||||||
|
<Bar dataKey="count" fill={BAR_COLOR} radius={[0, 4, 4, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Manufacturer mix</CardTitle>
|
||||||
|
<CardDescription>Which vendors supply this category.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="h-72">
|
||||||
|
{insightsQuery.isPending ? (
|
||||||
|
<Skeleton className="h-full w-full" />
|
||||||
|
) : manufacturerData.length === 0 ? (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
No MPNs yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={manufacturerData}
|
||||||
|
dataKey="count"
|
||||||
|
nameKey="name"
|
||||||
|
innerRadius={55}
|
||||||
|
outerRadius={90}
|
||||||
|
paddingAngle={2}
|
||||||
|
>
|
||||||
|
{manufacturerData.map((_m, i) => (
|
||||||
|
<Cell key={i} fill={MANUFACTURER_COLORS[i % MANUFACTURER_COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Failures by MPN</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Which models in this category have failed most.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="h-72">
|
||||||
|
{insightsQuery.isPending ? (
|
||||||
|
<Skeleton className="h-full w-full" />
|
||||||
|
) : failuresData.length === 0 ? (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
No failures recorded in this category.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={failuresData} layout="vertical" margin={{ left: 16 }}>
|
||||||
|
<XAxis type="number" tick={{ fontSize: 12 }} allowDecimals={false} />
|
||||||
|
<YAxis type="category" dataKey="name" tick={{ fontSize: 11 }} width={120} />
|
||||||
|
<Tooltip cursor={{ fill: 'hsl(var(--accent) / 0.2)' }} />
|
||||||
|
<Bar dataKey="repairs" fill={FAILURE_COLOR} radius={[0, 4, 4, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Summary</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<dl className="space-y-2">
|
||||||
|
<DetailRow label="Name" value={category.name} />
|
||||||
|
{category.description && (
|
||||||
|
<DetailRow label="Description" value={category.description} />
|
||||||
|
)}
|
||||||
|
<DetailRow
|
||||||
|
label="# MPNs"
|
||||||
|
value={
|
||||||
|
<span className="tabular-nums">{category._count?.partModels ?? '—'}</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Separator className="my-2" />
|
||||||
|
<DetailRow label="Created" value={new Date(category.createdAt).toLocaleString()} />
|
||||||
|
<DetailRow label="Updated" value={new Date(category.updatedAt).toLocaleString()} />
|
||||||
|
</dl>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{insights && insights.pastEolModels.length > 0 && (
|
||||||
|
<Card className="border-warning/50 bg-warning/5">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-warning" />
|
||||||
|
Past-EOL MPNs with deployed parts
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
These models have passed their end-of-life date — plan replacements.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2 pb-5">
|
||||||
|
{insights.pastEolModels.map((m) => (
|
||||||
|
<div
|
||||||
|
key={m.partModelId}
|
||||||
|
className="flex items-center justify-between rounded-md border border-border bg-background px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate font-mono text-xs font-medium">{m.mpn}</div>
|
||||||
|
{m.eolDate && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
EOL {new Date(m.eolDate).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="tabular-nums text-muted-foreground">
|
||||||
|
{m.deployedCount} deployed
|
||||||
|
</span>
|
||||||
|
<Button asChild variant="outline" size="sm">
|
||||||
|
<Link to={`/part-models/${m.partModelId}`}>View</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Part models</CardTitle>
|
||||||
|
<CardDescription>Every MPN in this category.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<DataTable<PartModel, Record<string, never>>
|
||||||
|
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={
|
||||||
|
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
No MPNs in this category yet.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<NamePromptDialog
|
||||||
|
open={editOpen}
|
||||||
|
onOpenChange={setEditOpen}
|
||||||
|
title="Rename category"
|
||||||
|
label="Name"
|
||||||
|
initialValue={category.name}
|
||||||
|
confirmLabel="Save"
|
||||||
|
pending={renameMutation.isPending}
|
||||||
|
onSubmit={(name) => renameMutation.mutate(name)}
|
||||||
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmDelete}
|
||||||
|
onOpenChange={setConfirmDelete}
|
||||||
|
title="Delete category?"
|
||||||
|
description={`Remove ${category.name}. Fails if any part models reference it.`}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
destructive
|
||||||
|
pending={deleteMutation.isPending}
|
||||||
|
onConfirm={() => deleteMutation.mutate()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -122,7 +122,11 @@ export default function ManufacturerDetail() {
|
|||||||
header: 'Category',
|
header: 'Category',
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
row.original.category ? (
|
row.original.category ? (
|
||||||
<Badge variant="outline">{row.original.category.name}</Badge>
|
<Link to={`/categories/${row.original.category.id}`}>
|
||||||
|
<Badge variant="outline" className="hover:bg-accent">
|
||||||
|
{row.original.category.name}
|
||||||
|
</Badge>
|
||||||
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-muted-foreground">—</span>
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -220,7 +220,16 @@ export default function PartModelDetail() {
|
|||||||
'—'
|
'—'
|
||||||
)}
|
)}
|
||||||
{' · '}
|
{' · '}
|
||||||
{model.category?.name ?? 'Uncategorized'}
|
{model.category ? (
|
||||||
|
<Link
|
||||||
|
to={`/categories/${model.category.id}`}
|
||||||
|
className="hover:underline"
|
||||||
|
>
|
||||||
|
{model.category.name}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
'Uncategorized'
|
||||||
|
)}
|
||||||
{eolDate && (
|
{eolDate && (
|
||||||
<>
|
<>
|
||||||
{' · EOL '}
|
{' · EOL '}
|
||||||
@@ -319,7 +328,14 @@ export default function PartModelDetail() {
|
|||||||
<DetailRow
|
<DetailRow
|
||||||
label="Category"
|
label="Category"
|
||||||
value={
|
value={
|
||||||
model.category?.name ?? (
|
model.category ? (
|
||||||
|
<Link
|
||||||
|
to={`/categories/${model.category.id}`}
|
||||||
|
className="text-foreground hover:underline"
|
||||||
|
>
|
||||||
|
{model.category.name}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
<span className="text-muted-foreground italic">Uncategorized</span>
|
<span className="text-muted-foreground italic">Uncategorized</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,11 @@ export default function PartModels() {
|
|||||||
header: 'Category',
|
header: 'Category',
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
row.original.category ? (
|
row.original.category ? (
|
||||||
<Badge variant="outline">{row.original.category.name}</Badge>
|
<Link to={`/categories/${row.original.category.id}`}>
|
||||||
|
<Badge variant="outline" className="hover:bg-accent">
|
||||||
|
{row.original.category.name}
|
||||||
|
</Badge>
|
||||||
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-muted-foreground">—</span>
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -141,9 +141,12 @@ export default function Parts() {
|
|||||||
header: 'Category',
|
header: 'Category',
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
row.original.partModel.category ? (
|
row.original.partModel.category ? (
|
||||||
<span className="text-xs text-muted-foreground">
|
<Link
|
||||||
|
to={`/categories/${row.original.partModel.category.id}`}
|
||||||
|
className="text-xs text-muted-foreground hover:underline"
|
||||||
|
>
|
||||||
{row.original.partModel.category.name}
|
{row.original.partModel.category.name}
|
||||||
</span>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-muted-foreground">—</span>
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -20,3 +20,4 @@ export * from './csv-imports.js';
|
|||||||
export * from './analytics.js';
|
export * from './analytics.js';
|
||||||
export * from './part-model-insights.js';
|
export * from './part-model-insights.js';
|
||||||
export * from './manufacturer-insights.js';
|
export * from './manufacturer-insights.js';
|
||||||
|
export * from './category-insights.js';
|
||||||
|
|||||||
Reference in New Issue
Block a user