feat(C-003): implement inventory detail views with drill-down

- Add inventory detail service functions for all 6 categories
- Update stored procedure names to match Epicor database
- Add InventoryDetailTable component with search and CSV export
- Add dynamic detail page at /inventory/[category]/detail
- Update summary table links to point to detail pages
- Fix InventoryDetailRow type to support flexible field names
- Add Dockerfile prisma generate step before build

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Lorentz 2026-02-16 12:35:06 +00:00
parent a8d53eef82
commit 3a5bf14007
5 changed files with 346 additions and 10 deletions

View file

@ -11,6 +11,7 @@ FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate
RUN npm run build
# Production image

View file

@ -0,0 +1,163 @@
import { Suspense } from 'react';
import { notFound, redirect } from 'next/navigation';
import Link from 'next/link';
import {
getInventoryDetails,
type InventoryCategory,
} from '@/services/inventory';
import {
getQuestSession,
getActiveCompany,
isSubUser,
} from '@/lib/permissions';
import { InventoryDetailTable } from '@/components/inventory/inventory-detail-table';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { ArrowLeft } from 'lucide-react';
export const dynamic = 'force-dynamic';
const VALID_CATEGORIES: InventoryCategory[] = [
'wip',
'finished-goods',
'processed-other',
'unprocessed',
'unprocessed-rr',
'processed-rr',
];
const CATEGORY_TITLES: Record<InventoryCategory, string> = {
wip: 'Work In Progress',
'finished-goods': 'Finished Goods',
'processed-other': 'Processed Other',
unprocessed: 'Unprocessed',
'unprocessed-rr': 'Unprocessed R&R',
'processed-rr': 'Processed R&R',
};
type PageProps = {
params: Promise<{ category: string }>;
searchParams: Promise<{ part?: string; plant?: string; warehouse?: string }>;
};
async function InventoryDetailData({
category,
part,
plant,
warehouse,
}: {
category: InventoryCategory;
part?: string;
plant?: string;
warehouse?: string;
}) {
const session = await getQuestSession();
const activeCompany = await getActiveCompany();
const userIsSubUser = await isSubUser();
if (!session || !activeCompany) {
redirect('/select-company');
}
// Block sub-users from R&R and Unprocessed categories
if (userIsSubUser) {
const blockedCategories: InventoryCategory[] = [
'unprocessed',
'unprocessed-rr',
'processed-rr',
];
if (blockedCategories.includes(category)) {
redirect('/inventory');
}
}
const dbName = `[${process.env.PORTAL_DB_NAME || 'VorteqPortal'}]`;
const sub = userIsSubUser ? 1 : 0;
const details = await getInventoryDetails(
category,
activeCompany.epicor_cust_id,
dbName,
sub,
{
partNum: part,
plant,
warehouse,
}
).catch((err) => {
console.error('Failed to fetch inventory details:', err);
return [];
});
return (
<InventoryDetailTable
data={details}
partNum={part}
plant={plant}
warehouse={warehouse}
/>
);
}
function LoadingSkeleton() {
return (
<Card>
<CardContent className="p-6">
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<div
key={i}
className="h-12 w-full animate-pulse rounded bg-muted"
/>
))}
</div>
</CardContent>
</Card>
);
}
export default async function InventoryDetailPage(props: PageProps) {
const params = await props.params;
const searchParams = await props.searchParams;
const category = params.category as InventoryCategory;
// Validate category
if (!VALID_CATEGORIES.includes(category)) {
notFound();
}
const { part, plant, warehouse } = searchParams;
return (
<div>
<div className="mb-6 flex items-center gap-4">
<Link href={`/inventory/${category}`}>
<Button variant="outline" size="sm">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Summary
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold">
{CATEGORY_TITLES[category]} Detail
</h1>
<p className="text-muted-foreground">
Detailed inventory breakdown
{part || plant || warehouse
? ` (filtered)`
: ` (all items)`}
</p>
</div>
</div>
<Suspense fallback={<LoadingSkeleton />}>
<InventoryDetailData
category={category}
part={part}
plant={plant}
warehouse={warehouse}
/>
</Suspense>
</div>
);
}

View file

