feat(part-models): detail page with fleet insights
Adds /part-models/:id mirroring host/part detail pattern: KPIs for units, spend, avg price, failure counts, and FMs implicating the model, a state-breakdown bar chart, and the parts-of-this-model table. New GET /part-models/:id/insights aggregates via part.groupBy + aggregate and repair/fm counts. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,20 @@ export async function get(req: Request<{ id: string }>, res: Response, next: Nex
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getInsights(
|
||||||
|
req: Request<{ id: string }>,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const insights = await prisma.$transaction((tx) => svc.getInsights(tx, req.params.id));
|
||||||
|
if (!insights) throw errors.notFound('Part model');
|
||||||
|
res.json(insights);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function create(req: Request, res: Response, next: NextFunction) {
|
export async function create(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const input = req.validated!.body as CreatePartModelRequest;
|
const input = req.validated!.body as CreatePartModelRequest;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const router = Router();
|
|||||||
|
|
||||||
router.get('/', requireAuth, validate('query', PartModelListQuery), ctrl.list);
|
router.get('/', requireAuth, validate('query', PartModelListQuery), ctrl.list);
|
||||||
router.get('/:id', requireAuth, ctrl.get);
|
router.get('/:id', requireAuth, ctrl.get);
|
||||||
|
router.get('/:id/insights', requireAuth, ctrl.getInsights);
|
||||||
router.post(
|
router.post(
|
||||||
'/',
|
'/',
|
||||||
requireAuth,
|
requireAuth,
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { Tx } from './types.js';
|
||||||
|
import { getInsights } from './part-models.js';
|
||||||
|
|
||||||
|
// Minimal in-memory tx double exercising getInsights(). We only stub the
|
||||||
|
// calls that getInsights actually makes.
|
||||||
|
function makeTx(args: {
|
||||||
|
modelExists: boolean;
|
||||||
|
partCount: number;
|
||||||
|
stateRows: { state: string; count: number; totalPrice: number }[];
|
||||||
|
priceAgg: {
|
||||||
|
sum: number | null;
|
||||||
|
avg: number | null;
|
||||||
|
min: number | null;
|
||||||
|
max: number | null;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
repairCount: number;
|
||||||
|
distinctFailedBrokenPartIds: string[];
|
||||||
|
fmCount: number;
|
||||||
|
}): Tx {
|
||||||
|
const tx = {
|
||||||
|
partModel: {
|
||||||
|
findUnique: async () => (args.modelExists ? { id: 'pm' } : null),
|
||||||
|
},
|
||||||
|
part: {
|
||||||
|
count: async () => args.partCount,
|
||||||
|
groupBy: async () =>
|
||||||
|
args.stateRows.map((s) => ({
|
||||||
|
state: s.state,
|
||||||
|
_count: { _all: s.count },
|
||||||
|
_sum: { price: s.totalPrice },
|
||||||
|
})),
|
||||||
|
aggregate: async () => ({
|
||||||
|
_sum: { price: args.priceAgg.sum },
|
||||||
|
_avg: { price: args.priceAgg.avg },
|
||||||
|
_min: { price: args.priceAgg.min },
|
||||||
|
_max: { price: args.priceAgg.max },
|
||||||
|
_count: { _all: args.priceAgg.count },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
repair: {
|
||||||
|
count: async () => args.repairCount,
|
||||||
|
findMany: async () =>
|
||||||
|
args.distinctFailedBrokenPartIds.map((brokenPartId) => ({ brokenPartId })),
|
||||||
|
},
|
||||||
|
fm: {
|
||||||
|
count: async () => args.fmCount,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return tx as unknown as Tx;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('part-models.getInsights', () => {
|
||||||
|
it('returns null when model does not exist', async () => {
|
||||||
|
const tx = makeTx({
|
||||||
|
modelExists: false,
|
||||||
|
partCount: 0,
|
||||||
|
stateRows: [],
|
||||||
|
priceAgg: { sum: null, avg: null, min: null, max: null, count: 0 },
|
||||||
|
repairCount: 0,
|
||||||
|
distinctFailedBrokenPartIds: [],
|
||||||
|
fmCount: 0,
|
||||||
|
});
|
||||||
|
const r = await getInsights(tx, 'nope');
|
||||||
|
expect(r).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aggregates totalParts and groups by state', async () => {
|
||||||
|
const tx = makeTx({
|
||||||
|
modelExists: true,
|
||||||
|
partCount: 3,
|
||||||
|
stateRows: [
|
||||||
|
{ state: 'SPARE', count: 1, totalPrice: 100 },
|
||||||
|
{ state: 'DEPLOYED', count: 2, totalPrice: 800 },
|
||||||
|
],
|
||||||
|
priceAgg: { sum: 900, avg: 450, min: 100, max: 500, count: 2 },
|
||||||
|
repairCount: 0,
|
||||||
|
distinctFailedBrokenPartIds: [],
|
||||||
|
fmCount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const r = await getInsights(tx, 'pm');
|
||||||
|
expect(r).not.toBeNull();
|
||||||
|
expect(r!.totalParts).toBe(3);
|
||||||
|
expect(r!.byState).toEqual([
|
||||||
|
{ state: 'SPARE', count: 1, totalPrice: 100 },
|
||||||
|
{ state: 'DEPLOYED', count: 2, totalPrice: 800 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes price stats from aggregate (countWithPrice drives average)', async () => {
|
||||||
|
const tx = makeTx({
|
||||||
|
modelExists: true,
|
||||||
|
partCount: 3,
|
||||||
|
stateRows: [],
|
||||||
|
// 2 priced parts ($100 + $500), 1 null-priced part
|
||||||
|
priceAgg: { sum: 600, avg: 300, min: 100, max: 500, count: 2 },
|
||||||
|
repairCount: 0,
|
||||||
|
distinctFailedBrokenPartIds: [],
|
||||||
|
fmCount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const r = await getInsights(tx, 'pm');
|
||||||
|
expect(r!.priceStats).toEqual({
|
||||||
|
total: 600,
|
||||||
|
average: 300,
|
||||||
|
min: 100,
|
||||||
|
max: 500,
|
||||||
|
countWithPrice: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeros price stats when no parts are priced', async () => {
|
||||||
|
const tx = makeTx({
|
||||||
|
modelExists: true,
|
||||||
|
partCount: 2,
|
||||||
|
stateRows: [],
|
||||||
|
priceAgg: { sum: null, avg: null, min: null, max: null, count: 0 },
|
||||||
|
repairCount: 0,
|
||||||
|
distinctFailedBrokenPartIds: [],
|
||||||
|
fmCount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const r = await getInsights(tx, 'pm');
|
||||||
|
expect(r!.priceStats).toEqual({
|
||||||
|
total: 0,
|
||||||
|
average: 0,
|
||||||
|
min: null,
|
||||||
|
max: null,
|
||||||
|
countWithPrice: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts repairs, distinct failed parts, and FMs implicating the model', async () => {
|
||||||
|
const tx = makeTx({
|
||||||
|
modelExists: true,
|
||||||
|
partCount: 5,
|
||||||
|
stateRows: [],
|
||||||
|
priceAgg: { sum: 0, avg: null, min: null, max: null, count: 0 },
|
||||||
|
repairCount: 3,
|
||||||
|
// one part failed twice → 2 distinct broken parts
|
||||||
|
distinctFailedBrokenPartIds: ['part-a', 'part-b'],
|
||||||
|
fmCount: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const r = await getInsights(tx, 'pm');
|
||||||
|
expect(r!.failures).toEqual({
|
||||||
|
repairs: 3,
|
||||||
|
distinctFailedParts: 2,
|
||||||
|
fmsImplicating: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Prisma } from '@vector/db';
|
import { Prisma } from '@vector/db';
|
||||||
import type {
|
import type {
|
||||||
CreatePartModelRequest,
|
CreatePartModelRequest,
|
||||||
|
PartModelInsights,
|
||||||
PartModelListQuery,
|
PartModelListQuery,
|
||||||
UpdatePartModelRequest,
|
UpdatePartModelRequest,
|
||||||
} from '@vector/shared';
|
} from '@vector/shared';
|
||||||
@@ -38,6 +39,59 @@ export function get(tx: Tx, id: string) {
|
|||||||
return tx.partModel.findUnique({ where: { id }, include: partModelInclude });
|
return tx.partModel.findUnique({ where: { id }, include: partModelInclude });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getInsights(tx: Tx, id: string): Promise<PartModelInsights | null> {
|
||||||
|
const model = await tx.partModel.findUnique({ where: { id }, select: { id: true } });
|
||||||
|
if (!model) return null;
|
||||||
|
|
||||||
|
const [totalParts, stateRows, priceAgg, repairs, failedParts, fmsImplicating] = await Promise.all([
|
||||||
|
tx.part.count({ where: { partModelId: id } }),
|
||||||
|
tx.part.groupBy({
|
||||||
|
by: ['state'],
|
||||||
|
where: { partModelId: id },
|
||||||
|
_count: { _all: true },
|
||||||
|
_sum: { price: true },
|
||||||
|
}),
|
||||||
|
tx.part.aggregate({
|
||||||
|
where: { partModelId: id, price: { not: null } },
|
||||||
|
_sum: { price: true },
|
||||||
|
_avg: { price: true },
|
||||||
|
_min: { price: true },
|
||||||
|
_max: { price: true },
|
||||||
|
_count: { _all: true },
|
||||||
|
}),
|
||||||
|
tx.repair.count({ where: { brokenPart: { partModelId: id } } }),
|
||||||
|
tx.repair.findMany({
|
||||||
|
where: { brokenPart: { partModelId: id } },
|
||||||
|
select: { brokenPartId: true },
|
||||||
|
distinct: ['brokenPartId'],
|
||||||
|
}),
|
||||||
|
tx.fm.count({ where: { problemParts: { some: { part: { partModelId: id } } } } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const byState = stateRows.map((row) => ({
|
||||||
|
state: row.state as PartModelInsights['byState'][number]['state'],
|
||||||
|
count: row._count._all,
|
||||||
|
totalPrice: row._sum.price ?? 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalParts,
|
||||||
|
byState,
|
||||||
|
priceStats: {
|
||||||
|
total: priceAgg._sum.price ?? 0,
|
||||||
|
average: priceAgg._avg.price ?? 0,
|
||||||
|
min: priceAgg._min.price,
|
||||||
|
max: priceAgg._max.price,
|
||||||
|
countWithPrice: priceAgg._count._all,
|
||||||
|
},
|
||||||
|
failures: {
|
||||||
|
repairs,
|
||||||
|
distinctFailedParts: failedParts.length,
|
||||||
|
fmsImplicating,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function create(tx: Tx, input: CreatePartModelRequest) {
|
export async function create(tx: Tx, input: CreatePartModelRequest) {
|
||||||
try {
|
try {
|
||||||
return await tx.partModel.create({
|
return await tx.partModel.create({
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import PartDetail from './pages/PartDetail.js';
|
|||||||
import Locations from './pages/Locations.js';
|
import Locations from './pages/Locations.js';
|
||||||
import Manufacturers from './pages/Manufacturers.js';
|
import Manufacturers from './pages/Manufacturers.js';
|
||||||
import PartModels from './pages/PartModels.js';
|
import PartModels from './pages/PartModels.js';
|
||||||
|
import PartModelDetail from './pages/PartModelDetail.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';
|
||||||
@@ -60,6 +61,7 @@ export default function App() {
|
|||||||
<Route path="/locations" element={<Locations />} />
|
<Route path="/locations" element={<Locations />} />
|
||||||
<Route path="/manufacturers" element={<Manufacturers />} />
|
<Route path="/manufacturers" element={<Manufacturers />} />
|
||||||
<Route path="/part-models" element={<PartModels />} />
|
<Route path="/part-models" element={<PartModels />} />
|
||||||
|
<Route path="/part-models/:id" element={<PartModelDetail />} />
|
||||||
<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 />} />
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Card, CardContent } from '@vector/ui';
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
sub?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatCard({ label, value, sub }: StatCardProps) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-2xl font-semibold tracking-tight">{value}</div>
|
||||||
|
{sub && <div className="mt-0.5 text-xs text-muted-foreground">{sub}</div>}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
CreatePartModelRequest,
|
CreatePartModelRequest,
|
||||||
|
PartModelInsights,
|
||||||
UpdatePartModelRequest,
|
UpdatePartModelRequest,
|
||||||
} from '@vector/shared';
|
} from '@vector/shared';
|
||||||
import { api } from './client.js';
|
import { api } from './client.js';
|
||||||
@@ -40,3 +41,8 @@ export async function updatePartModel(
|
|||||||
export async function deletePartModel(id: string): Promise<void> {
|
export async function deletePartModel(id: string): Promise<void> {
|
||||||
await api.delete(`/part-models/${id}`);
|
await api.delete(`/part-models/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getPartModelInsights(id: string): Promise<PartModelInsights> {
|
||||||
|
const res = await api.get<PartModelInsights>(`/part-models/${id}/insights`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type PartListFilters = {
|
|||||||
state?: string;
|
state?: string;
|
||||||
manufacturerId?: string;
|
manufacturerId?: string;
|
||||||
categoryId?: string;
|
categoryId?: string;
|
||||||
|
partModelId?: string;
|
||||||
binId?: string;
|
binId?: string;
|
||||||
tagId?: string;
|
tagId?: string;
|
||||||
eolOnly?: boolean;
|
eolOnly?: boolean;
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export const queryKeys = {
|
|||||||
list: (filters?: Record<string, unknown>) =>
|
list: (filters?: Record<string, unknown>) =>
|
||||||
[...queryKeys.partModels.all, 'list', filters ?? {}] as const,
|
[...queryKeys.partModels.all, 'list', filters ?? {}] as const,
|
||||||
detail: (id: string) => [...queryKeys.partModels.all, 'detail', id] as const,
|
detail: (id: string) => [...queryKeys.partModels.all, 'detail', id] as const,
|
||||||
|
insights: (id: string) => [...queryKeys.partModels.all, 'insights', id] as const,
|
||||||
},
|
},
|
||||||
tags: {
|
tags: {
|
||||||
all: ['tags'] as const,
|
all: ['tags'] as const,
|
||||||
|
|||||||
@@ -0,0 +1,413 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { ArrowLeft, Edit, Trash2 } from 'lucide-react';
|
||||||
|
import { Bar, BarChart, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type { PartState } from '@vector/shared';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
Separator,
|
||||||
|
Skeleton,
|
||||||
|
} from '@vector/ui';
|
||||||
|
import { deletePartModel, getPartModel, getPartModelInsights } from '../lib/api/part-models.js';
|
||||||
|
import { listParts } from '../lib/api/parts.js';
|
||||||
|
import { ApiRequestError } from '../lib/api/client.js';
|
||||||
|
import { queryKeys } from '../lib/queryKeys.js';
|
||||||
|
import { useAuth } from '../contexts/AuthContext.js';
|
||||||
|
import { DataTable } from '../components/data-table/DataTable.js';
|
||||||
|
import { PartStateBadge } from '../components/parts/PartStateBadge.js';
|
||||||
|
import { PartModelFormDialog } from '../components/part-models/PartModelFormDialog.js';
|
||||||
|
import { ConfirmDialog } from '../components/ConfirmDialog.js';
|
||||||
|
import { StatCard } from '../components/StatCard.js';
|
||||||
|
import type { Part } from '../lib/api/types.js';
|
||||||
|
|
||||||
|
const STATE_LABELS: Record<PartState, string> = {
|
||||||
|
SPARE: 'Spare',
|
||||||
|
DEPLOYED: 'Deployed',
|
||||||
|
BROKEN: 'Broken',
|
||||||
|
PENDING_DESTRUCTION: 'Pending destruction',
|
||||||
|
PENDING_DROP_IN_CUSTODY: 'In custody',
|
||||||
|
PENDING_DESTRUCTION_IN_CUSTODY: 'In custody (destroy)',
|
||||||
|
PENDING_REPAIR: 'Held for repair',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATE_FILL: Record<PartState, string> = {
|
||||||
|
SPARE: 'hsl(217 91% 60%)',
|
||||||
|
DEPLOYED: 'hsl(142 71% 45%)',
|
||||||
|
BROKEN: 'hsl(0 84% 60%)',
|
||||||
|
PENDING_DESTRUCTION: 'hsl(38 92% 50%)',
|
||||||
|
PENDING_DROP_IN_CUSTODY: 'hsl(262 83% 58%)',
|
||||||
|
PENDING_DESTRUCTION_IN_CUSTODY: 'hsl(340 82% 52%)',
|
||||||
|
PENDING_REPAIR: 'hsl(197 80% 50%)',
|
||||||
|
};
|
||||||
|
|
||||||
|
function currency(dollars: number): string {
|
||||||
|
return dollars.toLocaleString(undefined, { style: 'currency', currency: 'USD' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<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 PartModelDetail() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const isAdmin = user?.role === 'ADMIN';
|
||||||
|
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
|
||||||
|
const modelQuery = useQuery({
|
||||||
|
queryKey: queryKeys.partModels.detail(id!),
|
||||||
|
queryFn: () => getPartModel(id!),
|
||||||
|
enabled: Boolean(id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const insightsQuery = useQuery({
|
||||||
|
queryKey: queryKeys.partModels.insights(id!),
|
||||||
|
queryFn: () => getPartModelInsights(id!),
|
||||||
|
enabled: Boolean(id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () => deletePartModel(id!),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Part model deleted');
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.partModels.all });
|
||||||
|
navigate('/part-models', { replace: true });
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const partColumns = useMemo<ColumnDef<Part>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'serialNumber',
|
||||||
|
header: 'Serial',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Link to={`/parts/${row.original.id}`} className="font-mono text-xs hover:underline">
|
||||||
|
{row.original.serialNumber}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'state',
|
||||||
|
header: 'State',
|
||||||
|
cell: ({ row }) => <PartStateBadge state={row.original.state} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'location',
|
||||||
|
header: 'Location',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const p = row.original;
|
||||||
|
if (p.host) {
|
||||||
|
return (
|
||||||
|
<span className="font-mono text-xs">
|
||||||
|
{p.host.assetId} / {p.host.name}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (p.custodian) {
|
||||||
|
return <span className="text-xs">Custody: {p.custodian.username}</span>;
|
||||||
|
}
|
||||||
|
if (p.bin?.fullPath) {
|
||||||
|
return <span className="font-mono text-xs">{p.bin.fullPath}</span>;
|
||||||
|
}
|
||||||
|
return <span className="text-xs text-muted-foreground italic">Unassigned</span>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'price',
|
||||||
|
header: 'Price',
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.price != null ? (
|
||||||
|
<span className="tabular-nums">{currency(row.original.price)}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (modelQuery.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 (modelQuery.isError || !modelQuery.data) {
|
||||||
|
const msg =
|
||||||
|
modelQuery.error instanceof ApiRequestError
|
||||||
|
? modelQuery.error.body.message
|
||||||
|
: 'Part model not found.';
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Part model unavailable</CardTitle>
|
||||||
|
<CardDescription>{msg}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button variant="outline" onClick={() => navigate('/part-models')}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Back to part models
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = modelQuery.data;
|
||||||
|
const insights = insightsQuery.data;
|
||||||
|
const eolDate = model.eolDate ? new Date(model.eolDate) : null;
|
||||||
|
const pastEol = eolDate ? eolDate.getTime() <= Date.now() : false;
|
||||||
|
|
||||||
|
const failureRate =
|
||||||
|
insights && insights.totalParts > 0
|
||||||
|
? Math.round((insights.failures.repairs / insights.totalParts) * 100)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const chartData =
|
||||||
|
insights?.byState.map((s) => ({
|
||||||
|
name: STATE_LABELS[s.state],
|
||||||
|
state: s.state,
|
||||||
|
count: s.count,
|
||||||
|
})) ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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('/part-models')}
|
||||||
|
aria-label="Back"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="font-mono text-lg font-semibold tracking-tight">{model.mpn}</h1>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{model.manufacturer?.name ?? '—'}
|
||||||
|
{' · '}
|
||||||
|
{model.category?.name ?? 'Uncategorized'}
|
||||||
|
{eolDate && (
|
||||||
|
<>
|
||||||
|
{' · EOL '}
|
||||||
|
<span className={pastEol ? 'text-warning' : ''}>
|
||||||
|
{eolDate.toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</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-5">
|
||||||
|
{insightsQuery.isPending || !insights ? (
|
||||||
|
Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-20" />)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<StatCard label="Units" 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 units'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Failures"
|
||||||
|
value={insights.failures.repairs.toLocaleString()}
|
||||||
|
sub={
|
||||||
|
failureRate != null
|
||||||
|
? `${failureRate}% of units · ${insights.failures.distinctFailedParts} distinct`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="FMs implicated"
|
||||||
|
value={insights.failures.fmsImplicating.toLocaleString()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-[1fr_1.2fr]">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Summary</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<dl className="space-y-2">
|
||||||
|
<DetailRow label="Manufacturer" value={model.manufacturer?.name ?? '—'} />
|
||||||
|
<DetailRow
|
||||||
|
label="MPN"
|
||||||
|
value={<span className="font-mono text-xs">{model.mpn}</span>}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="Category"
|
||||||
|
value={
|
||||||
|
model.category?.name ?? (
|
||||||
|
<span className="text-muted-foreground italic">Uncategorized</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
label="EOL"
|
||||||
|
value={
|
||||||
|
eolDate ? (
|
||||||
|
<span className={pastEol ? 'text-warning' : ''}>
|
||||||
|
{eolDate.toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DetailRow label="Destroy on fail" value={model.destroyOnFail ? 'Yes' : 'No'} />
|
||||||
|
<Separator className="my-2" />
|
||||||
|
<DetailRow label="Created" value={new Date(model.createdAt).toLocaleString()} />
|
||||||
|
<DetailRow label="Updated" value={new Date(model.updatedAt).toLocaleString()} />
|
||||||
|
</dl>
|
||||||
|
{model.notes && (
|
||||||
|
<>
|
||||||
|
<Separator className="my-3" />
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-xs font-medium text-muted-foreground">Notes</p>
|
||||||
|
<p className="whitespace-pre-wrap text-sm text-foreground">{model.notes}</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">State breakdown</CardTitle>
|
||||||
|
<CardDescription>How the fleet is distributed across lifecycle states.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="h-72">
|
||||||
|
{insightsQuery.isPending ? (
|
||||||
|
<Skeleton className="h-full w-full" />
|
||||||
|
) : chartData.length === 0 ? (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
No units of this model yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={chartData} 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" radius={[0, 4, 4, 0]}>
|
||||||
|
{chartData.map((s) => (
|
||||||
|
<Cell key={s.state} fill={STATE_FILL[s.state]} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Parts of this model</CardTitle>
|
||||||
|
<CardDescription>Every unit tracked under {model.mpn}.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<DataTable<Part, Record<string, never>>
|
||||||
|
columns={partColumns}
|
||||||
|
getRowId={(p) => p.id}
|
||||||
|
queryKey={(params) =>
|
||||||
|
queryKeys.parts.list({
|
||||||
|
partModelId: id,
|
||||||
|
page: params.page,
|
||||||
|
pageSize: params.pageSize,
|
||||||
|
q: params.q,
|
||||||
|
sort: params.sort,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
queryFn={(params) =>
|
||||||
|
listParts({
|
||||||
|
partModelId: id,
|
||||||
|
page: params.page,
|
||||||
|
pageSize: params.pageSize,
|
||||||
|
q: params.q,
|
||||||
|
sort: params.sort,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
searchPlaceholder="Search serial..."
|
||||||
|
emptyState={
|
||||||
|
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
No parts of this model yet.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<PartModelFormDialog open={editOpen} onOpenChange={setEditOpen} partModel={model} />
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmDelete}
|
||||||
|
onOpenChange={setConfirmDelete}
|
||||||
|
title="Delete part model?"
|
||||||
|
description={`Remove ${model.mpn}. Fails if any parts reference this model.`}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
destructive
|
||||||
|
pending={deleteMutation.isPending}
|
||||||
|
onConfirm={() => deleteMutation.mutate()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Check, Edit, Layers, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
|
import { Check, Edit, Eye, Layers, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
@@ -25,6 +26,7 @@ import { useAuth } from '../contexts/AuthContext.js';
|
|||||||
export default function PartModels() {
|
export default function PartModels() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const isAdmin = user?.role === 'ADMIN';
|
const isAdmin = user?.role === 'ADMIN';
|
||||||
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
@@ -55,7 +57,12 @@ export default function PartModels() {
|
|||||||
accessorKey: 'mpn',
|
accessorKey: 'mpn',
|
||||||
header: 'MPN',
|
header: 'MPN',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="font-mono text-xs font-medium">{row.original.mpn}</span>
|
<Link
|
||||||
|
to={`/part-models/${row.original.id}`}
|
||||||
|
className="font-mono text-xs font-medium hover:underline"
|
||||||
|
>
|
||||||
|
{row.original.mpn}
|
||||||
|
</Link>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -104,33 +111,40 @@ export default function PartModels() {
|
|||||||
id: 'actions',
|
id: 'actions',
|
||||||
header: () => <span className="sr-only">Actions</span>,
|
header: () => <span className="sr-only">Actions</span>,
|
||||||
size: 40,
|
size: 40,
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) => (
|
||||||
isAdmin ? (
|
<DropdownMenu>
|
||||||
<DropdownMenu>
|
<DropdownMenuTrigger asChild>
|
||||||
<DropdownMenuTrigger asChild>
|
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
</Button>
|
||||||
</Button>
|
</DropdownMenuTrigger>
|
||||||
</DropdownMenuTrigger>
|
<DropdownMenuContent align="end" className="w-36">
|
||||||
<DropdownMenuContent align="end" className="w-36">
|
<DropdownMenuItem onSelect={() => navigate(`/part-models/${row.original.id}`)}>
|
||||||
<DropdownMenuItem onSelect={() => setEditing(row.original)}>
|
<Eye className="h-3.5 w-3.5" />
|
||||||
<Edit className="h-3.5 w-3.5" />
|
View
|
||||||
Edit
|
</DropdownMenuItem>
|
||||||
</DropdownMenuItem>
|
{isAdmin && (
|
||||||
<DropdownMenuSeparator />
|
<>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem onSelect={() => setEditing(row.original)}>
|
||||||
onSelect={() => setDeleting(row.original)}
|
<Edit className="h-3.5 w-3.5" />
|
||||||
className="text-destructive focus:text-destructive"
|
Edit
|
||||||
>
|
</DropdownMenuItem>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<DropdownMenuSeparator />
|
||||||
Delete
|
<DropdownMenuItem
|
||||||
</DropdownMenuItem>
|
onSelect={() => setDeleting(row.original)}
|
||||||
</DropdownMenuContent>
|
className="text-destructive focus:text-destructive"
|
||||||
</DropdownMenu>
|
>
|
||||||
) : null,
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[isAdmin],
|
[isAdmin, navigate],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -18,3 +18,4 @@ export * from './webhooks.js';
|
|||||||
export * from './saved-views.js';
|
export * from './saved-views.js';
|
||||||
export * from './csv-imports.js';
|
export * from './csv-imports.js';
|
||||||
export * from './analytics.js';
|
export * from './analytics.js';
|
||||||
|
export * from './part-model-insights.js';
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { PartState } from './enums.js';
|
||||||
|
|
||||||
|
export interface PartModelStateCount {
|
||||||
|
state: z.infer<typeof PartState>;
|
||||||
|
count: number;
|
||||||
|
totalPrice: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartModelPriceStats {
|
||||||
|
total: number;
|
||||||
|
average: number;
|
||||||
|
min: number | null;
|
||||||
|
max: number | null;
|
||||||
|
countWithPrice: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartModelFailureStats {
|
||||||
|
repairs: number;
|
||||||
|
distinctFailedParts: number;
|
||||||
|
fmsImplicating: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartModelInsights {
|
||||||
|
totalParts: number;
|
||||||
|
byState: PartModelStateCount[];
|
||||||
|
priceStats: PartModelPriceStats;
|
||||||
|
failures: PartModelFailureStats;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user