diff --git a/src/app/api/inventory/details/route.ts b/src/app/api/inventory/details/route.ts
new file mode 100644
index 0000000..fc25732
--- /dev/null
+++ b/src/app/api/inventory/details/route.ts
@@ -0,0 +1,82 @@
+import { NextRequest, NextResponse } from 'next/server';
+import {
+ getQuestSession,
+ getActiveCompany,
+ isSubUser,
+} from '@/lib/permissions';
+import {
+ getInventoryDetails,
+ type InventoryCategory,
+} from '@/services/inventory';
+
+const VALID_CATEGORIES: InventoryCategory[] = [
+ 'wip',
+ 'finished-goods',
+ 'processed-other',
+ 'unprocessed',
+ 'unprocessed-rr',
+ 'processed-rr',
+];
+
+const BLOCKED_FOR_SUBUSER: InventoryCategory[] = [
+ 'unprocessed',
+ 'unprocessed-rr',
+ 'processed-rr',
+];
+
+export async function GET(request: NextRequest) {
+ try {
+ const session = await getQuestSession();
+ if (!session) {
+ return NextResponse.json(
+ { error: 'Unauthorized', code: 'UNAUTHORIZED' },
+ { status: 401 }
+ );
+ }
+
+ const activeCompany = await getActiveCompany();
+ if (!activeCompany) {
+ return NextResponse.json(
+ { error: 'No active company selected', code: 'NO_COMPANY' },
+ { status: 400 }
+ );
+ }
+
+ const { searchParams } = request.nextUrl;
+ const category = searchParams.get('category') as InventoryCategory;
+ const part = searchParams.get('part') || undefined;
+ const plant = searchParams.get('plant') || undefined;
+ const warehouse = searchParams.get('warehouse') || undefined;
+
+ if (!category || !VALID_CATEGORIES.includes(category)) {
+ return NextResponse.json(
+ { error: 'Invalid category', code: 'INVALID_INPUT' },
+ { status: 400 }
+ );
+ }
+
+ const userIsSubUser = await isSubUser();
+ if (userIsSubUser && BLOCKED_FOR_SUBUSER.includes(category)) {
+ return NextResponse.json({ data: [] });
+ }
+
+ 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 }
+ );
+
+ return NextResponse.json({ data: details });
+ } catch (error) {
+ console.error('Error fetching inventory details:', error);
+ return NextResponse.json(
+ { error: 'Internal server error', code: 'INTERNAL_ERROR' },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/components/inventory/inventory-detail-table.tsx b/src/components/inventory/inventory-detail-table.tsx
index bb4c130..b79ee0a 100644
--- a/src/components/inventory/inventory-detail-table.tsx
+++ b/src/components/inventory/inventory-detail-table.tsx
@@ -26,50 +26,84 @@ type Props = {
partNum?: string;
plant?: string;
warehouse?: string;
+ embedded?: boolean;
};
-export function InventoryDetailTable({
+function formatNum(val: number | string | null | undefined): string {
+ if (val == null || val === '') return '';
+ const n = Number(val);
+ if (isNaN(n)) return '';
+ return n.toLocaleString('en-US');
+}
+
+function formatDate(val: string | null | undefined): string {
+ if (!val) return '';
+ const d = new Date(val);
+ if (isNaN(d.getTime())) return '';
+ return `${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()}`;
+}
+
+function DetailTableContent({
data,
partNum,
- plant,
- warehouse,
-}: Props) {
+}: {
+ data: InventoryDetailRow[];
+ partNum?: string;
+}) {
const [searchTerm, setSearchTerm] = useState('');
const filteredData = data.filter((row) => {
- const searchLower = searchTerm.toLowerCase();
+ if (!searchTerm) return true;
+ const s = 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)
+ row.CustPartNum?.toLowerCase().includes(s) ||
+ row.SkidNum?.toLowerCase().includes(s) ||
+ row.LotNum?.toLowerCase().includes(s) ||
+ row.MfgLot?.toLowerCase().includes(s) ||
+ row.Bin?.toLowerCase().includes(s) ||
+ row.CustomerPoNum?.toLowerCase().includes(s) ||
+ row.VorteqSalesOrderNum?.toLowerCase().includes(s) ||
+ row.VorteqJobNum?.toLowerCase().includes(s)
);
});
const handleExportCSV = () => {
const headers = [
- 'Part Number',
- 'Plant',
- 'Warehouse',
- 'Bin',
- 'Lot',
- 'Serial',
- 'On Hand Qty',
- 'UOM',
- 'Description',
+ 'Cust Part#',
+ 'Skid#',
+ 'Lot#',
+ 'Mfg Lot#',
+ 'Bin#',
+ 'Qty LB',
+ 'Lin. Ft',
+ 'Theo. Wt',
+ 'WIP/FG',
+ '# Coils Per Skid',
+ 'PO #',
+ 'Sales Order',
+ 'Job Order',
+ 'Alloc # / Release Id',
+ 'Date Processed',
];
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 || '',
+ row.CustPartNum ?? '',
+ row.SkidNum ?? '',
+ row.LotNum ?? '',
+ row.MfgLot ?? '',
+ row.Bin ?? '',
+ row.OnHandQty ?? '',
+ row.LinearFt ?? '',
+ row.TheoreticalWeight ?? '',
+ row.WIP_FG ?? '',
+ row.NumCoilsPerSkid ?? '',
+ row.CustomerPoNum ?? '',
+ row.VorteqSalesOrderNum ?? '',
+ row.VorteqJobNum ?? '',
+ [row.AllocationNumber, row.ShipmentReleaseId]
+ .filter(Boolean)
+ .join(' / '),
+ row.DateProcessedToInventory ?? '',
]);
const csvContent = [headers, ...rows]
@@ -80,11 +114,120 @@ export function InventoryDetailTable({
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`;
+ const partSuffix = partNum ? `-${partNum}` : '';
+ a.download = `inventory-detail${partSuffix}-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
window.URL.revokeObjectURL(url);
};
+ return (
+ <>
+
+
+
+ setSearchTerm(e.target.value)}
+ className="pl-8"
+ />
+
+
+
+
+
+
+
+
+ Cust Part#
+ Skid#
+ Lot#
+ Mfg Lot#
+ Bin#
+ Qty LB
+ Lin. Ft
+ Theo. Wt
+ WIP/FG
+ # Coils
+ PO #
+ Sales Order / Job
+ Alloc # / Release
+ Date Processed
+
+
+
+ {filteredData.length === 0 ? (
+
+
+ No inventory found
+
+
+ ) : (
+ filteredData.map((row, i) => (
+
+ {row.CustPartNum ?? ''}
+ {row.SkidNum ?? ''}
+ {row.LotNum ?? ''}
+ {row.MfgLot ?? ''}
+ {row.Bin ?? ''}
+
+ {formatNum(row.OnHandQty)}
+
+
+ {formatNum(row.LinearFt)}
+
+
+ {formatNum(row.TheoreticalWeight)}
+
+ {row.WIP_FG ?? ''}
+
+ {formatNum(row.NumCoilsPerSkid)}
+
+ {row.CustomerPoNum ?? ''}
+
+ {[row.VorteqSalesOrderNum, row.VorteqJobNum]
+ .filter(Boolean)
+ .join(' / ')}
+
+
+ {[row.AllocationNumber, row.ShipmentReleaseId]
+ .filter(Boolean)
+ .join(' / ')}
+
+
+ {formatDate(row.DateProcessedToInventory)}
+
+
+ ))
+ )}
+
+
+
+
+
+ Showing {filteredData.length} of {data.length} items
+
+ >
+ );
+}
+
+export function InventoryDetailTable({
+ data,
+ partNum,
+ plant,
+ warehouse,
+ embedded = false,
+}: Props) {
+ if (embedded) {
+ return ;
+ }
+
return (
@@ -96,70 +239,7 @@ export function InventoryDetailTable({
-
-
-
- setSearchTerm(e.target.value)}
- className="pl-8"
- />
-
-
-
-
-
-
-
-
- Part Number
- Plant
- Warehouse
- Bin
- Lot
- Serial
- On Hand
- UOM
- Description
-
-
-
- {filteredData.length === 0 ? (
-
-
- No inventory found
-
-
- ) : (
- filteredData.map((row, i) => (
-
- {row.part_num}
- {row.plant_name}
- {row.warehouse_desc}
- {row.bin_num || '-'}
- {row.lot_num || '-'}
- {row.serial_num || '-'}
-
- {row.on_hand_qty?.toFixed(2) || '0.00'}
-
- {row.uom || '-'}
-
- {row.part_description || '-'}
-
-
- ))
- )}
-
-
-
-
-
- Showing {filteredData.length} of {data.length} items
-
+
);
diff --git a/src/components/inventory/inventory-summary-table.tsx b/src/components/inventory/inventory-summary-table.tsx
index 9424233..8d7b7b8 100644
--- a/src/components/inventory/inventory-summary-table.tsx
+++ b/src/components/inventory/inventory-summary-table.tsx
@@ -1,7 +1,6 @@
'use client';
-import { useState } from 'react';
-import Link from 'next/link';
+import { useState, useCallback } from 'react';
import {
Table,
TableBody,
@@ -12,8 +11,17 @@ import {
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
-import { Search, Download, Eye } from 'lucide-react';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+} from '@/components/ui/dialog';
+import { Search, Download, Eye, Loader2 } from 'lucide-react';
import { InventorySummaryRow } from '@/services/inventory';
+import type { InventoryDetailRow } from '@/services/inventory';
+import { InventoryDetailTable } from './inventory-detail-table';
type InventorySummaryTableProps = {
data: InventorySummaryRow[];
@@ -25,6 +33,13 @@ export function InventorySummaryTable({
category,
}: InventorySummaryTableProps) {
const [searchTerm, setSearchTerm] = useState('');
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [selectedRow, setSelectedRow] = useState(
+ null
+ );
+ const [detailData, setDetailData] = useState([]);
+ const [detailLoading, setDetailLoading] = useState(false);
+ const [detailError, setDetailError] = useState(null);
const filteredData = data.filter(
(row) =>
@@ -67,6 +82,41 @@ export function InventorySummaryTable({
window.URL.revokeObjectURL(url);
};
+ const handleViewDetail = useCallback(
+ async (row: InventorySummaryRow) => {
+ setSelectedRow(row);
+ setDialogOpen(true);
+ setDetailLoading(true);
+ setDetailError(null);
+ setDetailData([]);
+
+ try {
+ const params = new URLSearchParams({ category });
+ if (row.part_num) params.set('part', row.part_num);
+ if (row.plant_key || row.plant)
+ params.set('plant', row.plant_key || row.plant);
+ if (row.warehouse) params.set('warehouse', row.warehouse);
+
+ const response = await fetch(
+ `/api/inventory/details?${params.toString()}`
+ );
+
+ if (!response.ok) {
+ throw new Error('Failed to fetch detail data');
+ }
+
+ const json = await response.json();
+ setDetailData(json.data || []);
+ } catch (err) {
+ console.error('Error fetching inventory details:', err);
+ setDetailError('Failed to load detail data. Please try again.');
+ } finally {
+ setDetailLoading(false);
+ }
+ },
+ [category]
+ );
+
if (data.length === 0) {
return (
@@ -123,13 +173,14 @@ export function InventorySummaryTable({
{row.rows}
- handleViewDetail(row)}
>
-
-
+
+
))}
@@ -140,6 +191,75 @@ export function InventorySummaryTable({
Showing {filteredData.length} of {data.length} items
+
+ {/* Detail Modal */}
+
);
}
diff --git a/src/services/inventory.ts b/src/services/inventory.ts
index 859ce3c..5706c6f 100644
--- a/src/services/inventory.ts
+++ b/src/services/inventory.ts
@@ -19,24 +19,27 @@ export type InventorySummaryRow = {
cust_part_num?: string;
};
+/**
+ * Raw detail row returned by Epicor stored procedures.
+ * Column names match the SP output (PascalCase).
+ */
export type InventoryDetailRow = {
- part_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;
- uom?: string;
- receipt_date?: Date;
- paint_code?: string;
+ CustPartNum?: string | null;
+ SkidNum?: string | null;
+ LotNum?: string | null;
+ MfgLot?: string | null;
+ Bin?: string | null;
+ OnHandQty?: number | null;
+ LinearFt?: number | null;
+ TheoreticalWeight?: number | null;
+ WIP_FG?: string | null;
+ NumCoilsPerSkid?: number | null;
+ CustomerPoNum?: string | null;
+ VorteqSalesOrderNum?: string | null;
+ VorteqJobNum?: string | null;
+ AllocationNumber?: string | null;
+ ShipmentReleaseId?: string | null;
+ DateProcessedToInventory?: string | null;
[key: string]: unknown;
};
@@ -256,12 +259,10 @@ export async function getInventoryDetails(
DBNAME: dbName,
};
- // V6 procedures use @Customer, non-V6 use @CUSTID
+ // All detail procedures use @CUSTID; V6 also needs @SUBUSER
+ params.CUSTID = custId;
if (procConfig.isV6) {
- params.Customer = custId;
params.SUBUSER = sub;
- } else {
- params.CUSTID = custId;
}
// Add filters if provided (only for filtered variant)