@ -0,0 +1,166 @@
'use client';
import { useState } from 'react';
import type { InventoryDetailRow } from '@/services/inventory';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Download, Search } from 'lucide-react';
type Props = {
data: InventoryDetailRow[];
partNum?: string;
plant?: string;
warehouse?: string;
};
export function InventoryDetailTable({
data,
partNum,
plant,
warehouse,
}: Props) {
const [searchTerm, setSearchTerm] = useState('');
const filteredData = data.filter((row) => {
const searchLower = searchTerm.toLowerCase();
return (
row.part_num?.toLowerCase().includes(searchLower) ||
row.plant_name?.toLowerCase().includes(searchLower) ||
row.warehouse_desc?.toLowerCase().includes(searchLower) ||
row.bin_num?.toLowerCase().includes(searchLower) ||
row.lot_num?.toLowerCase().includes(searchLower)
);
});
const handleExportCSV = () => {
const headers = [
'Part Number',
'Plant',
'Warehouse',
'Bin',
'Lot',
'Serial',
'On Hand Qty',
'UOM',
'Description',
];
const rows = filteredData.map((row) => [
row.part_num || '',
row.plant_name || '',
row.warehouse_desc || '',
row.bin_num || '',
row.lot_num || '',
row.serial_num || '',
row.on_hand_qty?.toString() || '0',
row.uom || '',
row.part_description || '',
]);
const csvContent = [headers, ...rows]
.map((row) => row.map((cell) => `"${cell}"`).join(','))
.join('\n');
const blob = new Blob([csvContent], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `inventory-detail-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
window.URL.revokeObjectURL(url);
};
return (
<Card>
<CardHeader>
<CardTitle>Inventory Detail</CardTitle>
<CardDescription>
{partNum && `Part: ${partNum}`}
{plant && ` | Plant: ${plant}`}
{warehouse && ` | Warehouse: ${warehouse}`}
</CardDescription>
</CardHeader>
<CardContent>
<div className="mb-4 flex items-center gap-4">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search part, plant, warehouse, bin, or lot..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
<Button onClick={handleExportCSV} variant="outline">
<Download className="mr-2 h-4 w-4" />
Export CSV
</Button>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Part Number</TableHead>
<TableHead>Plant</TableHead>
<TableHead>Warehouse</TableHead>
<TableHead>Bin</TableHead>
<TableHead>Lot</TableHead>
<TableHead>Serial</TableHead>
<TableHead className="text-right">On Hand</TableHead>
<TableHead>UOM</TableHead>
<TableHead>Description</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredData.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-center text-muted-foreground">
No inventory found
</TableCell>
</TableRow>
) : (
filteredData.map((row, i) => (
<TableRow key={i}>
<TableCell className="font-medium">{row.part_num}</TableCell>
<TableCell>{row.plant_name}</TableCell>
<TableCell>{row.warehouse_desc}</TableCell>
<TableCell>{row.bin_num || '-'}</TableCell>
<TableCell>{row.lot_num || '-'}</TableCell>
<TableCell>{row.serial_num || '-'}</TableCell>
<TableCell className="text-right">
{row.on_hand_qty?.toFixed(2) || '0.00'}
</TableCell>
<TableCell>{row.uom || '-'}</TableCell>
<TableCell className="max-w-xs truncate">
{row.part_description || '-'}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<div className="mt-4 text-sm text-muted-foreground">
Showing {filteredData.length} of {data.length} items
</div>
</CardContent>
</Card>
);
}

View file

@ -116,7 +116,7 @@ export function InventorySummaryTable({
<TableRow key={idx}>
<TableCell>
<Link
href={`/inventory/${category}/details?part=${row.part_num}&plant=${row.plant}&warehouse=${row.warehouse}`}
href={`/inventory/${category}/detail?part=${row.part_num}&plant=${row.plant}&warehouse=${row.warehouse}`}
className="font-medium hover:underline"
>
{row.part_num}

View file

@ -19,17 +19,23 @@ export type InventorySummaryRow = {
export type InventoryDetailRow = {
part_num: string;
description: string;
lot_num: string;
plant: string;
warehouse: string;
bin_num: string;
description?: string;
part_description?: string;
lot_num?: string;
serial_num?: string;
plant?: string;
plant_name?: string;
warehouse?: string;
warehouse_desc?: string;
bin_num?: string;
on_hand_qty: number;
allocated_qty: number;
available_qty: number;
um: string;
receipt_date: Date;
allocated_qty?: number;
available_qty?: number;
um?: string;
uom?: string;
receipt_date?: Date;
paint_code?: string;
[key: string]: unknown; // Allow additional fields from stored procedures
};
export type InventoryCategory =