From e47c35e9a9452b75e6c9563c60916144a0392a6b Mon Sep 17 00:00:00 2001 From: Lorentz Date: Mon, 16 Feb 2026 18:33:26 +0000 Subject: [PATCH] feat: add inventory detail modal with correct legacy columns Replace full-page navigation with an inline modal dialog when clicking View on inventory summary rows. Fix V6 detail stored procedure params (@CUSTID/@sub instead of @Customer/@SUBUSER) and update detail table columns to match legacy portal: Cust Part#, Skid#, Lot#, Mfg Lot#, Bin#, Qty LB, Lin. Ft, Theo. Wt, WIP/FG, # Coils, PO#, Sales Order/Job, Alloc#/Release, Date Processed. Co-Authored-By: Claude Opus 4.6 --- src/app/api/inventory/details/route.ts | 82 ++++++ .../inventory/inventory-detail-table.tsx | 266 ++++++++++++------ .../inventory/inventory-summary-table.tsx | 138 ++++++++- src/services/inventory.ts | 45 +-- 4 files changed, 407 insertions(+), 124 deletions(-) create mode 100644 src/app/api/inventory/details/route.ts 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 */} + + + + Inventory Detail + {selectedRow && ( + +
+
+ Vorteq Part#:{' '} + + {selectedRow.part_num} + +
+
+ Plant:{' '} + + {selectedRow.plant} + +
+
+ Warehouse:{' '} + + {selectedRow.warehouse} + +
+
+ Qty LB:{' '} + + {Number(selectedRow.on_hand_qty).toLocaleString()} + +
+
+ Description:{' '} + + {selectedRow.description || '-'} + +
+
+
+ )} +
+ +
+ {detailLoading && ( +
+ +
+ )} + + {detailError && ( +
+ {detailError} +
+ )} + + {!detailLoading && !detailError && ( + + )} +
+
+
); } diff --git a/src/services/inventory.ts b/src/services/inventory.ts index 859ce3c..91d2fb9 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 + // V6 detail procedures use @CUSTID and @sub (unlike summary SPs which use @Customer/@SUBUSER) + params.CUSTID = custId; if (procConfig.isV6) { - params.Customer = custId; - params.SUBUSER = sub; - } else { - params.CUSTID = custId; + params.sub = sub; } // Add filters if provided (only for filtered variant)