diff --git a/Dockerfile b/Dockerfile index f2ca0c3..deef428 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/src/app/(portal)/inventory/[category]/detail/page.tsx b/src/app/(portal)/inventory/[category]/detail/page.tsx new file mode 100644 index 0000000..fd7e8fa --- /dev/null +++ b/src/app/(portal)/inventory/[category]/detail/page.tsx @@ -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 = { + 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 ( + + ); +} + +function LoadingSkeleton() { + return ( + + +
+ {[...Array(5)].map((_, i) => ( +
+ ))} +
+ + + ); +} + +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 ( +
+
+ + + +
+

+ {CATEGORY_TITLES[category]} Detail +

+

+ Detailed inventory breakdown + {part || plant || warehouse + ? ` (filtered)` + : ` (all items)`} +

+
+
+ + }> + + +
+ ); +} diff --git a/src/components/inventory/inventory-detail-table.tsx b/src/components/inventory/inventory-detail-table.tsx new file mode 100644 index 0000000..bb4c130 --- /dev/null +++ b/src/components/inventory/inventory-detail-table.tsx @@ -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 ( + + + Inventory Detail + + {partNum && `Part: ${partNum}`} + {plant && ` | Plant: ${plant}`} + {warehouse && ` | Warehouse: ${warehouse}`} + + + +
+
+ + 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 479b0e8..49b8353 100644 --- a/src/components/inventory/inventory-summary-table.tsx +++ b/src/components/inventory/inventory-summary-table.tsx @@ -116,7 +116,7 @@ export function InventorySummaryTable({ {row.part_num} diff --git a/src/services/inventory.ts b/src/services/inventory.ts index ea40f22..b740569 100644 --- a/src/services/inventory.ts +++ b/src/services/inventory.ts @@ -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 =