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 <noreply@anthropic.com>
This commit is contained in:
parent
56ce4ba940
commit
e8d995dbe8
4 changed files with 406 additions and 123 deletions
82
src/app/api/inventory/details/route.ts
Normal file
82
src/app/api/inventory/details/route.ts
Normal file
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,50 +26,84 @@ type Props = {
|
||||||
partNum?: string;
|
partNum?: string;
|
||||||
plant?: string;
|
plant?: string;
|
||||||
warehouse?: 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,
|
data,
|
||||||
partNum,
|
partNum,
|
||||||
plant,
|
}: {
|
||||||
warehouse,
|
data: InventoryDetailRow[];
|
||||||
}: Props) {
|
partNum?: string;
|
||||||
|
}) {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
const filteredData = data.filter((row) => {
|
const filteredData = data.filter((row) => {
|
||||||
const searchLower = searchTerm.toLowerCase();
|
if (!searchTerm) return true;
|
||||||
|
const s = searchTerm.toLowerCase();
|
||||||
return (
|
return (
|
||||||
row.part_num?.toLowerCase().includes(searchLower) ||
|
row.CustPartNum?.toLowerCase().includes(s) ||
|
||||||
row.plant_name?.toLowerCase().includes(searchLower) ||
|
row.SkidNum?.toLowerCase().includes(s) ||
|
||||||
row.warehouse_desc?.toLowerCase().includes(searchLower) ||
|
row.LotNum?.toLowerCase().includes(s) ||
|
||||||
row.bin_num?.toLowerCase().includes(searchLower) ||
|
row.MfgLot?.toLowerCase().includes(s) ||
|
||||||
row.lot_num?.toLowerCase().includes(searchLower)
|
row.Bin?.toLowerCase().includes(s) ||
|
||||||
|
row.CustomerPoNum?.toLowerCase().includes(s) ||
|
||||||
|
row.VorteqSalesOrderNum?.toLowerCase().includes(s) ||
|
||||||
|
row.VorteqJobNum?.toLowerCase().includes(s)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleExportCSV = () => {
|
const handleExportCSV = () => {
|
||||||
const headers = [
|
const headers = [
|
||||||
'Part Number',
|
'Cust Part#',
|
||||||
'Plant',
|
'Skid#',
|
||||||
'Warehouse',
|
'Lot#',
|
||||||
'Bin',
|
'Mfg Lot#',
|
||||||
'Lot',
|
'Bin#',
|
||||||
'Serial',
|
'Qty LB',
|
||||||
'On Hand Qty',
|
'Lin. Ft',
|
||||||
'UOM',
|
'Theo. Wt',
|
||||||
'Description',
|
'WIP/FG',
|
||||||
|
'# Coils Per Skid',
|
||||||
|
'PO #',
|
||||||
|
'Sales Order',
|
||||||
|
'Job Order',
|
||||||
|
'Alloc # / Release Id',
|
||||||
|
'Date Processed',
|
||||||
];
|
];
|
||||||
|
|
||||||
const rows = filteredData.map((row) => [
|
const rows = filteredData.map((row) => [
|
||||||
row.part_num || '',
|
row.CustPartNum ?? '',
|
||||||
row.plant_name || '',
|
row.SkidNum ?? '',
|
||||||
row.warehouse_desc || '',
|
row.LotNum ?? '',
|
||||||
row.bin_num || '',
|
row.MfgLot ?? '',
|
||||||
row.lot_num || '',
|
row.Bin ?? '',
|
||||||
row.serial_num || '',
|
row.OnHandQty ?? '',
|
||||||
row.on_hand_qty?.toString() || '0',
|
row.LinearFt ?? '',
|
||||||
row.uom || '',
|
row.TheoreticalWeight ?? '',
|
||||||
row.part_description || '',
|
row.WIP_FG ?? '',
|
||||||
|
row.NumCoilsPerSkid ?? '',
|
||||||
|
row.CustomerPoNum ?? '',
|
||||||
|
row.VorteqSalesOrderNum ?? '',
|
||||||
|
row.VorteqJobNum ?? '',
|
||||||
|
[row.AllocationNumber, row.ShipmentReleaseId]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' / '),
|
||||||
|
row.DateProcessedToInventory ?? '',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const csvContent = [headers, ...rows]
|
const csvContent = [headers, ...rows]
|
||||||
|
|
@ -80,11 +114,120 @@ export function InventoryDetailTable({
|
||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
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();
|
a.click();
|
||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<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 by part, skid, lot, PO, or sales order..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleExportCSV} variant="outline" size="sm">
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Export CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Cust Part#</TableHead>
|
||||||
|
<TableHead>Skid#</TableHead>
|
||||||
|
<TableHead>Lot#</TableHead>
|
||||||
|
<TableHead>Mfg Lot#</TableHead>
|
||||||
|
<TableHead>Bin#</TableHead>
|
||||||
|
<TableHead className="text-right">Qty LB</TableHead>
|
||||||
|
<TableHead className="text-right">Lin. Ft</TableHead>
|
||||||
|
<TableHead className="text-right">Theo. Wt</TableHead>
|
||||||
|
<TableHead>WIP/FG</TableHead>
|
||||||
|
<TableHead className="text-right"># Coils</TableHead>
|
||||||
|
<TableHead>PO #</TableHead>
|
||||||
|
<TableHead>Sales Order / Job</TableHead>
|
||||||
|
<TableHead>Alloc # / Release</TableHead>
|
||||||
|
<TableHead>Date Processed</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredData.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={14}
|
||||||
|
className="text-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
No inventory found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
filteredData.map((row, i) => (
|
||||||
|
<TableRow key={i}>
|
||||||
|
<TableCell>{row.CustPartNum ?? ''}</TableCell>
|
||||||
|
<TableCell>{row.SkidNum ?? ''}</TableCell>
|
||||||
|
<TableCell>{row.LotNum ?? ''}</TableCell>
|
||||||
|
<TableCell>{row.MfgLot ?? ''}</TableCell>
|
||||||
|
<TableCell>{row.Bin ?? ''}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{formatNum(row.OnHandQty)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{formatNum(row.LinearFt)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{formatNum(row.TheoreticalWeight)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{row.WIP_FG ?? ''}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{formatNum(row.NumCoilsPerSkid)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{row.CustomerPoNum ?? ''}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{[row.VorteqSalesOrderNum, row.VorteqJobNum]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' / ')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{[row.AllocationNumber, row.ShipmentReleaseId]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' / ')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{formatDate(row.DateProcessedToInventory)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 text-sm text-muted-foreground">
|
||||||
|
Showing {filteredData.length} of {data.length} items
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InventoryDetailTable({
|
||||||
|
data,
|
||||||
|
partNum,
|
||||||
|
plant,
|
||||||
|
warehouse,
|
||||||
|
embedded = false,
|
||||||
|
}: Props) {
|
||||||
|
if (embedded) {
|
||||||
|
return <DetailTableContent data={data} partNum={partNum} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -96,70 +239,7 @@ export function InventoryDetailTable({
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="mb-4 flex items-center gap-4">
|
<DetailTableContent data={data} partNum={partNum} />
|
||||||
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import Link from 'next/link';
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
|
@ -12,8 +11,17 @@ import {
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
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 { InventorySummaryRow } from '@/services/inventory';
|
||||||
|
import type { InventoryDetailRow } from '@/services/inventory';
|
||||||
|
import { InventoryDetailTable } from './inventory-detail-table';
|
||||||
|
|
||||||
type InventorySummaryTableProps = {
|
type InventorySummaryTableProps = {
|
||||||
data: InventorySummaryRow[];
|
data: InventorySummaryRow[];
|
||||||
|
|
@ -25,6 +33,13 @@ export function InventorySummaryTable({
|
||||||
category,
|
category,
|
||||||
}: InventorySummaryTableProps) {
|
}: InventorySummaryTableProps) {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [selectedRow, setSelectedRow] = useState<InventorySummaryRow | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [detailData, setDetailData] = useState<InventoryDetailRow[]>([]);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
const [detailError, setDetailError] = useState<string | null>(null);
|
||||||
|
|
||||||
const filteredData = data.filter(
|
const filteredData = data.filter(
|
||||||
(row) =>
|
(row) =>
|
||||||
|
|
@ -67,6 +82,41 @@ export function InventorySummaryTable({
|
||||||
window.URL.revokeObjectURL(url);
|
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) {
|
if (data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
||||||
|
|
@ -123,13 +173,14 @@ export function InventorySummaryTable({
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">{row.rows}</TableCell>
|
<TableCell className="text-right">{row.rows}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Link
|
<Button
|
||||||
href={`/inventory/${category}/detail?part=${encodeURIComponent(row.part_num)}&plant=${encodeURIComponent(row.plant_key || row.plant)}&warehouse=${encodeURIComponent(row.warehouse)}`}
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-blue-600 hover:text-blue-800"
|
||||||
|
onClick={() => handleViewDetail(row)}
|
||||||
>
|
>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-blue-600 hover:text-blue-800">
|
<Eye className="h-4 w-4" />
|
||||||
<Eye className="h-4 w-4" />
|
</Button>
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|
@ -140,6 +191,75 @@ export function InventorySummaryTable({
|
||||||
<div className="mt-4 text-sm text-muted-foreground">
|
<div className="mt-4 text-sm text-muted-foreground">
|
||||||
Showing {filteredData.length} of {data.length} items
|
Showing {filteredData.length} of {data.length} items
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Detail Modal */}
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="max-h-[90vh] max-w-6xl overflow-hidden">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Inventory Detail</DialogTitle>
|
||||||
|
{selectedRow && (
|
||||||
|
<DialogDescription asChild>
|
||||||
|
<div className="grid grid-cols-2 gap-x-8 gap-y-1 pt-2 text-sm sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Vorteq Part#:</span>{' '}
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{selectedRow.part_num}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Plant:</span>{' '}
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{selectedRow.plant}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Warehouse:</span>{' '}
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{selectedRow.warehouse}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Qty LB:</span>{' '}
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{Number(selectedRow.on_hand_qty).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<span className="text-muted-foreground">Description:</span>{' '}
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{selectedRow.description || '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogDescription>
|
||||||
|
)}
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="overflow-y-auto">
|
||||||
|
{detailLoading && (
|
||||||
|
<div className="flex h-48 items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detailError && (
|
||||||
|
<div className="flex h-48 items-center justify-center text-destructive">
|
||||||
|
{detailError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!detailLoading && !detailError && (
|
||||||
|
<InventoryDetailTable
|
||||||
|
data={detailData}
|
||||||
|
partNum={selectedRow?.part_num}
|
||||||
|
plant={selectedRow?.plant_key || selectedRow?.plant}
|
||||||
|
warehouse={selectedRow?.warehouse}
|
||||||
|
embedded
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,24 +19,27 @@ export type InventorySummaryRow = {
|
||||||
cust_part_num?: string;
|
cust_part_num?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw detail row returned by Epicor stored procedures.
|
||||||
|
* Column names match the SP output (PascalCase).
|
||||||
|
*/
|
||||||
export type InventoryDetailRow = {
|
export type InventoryDetailRow = {
|
||||||
part_num: string;
|
CustPartNum?: string | null;
|
||||||
description?: string;
|
SkidNum?: string | null;
|
||||||
part_description?: string;
|
LotNum?: string | null;
|
||||||
lot_num?: string;
|
MfgLot?: string | null;
|
||||||
serial_num?: string;
|
Bin?: string | null;
|
||||||
plant?: string;
|
OnHandQty?: number | null;
|
||||||
plant_name?: string;
|
LinearFt?: number | null;
|
||||||
warehouse?: string;
|
TheoreticalWeight?: number | null;
|
||||||
warehouse_desc?: string;
|
WIP_FG?: string | null;
|
||||||
bin_num?: string;
|
NumCoilsPerSkid?: number | null;
|
||||||
on_hand_qty: number;
|
CustomerPoNum?: string | null;
|
||||||
allocated_qty?: number;
|
VorteqSalesOrderNum?: string | null;
|
||||||
available_qty?: number;
|
VorteqJobNum?: string | null;
|
||||||
um?: string;
|
AllocationNumber?: string | null;
|
||||||
uom?: string;
|
ShipmentReleaseId?: string | null;
|
||||||
receipt_date?: Date;
|
DateProcessedToInventory?: string | null;
|
||||||
paint_code?: string;
|
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -256,12 +259,10 @@ export async function getInventoryDetails(
|
||||||
DBNAME: dbName,
|
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) {
|
if (procConfig.isV6) {
|
||||||
params.Customer = custId;
|
|
||||||
params.SUBUSER = sub;
|
params.SUBUSER = sub;
|
||||||
} else {
|
|
||||||
params.CUSTID = custId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add filters if provided (only for filtered variant)
|
// Add filters if provided (only for filtered variant)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue