From 53c35cc273e92a253125e025d4e9e286edb8c130 Mon Sep 17 00:00:00 2001 From: Lorentz Hinrichsen Date: Tue, 17 Feb 2026 09:14:52 -0500 Subject: [PATCH] feat: add order ack detail/PDF, BOL detail/PDF, coil activity usage & receipts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C-005: Order Acknowledgement Detail + PDF - Order detail page with full line items, releases, addresses, paint codes - PDF export via Puppeteer matching legacy format - Clickable order # links and PDF icons in orders table C-007: BOL Detail + PDF - BOL detail page with ship-from/to, line items, weights - PDF export matching legacy BOL format - PDF icon column in shipments table C-008: Coil Activity - Usage Report - Date range picker (max 31 days, default last 10 days) - Reusable UI: Popover, Calendar (react-day-picker v9), DateRangePicker - SQL from legacy portal_CoilActivityUsage.sql with OnHandQty dedup - 11-column sortable table with search and CSV export C-009: Coil Activity - Receipts Report - VGL customer exception (special SQL vs portal view) - 10-column sortable table with search and CSV export - Shared date range picker component Also: dashboard API route, shipments API route, HDC→HDM mapping, Puppeteer PDF infrastructure, improved error handling. Progress: 9/16 Phase 2 tasks complete. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 18 +- TASKS.md | 70 ++-- next.config.ts | 1 + package-lock.json | 76 ---- .../(portal)/coil-activity/receipts/page.tsx | 92 +++++ src/app/(portal)/coil-activity/usage/page.tsx | 92 +++++ src/app/(portal)/dashboard/page.tsx | 153 ++++---- src/app/(portal)/orders/[orderNum]/page.tsx | 146 +++++++ src/app/(portal)/shipments/[bol]/page.tsx | 128 +++++++ src/app/(portal)/shipments/page.tsx | 55 +-- src/app/api/coil-activity/receipts/route.ts | 67 ++++ src/app/api/coil-activity/usage/route.ts | 69 ++++ src/app/api/dashboard/route.ts | 53 +++ src/app/api/orders/[orderNum]/pdf/route.ts | 56 +++ src/app/api/orders/[orderNum]/route.ts | 47 +++ src/app/api/shipments/[bol]/pdf/route.ts | 44 +++ src/app/api/shipments/[bol]/route.ts | 48 +++ src/app/api/shipments/route.ts | 22 ++ .../coil-activity/receipts-table.tsx | 248 ++++++++++++ src/components/coil-activity/usage-table.tsx | 262 +++++++++++++ .../dashboard/recent-shipments-table.tsx | 4 +- src/components/layout/portal-header.tsx | 3 +- .../orders/order-acknowledgement-detail.tsx | 356 ++++++++++++++++++ src/components/orders/orders-table.tsx | 26 +- src/components/shipments/bol-detail.tsx | 200 ++++++++++ src/components/shipments/shipments-table.tsx | 52 +-- src/components/ui/calendar.tsx | 74 ++++ src/components/ui/date-range-picker.tsx | 85 +++++ src/lib/epicor.ts | 8 +- src/lib/pdf-templates/bol.ts | 210 +++++++++++ .../pdf-templates/order-acknowledgement.ts | 278 ++++++++++++++ src/lib/pdf.ts | 82 ++++ src/services/coil-activity.ts | 258 +++++++++++++ src/services/dashboard.ts | 201 ++++++---- src/services/orders.ts | 293 +++++++++++++- src/services/shipments.ts | 224 +++++++++-- src/types/coil-activity.ts | 33 ++ src/types/orders.ts | 73 ++++ src/types/shipments.ts | 42 +++ 39 files changed, 3894 insertions(+), 355 deletions(-) create mode 100644 src/app/(portal)/coil-activity/receipts/page.tsx create mode 100644 src/app/(portal)/coil-activity/usage/page.tsx create mode 100644 src/app/(portal)/orders/[orderNum]/page.tsx create mode 100644 src/app/(portal)/shipments/[bol]/page.tsx create mode 100644 src/app/api/coil-activity/receipts/route.ts create mode 100644 src/app/api/coil-activity/usage/route.ts create mode 100644 src/app/api/dashboard/route.ts create mode 100644 src/app/api/orders/[orderNum]/pdf/route.ts create mode 100644 src/app/api/orders/[orderNum]/route.ts create mode 100644 src/app/api/shipments/[bol]/pdf/route.ts create mode 100644 src/app/api/shipments/[bol]/route.ts create mode 100644 src/app/api/shipments/route.ts create mode 100644 src/components/coil-activity/receipts-table.tsx create mode 100644 src/components/coil-activity/usage-table.tsx create mode 100644 src/components/orders/order-acknowledgement-detail.tsx create mode 100644 src/components/shipments/bol-detail.tsx create mode 100644 src/components/ui/calendar.tsx create mode 100644 src/components/ui/date-range-picker.tsx create mode 100644 src/lib/pdf-templates/bol.ts create mode 100644 src/lib/pdf-templates/order-acknowledgement.ts create mode 100644 src/lib/pdf.ts create mode 100644 src/services/coil-activity.ts create mode 100644 src/types/coil-activity.ts create mode 100644 src/types/orders.ts create mode 100644 src/types/shipments.ts diff --git a/CLAUDE.md b/CLAUDE.md index b3a50ad..a069965 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -142,14 +142,12 @@ quest-vorteq/ ### Not Yet Created (Planned) ``` -src/app/(portal)/shipments/ src/app/(portal)/coil-activity/ src/app/(portal)/invoices/ src/app/(portal)/shipment-requests/ src/app/(portal)/allocation-requests/ src/app/(portal)/jobs/ src/app/(portal)/admin/ -src/services/shipments.ts # Shipment data access src/services/coil-activity.ts # Coil activity data access src/services/ship-requests.ts # Shipment request business logic src/services/alloc-requests.ts # Allocation request business logic @@ -251,6 +249,22 @@ export function ShipmentRequestCart() { ... } - Standard view: `dbo.portal_Orders WHERE CustomerID = @CustID` - HDC exception: uses `dbo.portal_OrdersHDC` view and maps CustID `HDC` → `HDM` +### RSC Serialization — Critical Pattern for Epicor Data + +**Problem:** Next.js RSC (React Server Components) dev mode serializes ALL server-side data and console output to forward to the browser. The `mssql` package returns `recordset` objects with circular references, metadata, and prototype chains that blow up RSC serialization (`RangeError: Maximum call stack size exceeded` at `Set.add` or `Map.set`). This is especially severe for large result sets (e.g., ACM has 7,633 shipment rows). + +**Solution:** For Epicor data displayed in the UI, use a **client-side fetch → API route** pattern instead of RSC server components: + +1. Create an API route (e.g., `/api/shipments/route.ts`) that calls the service and returns `NextResponse.json(data)` +2. Make the page a `'use client'` component that fetches from the API route via `useEffect` + `fetch()` +3. The API route handles auth/session checks and returns plain JSON — no RSC serialization involved + +**When RSC works fine:** Small result sets (< ~500 rows) through `execStoredProc` generally work. The `[...result.recordset]` spread in `execStoredProc` detaches the array-level prototype. For safety, services should still `JSON.parse(JSON.stringify(...))` their return values if passing to RSC. + +**When RSC breaks:** Large result sets (1000+ rows), or any scenario where mssql objects remain in scope during server component rendering. Even with `JSON.parse(JSON.stringify())`, Next.js dev mode console forwarding can serialize the function's closure scope including mssql objects. + +**Rule of thumb:** If an Epicor query can return > 500 rows for any customer, use the API route pattern. + ### Column Mapping Epicor SP results use PascalCase column names. Services map them to snake_case: ```typescript diff --git a/TASKS.md b/TASKS.md index 5a91ac2..8fc7c2d 100644 --- a/TASKS.md +++ b/TASKS.md @@ -128,7 +128,7 @@ ## Phase 2: Core Features (Est. 80-110 hrs) -**Progress:** 2/16 tasks complete +**Progress:** 9/16 tasks complete ### C-001: Dashboard - [x] Dashboard page at `/(portal)/dashboard/page.tsx` @@ -181,43 +181,53 @@ - **Deps:** F-006, F-009 | **Est:** 6 hrs | **Status:** ✅ Complete ### C-005: Order Acknowledgement Detail + PDF -- [ ] `/(portal)/orders/[id]/page.tsx` -- [ ] Service: `getAcknowledgement(customer, poNumber?, orderNum?)` using `SalesOrder.sql` -- [ ] Detail view with all order line information -- [ ] PDF export button (generate PDF server-side) -- **Deps:** C-004 | **Est:** 6 hrs +- [x] `/(portal)/orders/[orderNum]/page.tsx` — client-side fetch with loading skeleton +- [x] Service: `getOrderAcknowledgement(orderNum, custId)` with Epicor SQL (OrderHed/OrderDtl/OrderRel/Customer/ShipTo/Terms/ShipVia/Plant/JobProd) +- [x] Detail view matching legacy PDF layout (addresses, order info, line items, releases, paint codes, comments, totals) +- [x] PDF export via Puppeteer (`/api/orders/[orderNum]/pdf`) +- [x] Orders table: clickable Order # links + PDF download icons +- [x] Cross-customer security verified (BAS cannot access ACM orders) +- [x] UTC date handling fix for correct date display +- [x] FOB plant name resolution (code → description via Erp.Plant) +- **Deps:** C-004 | **Est:** 6 hrs | **Status:** ✅ Complete ### C-006: Shipment List -- [ ] `/(portal)/shipments/page.tsx` -- [ ] Service: `getTop100Shipments(custId, dbName)` via `portal_GetShipmentsV1` -- [ ] ShipToLoc formatting (replace `, ` with line breaks) -- [ ] Data table with shipment details -- [ ] Click-through to BOL detail -- **Deps:** F-006, F-009 | **Est:** 5 hrs +- [x] `/(portal)/shipments/page.tsx` — client-side fetch via API route (RSC-safe for large datasets) +- [x] Service: `getTop100Shipments(custId)` via `portal_GetShipmentsV1` stored procedure +- [x] ShipToLoc formatting (replace `, ` with newlines) +- [x] Data table with search, sort, CSV export (teal-700 header) +- [x] Click-through to BOL detail (`/shipments/{bol_num}`) +- [x] API route at `/api/shipments` with auth + company context +- **Deps:** F-006, F-009 | **Est:** 5 hrs | **Status:** ✅ Complete ### C-007: BOL Detail + PDF -- [ ] `/(portal)/shipments/[bol]/page.tsx` -- [ ] Service: `getBOL(bolNum, custId)` using `getBOL.sql` -- [ ] Detail view with line items -- [ ] PDF export -- **Deps:** C-006 | **Est:** 4 hrs +- [x] `/(portal)/shipments/[bol]/page.tsx` +- [x] Service: `getBOL(bolNum, custId)` using `getBOL.sql` +- [x] Detail view with line items +- [x] PDF export +- **Deps:** C-006 | **Est:** 4 hrs | **Status:** ✅ Complete ### C-008: Coil Activity - Usage Report -- [ ] `/(portal)/coil-activity/usage/page.tsx` -- [ ] Date range picker (with validation) -- [ ] Service: `getCoilActivityUsage(custId, startDate, endDate)` using `portal_CoilActivityUsage.sql` -- [ ] Deduplication logic: only show OnHandQty for most recent DateUsed per LotNum -- [ ] Zero weights → null display -- [ ] Data table with full column set -- **Deps:** F-006, F-009 | **Est:** 6 hrs +- [x] `/(portal)/coil-activity/usage/page.tsx` +- [x] Date range picker (with validation — max 31 days, default last 10 days) +- [x] Reusable UI components: Popover, Calendar (react-day-picker v9), DateRangePicker +- [x] Service: `getCoilUsage(custId, startDate, endDate)` using legacy `portal_CoilActivityUsage.sql` +- [x] Deduplication logic: only show OnHandQty for most recent DateUsed per LotNum +- [x] Zero weights → null display +- [x] Data table with all 11 columns: Date Used, Vorteq Part#, Cust Part#, Part Description, Lot#, Mfg Lot#, Weight, Plant Name, Job#, Cust PO#, Qty LB +- [x] Search, sort, CSV export +- [x] HDC→HDM customer ID mapping +- **Deps:** F-006, F-009 | **Est:** 6 hrs | **Status:** ✅ Complete ### C-009: Coil Activity - Receipts Report -- [ ] `/(portal)/coil-activity/receipts/page.tsx` -- [ ] Date range picker -- [ ] Service: `getCoilActivityReceipts(custId, startDate, endDate)` -- [ ] VGL customer exception (use special SQL) -- [ ] Data table with receipt columns -- **Deps:** F-006, F-009 | **Est:** 4 hrs +- [x] `/(portal)/coil-activity/receipts/page.tsx` +- [x] Date range picker (shared component from C-008) +- [x] Service: `getCoilReceipts(custId, startDate, endDate)` with VGL exception +- [x] VGL customer exception: uses `VGLCoilReceiptsData.sql`; all others use `dbo.portal_CoilActivityReceipts` view +- [x] Data table with all 10 columns: Date Rec, Vorteq Part#, Cust Part#, Part Description, Lot#, Mfg Lot#, Plant Name, Packing Slip, Supplier Name, Mill Order # +- [x] Search, sort, CSV export +- [x] HDC→HDM customer ID mapping +- **Deps:** F-006, F-009 | **Est:** 4 hrs | **Status:** ✅ Complete ### C-010: Coil-by-Coil Report - [ ] `/(portal)/coil-activity/coil-by-coil/page.tsx` diff --git a/next.config.ts b/next.config.ts index a45246b..7ff3a68 100644 --- a/next.config.ts +++ b/next.config.ts @@ -5,6 +5,7 @@ const nextConfig: NextConfig = { reactStrictMode: true, poweredByHeader: false, compress: true, + allowedDevOrigins: ['https://dev.quest.vorteq.local'], experimental: { serverActions: { bodySizeLimit: '10mb', diff --git a/package-lock.json b/package-lock.json index 166d384..a97b0d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -756,7 +756,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -773,7 +772,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -790,7 +788,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -807,7 +804,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -824,7 +820,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -841,7 +836,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -858,7 +852,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -875,7 +868,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -892,7 +884,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -909,7 +900,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -926,7 +916,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -943,7 +932,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -960,7 +948,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -977,7 +964,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -994,7 +980,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1011,7 +996,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1028,7 +1012,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1045,7 +1028,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1062,7 +1044,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1079,7 +1060,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1096,7 +1076,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1113,7 +1092,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1130,7 +1108,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1147,7 +1124,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1164,7 +1140,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1181,7 +1156,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3760,7 +3734,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3774,7 +3747,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3788,7 +3760,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3802,7 +3773,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3816,7 +3786,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3830,7 +3799,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3844,7 +3812,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3858,7 +3825,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3872,7 +3838,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3886,7 +3851,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3900,7 +3864,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3914,7 +3877,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3928,7 +3890,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3942,7 +3903,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3956,7 +3916,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3970,7 +3929,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3984,7 +3942,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3998,7 +3955,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4012,7 +3968,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4026,7 +3981,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4040,7 +3994,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4054,7 +4007,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4068,7 +4020,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4082,7 +4033,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4096,7 +4046,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12540,7 +12489,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -12964,7 +12912,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12981,7 +12928,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12998,7 +12944,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13015,7 +12960,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13032,7 +12976,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13049,7 +12992,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13066,7 +13008,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13083,7 +13024,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13100,7 +13040,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13117,7 +13056,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13134,7 +13072,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13151,7 +13088,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13168,7 +13104,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13185,7 +13120,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13202,7 +13136,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13219,7 +13152,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13236,7 +13168,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13253,7 +13184,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13270,7 +13200,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13287,7 +13216,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13304,7 +13232,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13321,7 +13248,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13338,7 +13264,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13391,7 +13316,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/src/app/(portal)/coil-activity/receipts/page.tsx b/src/app/(portal)/coil-activity/receipts/page.tsx new file mode 100644 index 0000000..fbe1f71 --- /dev/null +++ b/src/app/(portal)/coil-activity/receipts/page.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { subDays } from 'date-fns'; +import type { CoilReceiptRow } from '@/types/coil-activity'; +import { ReceiptsTable } from '@/components/coil-activity/receipts-table'; +import { DateRangePicker } from '@/components/ui/date-range-picker'; +import { Card, CardContent } from '@/components/ui/card'; + +function LoadingSkeleton() { + return ( + + +
+ {[...Array(10)].map((_, i) => ( +
+ ))} +
+ + + ); +} + +export default function CoilReceiptsPage() { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [dateRange, setDateRange] = useState(() => ({ + from: subDays(new Date(), 9), + to: new Date(), + })); + + const fetchData = useCallback(async () => { + setLoading(true); + setError(null); + try { + const startDate = dateRange.from.toISOString().split('T')[0]; + const endDate = dateRange.to.toISOString().split('T')[0]; + const res = await fetch( + `/api/coil-activity/receipts?startDate=${startDate}&endDate=${endDate}` + ); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `HTTP ${res.status}`); + } + const json = await res.json(); + setData(json); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, [dateRange]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + return ( +
+
+
+

Coil Activity — Receipts

+

+ View coil material receipts for the selected date range +

+
+ +
+ + {error ? ( + + + Failed to load receipts data: {error} + + + ) : loading || data === null ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/app/(portal)/coil-activity/usage/page.tsx b/src/app/(portal)/coil-activity/usage/page.tsx new file mode 100644 index 0000000..3c40152 --- /dev/null +++ b/src/app/(portal)/coil-activity/usage/page.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { subDays } from 'date-fns'; +import type { CoilUsageRow } from '@/types/coil-activity'; +import { UsageTable } from '@/components/coil-activity/usage-table'; +import { DateRangePicker } from '@/components/ui/date-range-picker'; +import { Card, CardContent } from '@/components/ui/card'; + +function LoadingSkeleton() { + return ( + + +
+ {[...Array(10)].map((_, i) => ( +
+ ))} +
+ + + ); +} + +export default function CoilUsagePage() { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [dateRange, setDateRange] = useState(() => ({ + from: subDays(new Date(), 9), + to: new Date(), + })); + + const fetchData = useCallback(async () => { + setLoading(true); + setError(null); + try { + const startDate = dateRange.from.toISOString().split('T')[0]; + const endDate = dateRange.to.toISOString().split('T')[0]; + const res = await fetch( + `/api/coil-activity/usage?startDate=${startDate}&endDate=${endDate}` + ); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `HTTP ${res.status}`); + } + const json = await res.json(); + setData(json); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, [dateRange]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + return ( +
+
+
+

Coil Activity — Usage

+

+ View coil material usage for the selected date range +

+
+ +
+ + {error ? ( + + + Failed to load usage data: {error} + + + ) : loading || data === null ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/app/(portal)/dashboard/page.tsx b/src/app/(portal)/dashboard/page.tsx index a03acb7..2bb7037 100644 --- a/src/app/(portal)/dashboard/page.tsx +++ b/src/app/(portal)/dashboard/page.tsx @@ -1,6 +1,6 @@ -import { Suspense } from 'react'; +'use client'; -export const dynamic = 'force-dynamic'; +import { useEffect, useState } from 'react'; import { Package, ShoppingCart, @@ -16,45 +16,59 @@ import { SummaryCard } from '@/components/dashboard/summary-card'; import { QuickNavCard } from '@/components/dashboard/quick-nav-card'; import { RecentOrdersTable } from '@/components/dashboard/recent-orders-table'; import { RecentShipmentsTable } from '@/components/dashboard/recent-shipments-table'; -import { - getRecentOrders, - getRecentShipments, - getInventorySummary, +import type { + DashboardOrder, + DashboardShipment, + InventorySummary, } from '@/services/dashboard'; -import { getQuestSession, getActiveCompany } from '@/lib/permissions'; -import { redirect } from 'next/navigation'; -import { db } from '@/lib/db'; -async function DashboardData() { - const session = await getQuestSession(); - const activeCompany = await getActiveCompany(); +type DashboardData = { + orders: DashboardOrder[]; + shipments: DashboardShipment[]; + inventorySummary: InventorySummary; + unreadNotifications: number; +}; - if (!session || !activeCompany) { - redirect('/select-company'); - } +function DashboardSkeleton() { + return ( +
+
+ {[...Array(4)].map((_, i) => ( + + +
+ + +
+ + + ))} +
+
+ {[...Array(2)].map((_, i) => ( + + +
+ + +
+ {[...Array(5)].map((_, j) => ( +
+ ))} +
+ + + ))} +
+
+ ); +} - // Fetch dashboard data in parallel - const [orders, shipments, inventorySummary, unreadNotifications] = - await Promise.all([ - getRecentOrders(activeCompany.epicor_cust_id).catch(() => []), - getRecentShipments(activeCompany.epicor_cust_id).catch(() => []), - getInventorySummary(activeCompany.epicor_cust_id).catch(() => ({ - wip_count: 0, - finished_goods_count: 0, - unprocessed_count: 0, - total_weight: 0, - })), - db.quest_notification - .count({ - where: { - is_alert: true, - created_at: { - gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), // Last 7 days - }, - }, - }) - .catch(() => 0), - ]); +function DashboardContent({ data }: { data: DashboardData }) { + const { orders, shipments, inventorySummary, unreadNotifications } = data; return ( <> @@ -176,51 +190,34 @@ async function DashboardData() { ); } -function DashboardSkeleton() { - return ( -
-
- {[...Array(4)].map((_, i) => ( - - -
- - -
- - - ))} -
-
- {[...Array(2)].map((_, i) => ( - - -
- - -
- {[...Array(5)].map((_, j) => ( -
- ))} -
- - - ))} -
-
- ); -} - export default function DashboardPage() { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + fetch('/api/dashboard') + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }) + .then((json) => setData(json)) + .catch((err) => setError(err.message)); + }, []); + return (

Dashboard

- }> - - + {error ? ( + + + Failed to load dashboard: {error} + + + ) : data === null ? ( + + ) : ( + + )}
); } diff --git a/src/app/(portal)/orders/[orderNum]/page.tsx b/src/app/(portal)/orders/[orderNum]/page.tsx new file mode 100644 index 0000000..670c974 --- /dev/null +++ b/src/app/(portal)/orders/[orderNum]/page.tsx @@ -0,0 +1,146 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useParams } from 'next/navigation'; +import Link from 'next/link'; +import { ArrowLeft, Download, Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { OrderAcknowledgementDetail } from '@/components/orders/order-acknowledgement-detail'; +import type { OrderAcknowledgementData } from '@/types/orders'; + +function DetailSkeleton() { + return ( +
+ {/* Header skeleton */} +
+
+
+
+ {/* Address cards skeleton */} +
+ {[...Array(2)].map((_, i) => ( + + +
+
+
+
+
+
+ + + ))} +
+ {/* Order info skeleton */} + + +
+ {[...Array(9)].map((_, i) => ( +
+
+
+
+ ))} +
+ + + {/* Table skeleton */} + + +
+ {[...Array(5)].map((_, i) => ( +
+ ))} +
+ + +
+ ); +} + +export default function OrderDetailPage() { + const params = useParams(); + const orderNum = params.orderNum as string; + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [pdfLoading, setPdfLoading] = useState(false); + + useEffect(() => { + fetch(`/api/orders/${orderNum}`) + .then((res) => { + if (!res.ok) { + if (res.status === 404) throw new Error('Order not found'); + throw new Error(`HTTP ${res.status}`); + } + return res.json(); + }) + .then((json) => setData(json.data)) + .catch((err) => setError(err.message)); + }, [orderNum]); + + const handleDownloadPdf = async () => { + setPdfLoading(true); + try { + const res = await fetch(`/api/orders/${orderNum}/pdf`); + if (!res.ok) throw new Error(`PDF generation failed: HTTP ${res.status}`); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `order-ack-${orderNum}.pdf`; + a.click(); + URL.revokeObjectURL(url); + } catch (err) { + console.error('PDF download error:', err); + alert('Failed to download PDF. Please try again.'); + } finally { + setPdfLoading(false); + } + }; + + return ( +
+ {/* Back link */} + + + Back to Orders + + + {error ? ( + + + Failed to load order: {error} + + + ) : data === null ? ( + + ) : ( + <> + {/* Page header */} +
+

+ Order #{data.header.order_num} +

+ +
+ + + + )} +
+ ); +} diff --git a/src/app/(portal)/shipments/[bol]/page.tsx b/src/app/(portal)/shipments/[bol]/page.tsx new file mode 100644 index 0000000..90a7857 --- /dev/null +++ b/src/app/(portal)/shipments/[bol]/page.tsx @@ -0,0 +1,128 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useParams } from 'next/navigation'; +import Link from 'next/link'; +import { ArrowLeft, Download, Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { BOLDetailView } from '@/components/shipments/bol-detail'; +import type { BOLData } from '@/types/shipments'; + +function DetailSkeleton() { + return ( +
+
+
+
+
+
+ {[...Array(2)].map((_, i) => ( + + +
+
+
+
+
+
+ + + ))} +
+ + +
+ {[...Array(5)].map((_, i) => ( +
+ ))} +
+ + +
+ ); +} + +export default function BOLDetailPage() { + const params = useParams(); + const bol = params.bol as string; + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [pdfLoading, setPdfLoading] = useState(false); + + useEffect(() => { + fetch(`/api/shipments/${bol}`) + .then((res) => { + if (!res.ok) { + if (res.status === 404) throw new Error('BOL not found'); + throw new Error(`HTTP ${res.status}`); + } + return res.json(); + }) + .then((json) => setData(json.data)) + .catch((err) => setError(err.message)); + }, [bol]); + + const handleDownloadPdf = async () => { + setPdfLoading(true); + try { + const res = await fetch(`/api/shipments/${bol}/pdf`); + if (!res.ok) throw new Error(`PDF generation failed: HTTP ${res.status}`); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `bol-${bol}.pdf`; + a.click(); + URL.revokeObjectURL(url); + } catch (err) { + console.error('PDF download error:', err); + alert('Failed to download PDF. Please try again.'); + } finally { + setPdfLoading(false); + } + }; + + return ( +
+ + + Back to Shipments + + + {error ? ( + + + Failed to load BOL: {error} + + + ) : data === null ? ( + + ) : ( + <> +
+

+ BOL #{data.header.bol_num} +

+ +
+ + + + )} +
+ ); +} diff --git a/src/app/(portal)/shipments/page.tsx b/src/app/(portal)/shipments/page.tsx index 02af895..2e0a62e 100644 --- a/src/app/(portal)/shipments/page.tsx +++ b/src/app/(portal)/shipments/page.tsx @@ -1,30 +1,10 @@ -import { Suspense } from 'react'; -import { redirect } from 'next/navigation'; -import { getTop100Shipments } from '@/services/shipments'; -import { getQuestSession, getActiveCompany } from '@/lib/permissions'; +'use client'; + +import { useEffect, useState } from 'react'; +import type { ShipmentRow } from '@/services/shipments'; import { ShipmentsTable } from '@/components/shipments/shipments-table'; import { Card, CardContent } from '@/components/ui/card'; -export const dynamic = 'force-dynamic'; - -async function ShipmentsData() { - const session = await getQuestSession(); - const activeCompany = await getActiveCompany(); - - if (!session || !activeCompany) { - redirect('/select-company'); - } - - const shipments = await getTop100Shipments( - activeCompany.epicor_cust_id - ).catch((err) => { - console.error('Failed to fetch shipments:', err); - return []; - }); - - return ; -} - function LoadingSkeleton() { return ( @@ -43,6 +23,19 @@ function LoadingSkeleton() { } export default function ShipmentsPage() { + const [shipments, setShipments] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + fetch('/api/shipments') + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }) + .then((data) => setShipments(data)) + .catch((err) => setError(err.message)); + }, []); + return (

Shipments

@@ -50,9 +43,17 @@ export default function ShipmentsPage() { View your most recent shipments and BOL details

- }> - - + {error ? ( + + + Failed to load shipments: {error} + + + ) : shipments === null ? ( + + ) : ( + + )}
); } diff --git a/src/app/api/coil-activity/receipts/route.ts b/src/app/api/coil-activity/receipts/route.ts new file mode 100644 index 0000000..4a3e49c --- /dev/null +++ b/src/app/api/coil-activity/receipts/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getCoilReceipts } from '@/services/coil-activity'; +import { getQuestSession, getActiveCompany } from '@/lib/permissions'; + +export const dynamic = 'force-dynamic'; + +export async function GET(request: NextRequest) { + const session = await getQuestSession(); + const activeCompany = await getActiveCompany(); + + if (!session || !activeCompany) { + return NextResponse.json({ error: 'No active company' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const startDate = searchParams.get('startDate'); + const endDate = searchParams.get('endDate'); + + if (!startDate || !endDate) { + return NextResponse.json( + { error: 'startDate and endDate query parameters are required' }, + { status: 400 } + ); + } + + const start = new Date(startDate); + const end = new Date(endDate); + if (isNaN(start.getTime()) || isNaN(end.getTime())) { + return NextResponse.json({ error: 'Invalid date format' }, { status: 400 }); + } + if (end < start) { + return NextResponse.json( + { error: 'endDate must be after startDate' }, + { status: 400 } + ); + } + const diffDays = Math.ceil( + (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24) + ); + if (diffDays > 31) { + return NextResponse.json( + { error: 'Date range cannot exceed 31 days' }, + { status: 400 } + ); + } + + try { + const data = await getCoilReceipts( + activeCompany.epicor_cust_id, + startDate, + endDate + ); + return NextResponse.json(data); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + let details = ''; + if (err && typeof err === 'object' && 'originalError' in err) { + const originalError = (err as { originalError: unknown }).originalError; + details = + originalError instanceof Error + ? originalError.message + : String(originalError); + } + console.error('Coil receipts error:', err); + return NextResponse.json({ error: message, details }, { status: 500 }); + } +} diff --git a/src/app/api/coil-activity/usage/route.ts b/src/app/api/coil-activity/usage/route.ts new file mode 100644 index 0000000..d98e1e0 --- /dev/null +++ b/src/app/api/coil-activity/usage/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getCoilUsage } from '@/services/coil-activity'; +import { getQuestSession, getActiveCompany } from '@/lib/permissions'; + +export const dynamic = 'force-dynamic'; + +export async function GET(request: NextRequest) { + const session = await getQuestSession(); + const activeCompany = await getActiveCompany(); + + if (!session || !activeCompany) { + return NextResponse.json({ error: 'No active company' }, { status: 401 }); + } + + // Parse date range from query params + const { searchParams } = new URL(request.url); + const startDate = searchParams.get('startDate'); + const endDate = searchParams.get('endDate'); + + if (!startDate || !endDate) { + return NextResponse.json( + { error: 'startDate and endDate query parameters are required' }, + { status: 400 } + ); + } + + // Validate date range (max 31 days) + const start = new Date(startDate); + const end = new Date(endDate); + if (isNaN(start.getTime()) || isNaN(end.getTime())) { + return NextResponse.json({ error: 'Invalid date format' }, { status: 400 }); + } + if (end < start) { + return NextResponse.json( + { error: 'endDate must be after startDate' }, + { status: 400 } + ); + } + const diffDays = Math.ceil( + (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24) + ); + if (diffDays > 31) { + return NextResponse.json( + { error: 'Date range cannot exceed 31 days' }, + { status: 400 } + ); + } + + try { + const data = await getCoilUsage( + activeCompany.epicor_cust_id, + startDate, + endDate + ); + return NextResponse.json(data); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + let details = ''; + if (err && typeof err === 'object' && 'originalError' in err) { + const originalError = (err as { originalError: unknown }).originalError; + details = + originalError instanceof Error + ? originalError.message + : String(originalError); + } + console.error('Coil usage error:', err); + return NextResponse.json({ error: message, details }, { status: 500 }); + } +} diff --git a/src/app/api/dashboard/route.ts b/src/app/api/dashboard/route.ts new file mode 100644 index 0000000..60da06b --- /dev/null +++ b/src/app/api/dashboard/route.ts @@ -0,0 +1,53 @@ +import { NextResponse } from 'next/server'; +import { + getRecentOrders, + getRecentShipments, + getInventorySummary, +} from '@/services/dashboard'; +import { getQuestSession, getActiveCompany } from '@/lib/permissions'; +import { db } from '@/lib/db'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + const session = await getQuestSession(); + const activeCompany = await getActiveCompany(); + + if (!session || !activeCompany) { + return NextResponse.json({ error: 'No active company' }, { status: 401 }); + } + + try { + const [orders, shipments, inventorySummary, unreadNotifications] = + await Promise.all([ + getRecentOrders(activeCompany.epicor_cust_id).catch(() => []), + getRecentShipments(activeCompany.epicor_cust_id).catch(() => []), + getInventorySummary(activeCompany.epicor_cust_id).catch(() => ({ + wip_count: 0, + finished_goods_count: 0, + unprocessed_count: 0, + total_weight: 0, + })), + db.quest_notification + .count({ + where: { + is_alert: true, + created_at: { + gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), + }, + }, + }) + .catch(() => 0), + ]); + + return NextResponse.json({ + orders, + shipments, + inventorySummary, + unreadNotifications, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/orders/[orderNum]/pdf/route.ts b/src/app/api/orders/[orderNum]/pdf/route.ts new file mode 100644 index 0000000..2d6aa0d --- /dev/null +++ b/src/app/api/orders/[orderNum]/pdf/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getOrderAcknowledgement } from '@/services/orders'; +import { generateOrderAckPdf } from '@/lib/pdf'; +import { getQuestSession, getActiveCompany } from '@/lib/permissions'; + +export const dynamic = 'force-dynamic'; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ orderNum: string }> } +) { + const session = await getQuestSession(); + const activeCompany = await getActiveCompany(); + + if (!session || !activeCompany) { + return NextResponse.json({ error: 'No active company' }, { status: 401 }); + } + + const { orderNum } = await params; + const orderNumInt = parseInt(orderNum, 10); + + if (isNaN(orderNumInt) || orderNumInt <= 0) { + return NextResponse.json( + { error: 'Invalid order number' }, + { status: 400 } + ); + } + + try { + const data = await getOrderAcknowledgement( + orderNumInt, + activeCompany.epicor_cust_id + ); + + if (!data) { + return NextResponse.json( + { error: 'Order not found' }, + { status: 404 } + ); + } + + const pdfBuffer = await generateOrderAckPdf(data); + + return new NextResponse(new Uint8Array(pdfBuffer), { + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="order-ack-${orderNum}.pdf"`, + 'Content-Length': String(pdfBuffer.length), + }, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`Error generating PDF for order ${orderNum}:`, message); + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/orders/[orderNum]/route.ts b/src/app/api/orders/[orderNum]/route.ts new file mode 100644 index 0000000..6727a22 --- /dev/null +++ b/src/app/api/orders/[orderNum]/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getOrderAcknowledgement } from '@/services/orders'; +import { getQuestSession, getActiveCompany } from '@/lib/permissions'; + +export const dynamic = 'force-dynamic'; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ orderNum: string }> } +) { + const session = await getQuestSession(); + const activeCompany = await getActiveCompany(); + + if (!session || !activeCompany) { + return NextResponse.json({ error: 'No active company' }, { status: 401 }); + } + + const { orderNum } = await params; + const orderNumInt = parseInt(orderNum, 10); + + if (isNaN(orderNumInt) || orderNumInt <= 0) { + return NextResponse.json( + { error: 'Invalid order number' }, + { status: 400 } + ); + } + + try { + const data = await getOrderAcknowledgement( + orderNumInt, + activeCompany.epicor_cust_id + ); + + if (!data) { + return NextResponse.json( + { error: 'Order not found' }, + { status: 404 } + ); + } + + return NextResponse.json({ data }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`Error fetching order ${orderNum}:`, message); + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/shipments/[bol]/pdf/route.ts b/src/app/api/shipments/[bol]/pdf/route.ts new file mode 100644 index 0000000..3a6c3ea --- /dev/null +++ b/src/app/api/shipments/[bol]/pdf/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from 'next/server'; +import { getBOLDetail } from '@/services/shipments'; +import { getQuestSession, getActiveCompany } from '@/lib/permissions'; +import { generateBOLPdf } from '@/lib/pdf'; + +export const dynamic = 'force-dynamic'; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ bol: string }> } +) { + const session = await getQuestSession(); + const activeCompany = await getActiveCompany(); + + if (!session || !activeCompany) { + return NextResponse.json({ error: 'No active company' }, { status: 401 }); + } + + const { bol } = await params; + const bolNum = parseInt(bol, 10); + if (isNaN(bolNum)) { + return NextResponse.json({ error: 'Invalid BOL number' }, { status: 400 }); + } + + try { + const data = await getBOLDetail(bolNum, activeCompany.epicor_cust_id); + + if (!data) { + return NextResponse.json({ error: 'BOL not found' }, { status: 404 }); + } + + const pdfBuffer = await generateBOLPdf(data); + + return new NextResponse(new Uint8Array(pdfBuffer), { + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename=bol-${bolNum}.pdf`, + }, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/shipments/[bol]/route.ts b/src/app/api/shipments/[bol]/route.ts new file mode 100644 index 0000000..03f436a --- /dev/null +++ b/src/app/api/shipments/[bol]/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from 'next/server'; +import { getBOLDetail } from '@/services/shipments'; +import { getQuestSession, getActiveCompany } from '@/lib/permissions'; + +export const dynamic = 'force-dynamic'; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ bol: string }> } +) { + const session = await getQuestSession(); + const activeCompany = await getActiveCompany(); + + if (!session || !activeCompany) { + return NextResponse.json({ error: 'No active company' }, { status: 401 }); + } + + const { bol } = await params; + const bolNum = parseInt(bol, 10); + if (isNaN(bolNum)) { + return NextResponse.json({ error: 'Invalid BOL number' }, { status: 400 }); + } + + try { + const data = await getBOLDetail(bolNum, activeCompany.epicor_cust_id); + + if (!data) { + return NextResponse.json({ error: 'BOL not found' }, { status: 404 }); + } + + return NextResponse.json({ data }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + let details = ''; + + // Extract original error from EpicorQueryError + if (err && typeof err === 'object' && 'originalError' in err) { + const originalError = err.originalError; + details = originalError instanceof Error ? originalError.message : String(originalError); + } + + console.error('BOL detail error:', err); + return NextResponse.json( + { error: message, details }, + { status: 500 } + ); + } +} diff --git a/src/app/api/shipments/route.ts b/src/app/api/shipments/route.ts new file mode 100644 index 0000000..435176b --- /dev/null +++ b/src/app/api/shipments/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from 'next/server'; +import { getTop100Shipments } from '@/services/shipments'; +import { getQuestSession, getActiveCompany } from '@/lib/permissions'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + const session = await getQuestSession(); + const activeCompany = await getActiveCompany(); + + if (!session || !activeCompany) { + return NextResponse.json({ error: 'No active company' }, { status: 401 }); + } + + try { + const shipments = await getTop100Shipments(activeCompany.epicor_cust_id); + return NextResponse.json(shipments); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/components/coil-activity/receipts-table.tsx b/src/components/coil-activity/receipts-table.tsx new file mode 100644 index 0000000..77b4da1 --- /dev/null +++ b/src/components/coil-activity/receipts-table.tsx @@ -0,0 +1,248 @@ +'use client'; + +import { useState } from 'react'; +import type { CoilReceiptRow } from '@/types/coil-activity'; +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, + TableRow, +} from '@/components/ui/table'; +import { Download, Search } from 'lucide-react'; +import { + SortableTableHead, + useSortableTable, +} from '@/components/ui/sortable-table-head'; +import { formatDate } from '@/lib/utils'; + +type Props = { + data: CoilReceiptRow[]; +}; + +export function ReceiptsTable({ data }: Props) { + const [searchTerm, setSearchTerm] = useState(''); + + const filteredData = data.filter((row) => { + const s = searchTerm.toLowerCase(); + return ( + row.vorteq_part_num.toLowerCase().includes(s) || + (row.customer_part_num ?? '').toLowerCase().includes(s) || + row.part_desc.toLowerCase().includes(s) || + (row.manufacturer_lot_num ?? '').toLowerCase().includes(s) || + (row.alloy ?? '').toLowerCase().includes(s) || + row.plant_name.toLowerCase().includes(s) || + row.packing_slip.toLowerCase().includes(s) || + (row.supplier_name ?? '').toLowerCase().includes(s) || + (row.mill_order_num ?? '').toLowerCase().includes(s) + ); + }); + + const { sortKey, sortDirection, handleSort, sortedData } = + useSortableTable(filteredData); + + const handleExportCSV = () => { + const headers = [ + 'Date Rec', + 'Vorteq Part#', + 'Cust Part#', + 'Part Description', + 'Lot#', + 'Mfg Lot#', + 'Plant Name', + 'Packing Slip', + 'Supplier Name', + 'Mill Order #', + ]; + + const rows = sortedData.map((row) => [ + row.date_received + ? new Date(row.date_received).toLocaleDateString() + : '', + row.vorteq_part_num, + row.customer_part_num ?? '', + row.part_desc, + row.manufacturer_lot_num ?? '', + row.alloy ?? '', + row.plant_name, + row.packing_slip, + row.supplier_name ?? '', + row.mill_order_num ?? '', + ]); + + 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 = `coil-receipts-${new Date().toISOString().split('T')[0]}.csv`; + a.click(); + window.URL.revokeObjectURL(url); + }; + + return ( + + + Receipts Data + Showing {sortedData.length} results + + +
+
+ + setSearchTerm(e.target.value)} + className="pl-8" + /> +
+ +
+ +
+ + + + + Date Rec + + + Vorteq Part# + + + Cust Part# + + + Part Description + + + Lot# + + + Mfg Lot# + + + Plant Name + + + Packing Slip + + + Supplier Name + + + Mill Order # + + + + + {sortedData.length === 0 ? ( + + + No receipts found + + + ) : ( + sortedData.map((row, i) => ( + + + {row.date_received + ? formatDate(new Date(row.date_received)) + : '-'} + + {row.vorteq_part_num || '-'} + {row.customer_part_num || '-'} + + {row.part_desc || '-'} + + {row.manufacturer_lot_num || '-'} + {row.alloy || '-'} + {row.plant_name || '-'} + {row.packing_slip || '-'} + {row.supplier_name || '-'} + {row.mill_order_num || '-'} + + )) + )} + +
+
+ +
+ Showing {sortedData.length} of {data.length} receipts +
+
+
+ ); +} diff --git a/src/components/coil-activity/usage-table.tsx b/src/components/coil-activity/usage-table.tsx new file mode 100644 index 0000000..017776a --- /dev/null +++ b/src/components/coil-activity/usage-table.tsx @@ -0,0 +1,262 @@ +'use client'; + +import { useState } from 'react'; +import type { CoilUsageRow } from '@/types/coil-activity'; +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, + TableRow, +} from '@/components/ui/table'; +import { Download, Search } from 'lucide-react'; +import { + SortableTableHead, + useSortableTable, +} from '@/components/ui/sortable-table-head'; +import { formatDate } from '@/lib/utils'; + +type Props = { + data: CoilUsageRow[]; +}; + +export function UsageTable({ data }: Props) { + const [searchTerm, setSearchTerm] = useState(''); + + const filteredData = data.filter((row) => { + const s = searchTerm.toLowerCase(); + return ( + (row.vorteq_part_num || '').toLowerCase().includes(s) || + (row.customer_part_num || '').toLowerCase().includes(s) || + (row.part_desc || '').toLowerCase().includes(s) || + (row.lot_num || '').toLowerCase().includes(s) || + (row.mfg_lot || '').toLowerCase().includes(s) || + (row.plant_name || '').toLowerCase().includes(s) || + (row.job_num || '').toLowerCase().includes(s) || + (row.customer_po || '').toLowerCase().includes(s) + ); + }); + + const { sortKey, sortDirection, handleSort, sortedData } = + useSortableTable(filteredData); + + const handleExportCSV = () => { + const headers = [ + 'Date Used', + 'Vorteq Part#', + 'Cust Part#', + 'Part Description', + 'Lot#', + 'Mfg Lot#', + 'Weight', + 'Plant Name', + 'Job#', + 'Cust PO#', + 'Qty LB', + ]; + + const rows = sortedData.map((row) => [ + row.date_used ? new Date(row.date_used).toLocaleDateString() : '', + row.vorteq_part_num || '', + row.customer_part_num || '', + row.part_desc || '', + row.lot_num || '', + row.mfg_lot || '', + row.weight ?? '', + row.plant_name || '', + row.job_num || '', + row.customer_po || '', + row.on_hand_qty ?? '', + ]); + + 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 = `usage-data-${new Date().toISOString().split('T')[0]}.csv`; + a.click(); + window.URL.revokeObjectURL(url); + }; + + return ( + + + Usage Data + Showing {sortedData.length} results + + +
+
+ + setSearchTerm(e.target.value)} + className="pl-8" + /> +
+ +
+ +
+ + + + + Date Used + + + Vorteq Part# + + + Cust Part# + + + Part Description + + + Lot# + + + Mfg Lot# + + + Weight + + + Plant Name + + + Job# + + + Cust PO# + + + Qty LB + + + + + {sortedData.length === 0 ? ( + + + No usage data found + + + ) : ( + sortedData.map((row, i) => ( + + + {row.date_used ? formatDate(new Date(row.date_used)) : '-'} + + {row.vorteq_part_num || '-'} + {row.customer_part_num || '-'} + {row.part_desc || '-'} + {row.lot_num || '-'} + {row.mfg_lot || '-'} + + {row.weight != null + ? row.weight.toLocaleString() + : '-'} + + {row.plant_name || '-'} + {row.job_num || '-'} + {row.customer_po || '-'} + + {row.on_hand_qty != null + ? row.on_hand_qty.toLocaleString() + : '-'} + + + )) + )} + +
+
+ +
+ Showing {sortedData.length} of {data.length} records +
+
+
+ ); +} diff --git a/src/components/dashboard/recent-shipments-table.tsx b/src/components/dashboard/recent-shipments-table.tsx index 89aca52..6275ce8 100644 --- a/src/components/dashboard/recent-shipments-table.tsx +++ b/src/components/dashboard/recent-shipments-table.tsx @@ -30,7 +30,7 @@ export function RecentShipmentsTable({ shipments }: RecentShipmentsTableProps) { BOL # Ship To Ship Date - Carrier + Plant Weight @@ -49,7 +49,7 @@ export function RecentShipmentsTable({ shipments }: RecentShipmentsTableProps) { {shipment.ship_to} {formatDate(shipment.ship_date)} - {shipment.carrier} + {shipment.plant} {shipment.weight.toLocaleString()} lbs diff --git a/src/components/layout/portal-header.tsx b/src/components/layout/portal-header.tsx index c8812e9..d06e079 100644 --- a/src/components/layout/portal-header.tsx +++ b/src/components/layout/portal-header.tsx @@ -79,7 +79,8 @@ export function PortalHeader({ body: JSON.stringify({ companyId }), }); if (response.ok) { - window.location.href = '/dashboard'; + // Refresh current page so data reloads with new company + window.location.reload(); } } catch (error) { console.error('Error switching company:', error); diff --git a/src/components/orders/order-acknowledgement-detail.tsx b/src/components/orders/order-acknowledgement-detail.tsx new file mode 100644 index 0000000..65fd54e --- /dev/null +++ b/src/components/orders/order-acknowledgement-detail.tsx @@ -0,0 +1,356 @@ +'use client'; + +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import type { OrderAcknowledgementData, OrderAckLine } from '@/types/orders'; + +type Props = { + data: OrderAcknowledgementData; +}; + +function formatDate(isoDate: string): string { + if (!isoDate) return '-'; + const d = new Date(isoDate); + // Use UTC to avoid timezone shift (Epicor dates are midnight UTC) + const mm = String(d.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(d.getUTCDate()).padStart(2, '0'); + const yyyy = d.getUTCFullYear(); + return `${mm}/${dd}/${yyyy}`; +} + +function formatCurrency(amount: number): string { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(amount); +} + +function formatQty(qty: number, um: string): string { + return `${qty.toLocaleString()} ${um}`; +} + +function formatUnitPrice(price: number, um: string): string { + // Format like ".58000 / 1" matching legacy PDF style + const formatted = price.toFixed(5); + return `${formatted} / 1`; +} + +function formatAddress( + name: string, + addr1: string, + addr2: string, + city: string, + state: string, + zip: string +): string[] { + const lines = [name]; + if (addr1) lines.push(addr1); + if (addr2) lines.push(addr2); + const cityLine = [city, state].filter(Boolean).join(', '); + if (cityLine || zip) lines.push(`${cityLine} ${zip}`.trim()); + return lines; +} + +function LineItemRow({ line }: { line: OrderAckLine }) { + const paintCodes = [line.top_finish, line.bottom_finish] + .filter(Boolean) + .join(' / '); + + return ( + <> + {/* Main line row */} + + + {line.order_line} + + +
+
{line.part_num}
+
+ {line.part_description} +
+
+
+ + {line.revision || '-'} + + + {formatQty(line.order_qty, line.unit_of_measure)} + + + {formatUnitPrice(line.unit_price, line.unit_of_measure)} + + + {formatCurrency(line.extended_price)} + +
+ + {/* Customer part row */} + {line.customer_part && ( + + + +
+ Our Part: + + {line.customer_part} + {line.customer_part_desc + ? ` / ${line.customer_part_desc}` + : ''} + +
+ {paintCodes && ( +
{paintCodes}
+ )} +
+
+ )} + + {/* Releases sub-table */} + {line.releases.length > 0 && ( + + + +
+
+ Rel + Date + Quantity + Job Number +
+ {line.releases.map((rel) => ( +
+ {rel.release_num} + {formatDate(rel.need_by_date)} + + {rel.quantity.toLocaleString(undefined, { + minimumFractionDigits: 2, + })} + + {rel.job_num || '-'} +
+ ))} +
+
+
+ )} + + {/* Comment row */} + {line.comment && ( + + + +
+ {line.comment} +
+
+
+ )} + + ); +} + +export function OrderAcknowledgementDetail({ data }: Props) { + const { header, lines } = data; + + const soldToLines = formatAddress( + header.customer_name, + header.customer_address1, + header.customer_address2, + header.customer_city, + header.customer_state, + header.customer_zip + ); + + const shipToLines = formatAddress( + header.ship_to_name, + header.ship_to_address1, + header.ship_to_address2, + header.ship_to_city, + header.ship_to_state, + header.ship_to_zip + ); + + const lineTotalPrice = lines.reduce((sum, l) => sum + l.extended_price, 0); + const lineMiscCharges = lines.reduce((sum, l) => sum + l.line_misc_charges, 0); + + return ( +
+ {/* Order type + number badge */} +
+ {header.order_type} + + Sales Order: {header.order_num} + +
+ + {/* Sold To / Ship To */} +
+ + +
Sold To:
+ {soldToLines.map((line, i) => ( +
+ {line} +
+ ))} +
+
+ + +
Ship To:
+ {shipToLines.map((line, i) => ( +
+ {line} +
+ ))} +
+
+
+ + {/* Order Info Bar */} + + +
+
+
+ Order Date +
+
{formatDate(header.order_date)}
+
+
+
+ PO Number +
+
{header.po_num}
+
+
+
+ FOB +
+
{header.fob || '-'}
+
+
+
+ Need By +
+
{formatDate(header.need_by_date)}
+
+
+
+ Sales Person +
+
{header.sales_person}
+
+
+
+ Currency +
+
{header.currency}
+
+
+
+ Payment Terms +
+
{header.payment_terms || '-'}
+
+
+
+ Ship Via +
+
{header.ship_via || '-'}
+
+
+
+
+ + {/* Order comment (if present) */} + {header.order_comment && ( + + +
+ Order Notes +
+
{header.order_comment}
+
+
+ )} + + {/* Line Items Table */} + + +
+ + + + Line + + Part Number/Description + + + Rev + + + Order Qty + + + Unit Price + + + Ext. Price + + + + + {lines.map((line) => ( + + ))} + +
+
+
+
+ + {/* Totals */} +
+
+
+ Line Total: + {formatCurrency(lineTotalPrice)} +
+
+ + Line Miscellaneous Charges: + + {formatCurrency(lineMiscCharges)} +
+
+ + Order Miscellaneous Charges: + + {formatCurrency(data.order_misc_charges)} +
+
+ Order Total: + + {data.order_total_qty.toLocaleString()} + {lines[0]?.unit_of_measure || 'LB'}{' '} + {formatCurrency(data.order_total_price)} + +
+
+
+
+ ); +} diff --git a/src/components/orders/orders-table.tsx b/src/components/orders/orders-table.tsx index a49112e..021a4a8 100644 --- a/src/components/orders/orders-table.tsx +++ b/src/components/orders/orders-table.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState } from 'react'; +import Link from 'next/link'; import type { OrderRow } from '@/services/orders'; import { Card, @@ -17,7 +18,7 @@ import { TableCell, TableRow, } from '@/components/ui/table'; -import { Download, Search } from 'lucide-react'; +import { Download, FileText, Search } from 'lucide-react'; import { SortableTableHead, useSortableTable, @@ -183,13 +184,16 @@ export function OrdersTable({ data }: Props) { > Job # + + PDF + {sortedData.length === 0 ? ( No orders found @@ -199,7 +203,12 @@ export function OrdersTable({ data }: Props) { sortedData.map((row, i) => ( - {row.order_num} + + {row.order_num} + {row.customer_po || '-'} @@ -219,6 +228,17 @@ export function OrdersTable({ data }: Props) { : '-'} {row.job_num || '-'} + + + + + )) )} diff --git a/src/components/shipments/bol-detail.tsx b/src/components/shipments/bol-detail.tsx new file mode 100644 index 0000000..24cbfe3 --- /dev/null +++ b/src/components/shipments/bol-detail.tsx @@ -0,0 +1,200 @@ +'use client'; + +import type { BOLData } from '@/types/shipments'; +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; + +type Props = { + data: BOLData; +}; + +function formatDate(dateStr: string): string { + if (!dateStr) return '-'; + const d = new Date(dateStr); + const mm = String(d.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(d.getUTCDate()).padStart(2, '0'); + const yyyy = d.getUTCFullYear(); + return `${mm}/${dd}/${yyyy}`; +} + +function formatAddress( + addr1: string, + addr2: string, + city: string, + state: string, + zip: string +): string[] { + const lines: string[] = []; + if (addr1) lines.push(addr1); + if (addr2) lines.push(addr2); + const cityStateZip = [city, state].filter(Boolean).join(', '); + if (cityStateZip || zip) lines.push(`${cityStateZip} ${zip}`.trim()); + return lines; +} + +export function BOLDetailView({ data }: Props) { + const { header, lines } = data; + + const shipFromLines = formatAddress( + header.plant_address1, + header.plant_address2, + header.plant_city, + header.plant_state, + header.plant_zip + ); + + const shipToLines = formatAddress( + header.ship_to_address1, + header.ship_to_address2, + header.ship_to_city, + header.ship_to_state, + header.ship_to_zip + ); + + // Collect unique PO numbers from lines + const poNumbers = [...new Set(lines.map((l) => l.po_num).filter(Boolean))]; + + return ( +
+ {/* Order type badges */} +
+ Pack #{header.pack_num} + + {header.customer_name} + +
+ + {/* Ship From / Ship To */} +
+ + +

+ Ship From: +

+

{header.plant_name}

+ {shipFromLines.map((line, i) => ( +

+ {line} +

+ ))} +
+
+ + +

+ Ship To: +

+

{header.ship_to_name}

+ {shipToLines.map((line, i) => ( +

+ {line} +

+ ))} +
+
+
+ + {/* Shipment Info */} + + +
+

Ship Date

+

{formatDate(header.ship_date)}

+
+
+

Ship Via

+

{header.ship_via || '-'}

+
+
+

PO Number(s)

+

{poNumbers.join(', ') || '-'}

+
+
+

Total Weight

+

+ {header.total_weight.toLocaleString()} lbs +

+
+
+
+ + {/* Line Items */} +
+ + + + Line + Part Number/Description + Rev + Lot # + Qty Shipped + UOM + Weight (lbs) + + + + {lines.map((line, i) => ( + + {line.pack_line} + +
+ {line.part_num} + {line.part_description && ( +

+ {line.part_description} +

+ )} + {line.cust_part_num && ( +

+ Cust Part: {line.cust_part_num} +

+ )} + {line.order_num > 0 && ( +

+ Order: {line.order_num} / Line: {line.order_line} + {line.po_num ? ` / PO: ${line.po_num}` : ''} +

+ )} +
+
+ {line.revision || '-'} + {line.lot_num || '-'} + + {line.ship_qty.toLocaleString()} + + {line.uom || '-'} + + {line.net_weight > 0 + ? line.net_weight.toLocaleString() + : '-'} + +
+ ))} +
+
+
+ + {/* Totals */} +
+
+
+ Total Lines: + {header.total_lines} +
+
+ Total Weight: + {header.total_weight.toLocaleString()} lbs +
+
+
+
+ ); +} diff --git a/src/components/shipments/shipments-table.tsx b/src/components/shipments/shipments-table.tsx index 4c9740a..e21ad5b 100644 --- a/src/components/shipments/shipments-table.tsx +++ b/src/components/shipments/shipments-table.tsx @@ -18,7 +18,7 @@ import { TableCell, TableRow, } from '@/components/ui/table'; -import { Download, Search } from 'lucide-react'; +import { Download, FileText, Search } from 'lucide-react'; import { SortableTableHead, useSortableTable, @@ -35,10 +35,9 @@ export function ShipmentsTable({ data }: Props) { const filteredData = data.filter((row) => { const s = searchTerm.toLowerCase(); return ( - row.pack_num.toLowerCase().includes(s) || + row.bol_num.toLowerCase().includes(s) || row.ship_to.toLowerCase().includes(s) || - row.carrier.toLowerCase().includes(s) || - row.tracking_num.toLowerCase().includes(s) + row.plant.toLowerCase().includes(s) ); }); @@ -50,20 +49,18 @@ export function ShipmentsTable({ data }: Props) { 'BOL #', 'Ship Date', 'Ship To', - 'Carrier', - 'Weight', - 'Tracking #', + 'Plant', + 'Weight (lbs)', ]; const rows = sortedData.map((row) => [ - row.pack_num, + row.bol_num, row.ship_date ? new Date(row.ship_date).toLocaleDateString() : '', row.ship_to.replace(/\n/g, ', '), - row.carrier, + row.plant, row.weight, - row.tracking_num, ]); const csvContent = [headers, ...rows] @@ -90,7 +87,7 @@ export function ShipmentsTable({ data }: Props) {
setSearchTerm(e.target.value)} className="pl-8" @@ -107,7 +104,7 @@ export function ShipmentsTable({ data }: Props) { - Carrier + Plant Weight - - Tracking # - + @@ -175,10 +165,10 @@ export function ShipmentsTable({ data }: Props) { > - {row.pack_num} + {row.bol_num} @@ -187,13 +177,23 @@ export function ShipmentsTable({ data }: Props) { {row.ship_to || '-'} - {row.carrier || '-'} + {row.plant || '-'} {row.weight ? `${row.weight.toLocaleString()} lbs` : '-'} - {row.tracking_num || '-'} + + + + + )) )} diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx new file mode 100644 index 0000000..58642a3 --- /dev/null +++ b/src/components/ui/calendar.tsx @@ -0,0 +1,74 @@ +'use client'; + +import * as React from 'react'; +import { DayPicker } from 'react-day-picker'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { buttonVariants } from '@/components/ui/button'; + +export type CalendarProps = React.ComponentProps; + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: CalendarProps) { + return ( + .day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md' + : '[&:has([aria-selected])]:rounded-md' + ), + day_button: cn( + buttonVariants({ variant: 'ghost' }), + 'h-9 w-9 p-0 font-normal aria-selected:opacity-100' + ), + range_start: 'day-range-start', + range_end: 'day-range-end', + selected: + 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground', + today: 'bg-accent text-accent-foreground', + outside: + 'day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground', + disabled: 'text-muted-foreground opacity-50', + range_middle: + 'aria-selected:bg-accent aria-selected:text-accent-foreground', + hidden: 'invisible', + ...classNames, + }} + components={{ + Chevron: ({ orientation }) => { + const Icon = orientation === 'left' ? ChevronLeft : ChevronRight; + return ; + }, + }} + {...props} + /> + ); +} +Calendar.displayName = 'Calendar'; + +export { Calendar }; diff --git a/src/components/ui/date-range-picker.tsx b/src/components/ui/date-range-picker.tsx new file mode 100644 index 0000000..2dc6f49 --- /dev/null +++ b/src/components/ui/date-range-picker.tsx @@ -0,0 +1,85 @@ +'use client'; + +import * as React from 'react'; +import { format, differenceInDays } from 'date-fns'; +import { CalendarIcon } from 'lucide-react'; +import type { DateRange } from 'react-day-picker'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Calendar } from '@/components/ui/calendar'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; + +type DateRangePickerProps = { + from: Date; + to: Date; + onUpdate: (range: { from: Date; to: Date }) => void; + maxDays?: number; + className?: string; +}; + +export function DateRangePicker({ + from, + to, + onUpdate, + maxDays = 31, + className, +}: DateRangePickerProps) { + const [open, setOpen] = React.useState(false); + + const handleSelect = (range: DateRange | undefined) => { + if (!range?.from) return; + + const newFrom = range.from; + const newTo = range.to || range.from; + + // Enforce max days + if (differenceInDays(newTo, newFrom) > maxDays) return; + + onUpdate({ from: newFrom, to: newTo }); + + // Close popover when both dates selected + if (range.from && range.to) { + setOpen(false); + } + }; + + return ( + + + + + + +
+ Max range: {maxDays} days +
+
+
+ ); +} diff --git a/src/lib/epicor.ts b/src/lib/epicor.ts index 7a82691..e3b53df 100644 --- a/src/lib/epicor.ts +++ b/src/lib/epicor.ts @@ -63,7 +63,7 @@ const config: EpicorConfig = { encrypt: false, trustServerCertificate: true, connectTimeout: 30000, // 30 seconds - requestTimeout: 60000, // 60 seconds + requestTimeout: 120000, // 120 seconds — large customers (ACM: 7k+ rows) need time over VPN }, }; @@ -133,7 +133,9 @@ export async function execStoredProc( } const result: IProcedureResult = await request.execute(procedureName); - return result.recordset as T; + // Spread into plain array to detach from mssql recordset prototype. + // Callers must JSON-sanitize individual rows before passing to RSC. + return [...result.recordset] as T; } catch (error) { if (error instanceof Error) { if (error.message.includes('timeout')) { @@ -168,7 +170,7 @@ export async function execQuery( } const result: IResult = await request.query(query); - return result.recordset as T; + return [...result.recordset] as T; } catch (error) { if (error instanceof Error) { if (error.message.includes('timeout')) { diff --git a/src/lib/pdf-templates/bol.ts b/src/lib/pdf-templates/bol.ts new file mode 100644 index 0000000..41007c4 --- /dev/null +++ b/src/lib/pdf-templates/bol.ts @@ -0,0 +1,210 @@ +/** + * Bill of Lading PDF HTML Template + * + * Renders a complete HTML document matching the order-ack layout pattern. + * Uses inline CSS only (no external stylesheets) since Puppeteer + * renders from an in-memory HTML string. + */ + +import type { BOLData, BOLLine } from '@/types/shipments'; + +function fmtDate(iso: string): string { + if (!iso) return ''; + const d = new Date(iso + 'T00:00:00Z'); // Force UTC + const mm = String(d.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(d.getUTCDate()).padStart(2, '0'); + const yyyy = d.getUTCFullYear(); + return `${mm}/${dd}/${yyyy}`; +} + +function fmtAddress( + name: string, + addr1: string, + addr2: string, + city: string, + state: string, + zip: string +): string { + const lines = []; + if (name) lines.push(name); + if (addr1) lines.push(addr1); + if (addr2) lines.push(addr2); + const cityLine = [city, state].filter(Boolean).join(', '); + if (cityLine || zip) lines.push(`${cityLine} ${zip}`.trim()); + return lines.join('
'); +} + +function fmtQty(qty: number): string { + return qty.toLocaleString('en-US', { minimumFractionDigits: 2 }); +} + +function renderLineHtml(line: BOLLine): string { + const customerPartLine = + line.cust_part_num + ? `
+ Customer Part: ${line.cust_part_num} +
` + : ''; + + const orderRefLine = + line.order_num && line.order_line + ? `
+ Order ${line.order_num} / Line ${line.order_line}${line.po_num ? ` / PO ${line.po_num}` : ''} +
` + : ''; + + return ` + + ${line.pack_line} + +
${line.part_num}
+
${line.part_description}
+ ${customerPartLine} + ${orderRefLine} + + ${line.revision || ''} + ${line.lot_num || ''} + ${fmtQty(line.ship_qty)} + ${line.uom} + ${fmtQty(line.net_weight)} + + `; +} + +// Vorteq Q logo as inline SVG matching the brand +const VORTEQ_LOGO_SVG = ` + + + + +`; + +export function renderBOLHtml(data: BOLData): string { + const { header, lines } = data; + + const shipFrom = fmtAddress( + header.plant_name, + header.plant_address1, + header.plant_address2, + header.plant_city, + header.plant_state, + header.plant_zip + ); + + const shipTo = fmtAddress( + header.ship_to_name, + header.ship_to_address1, + header.ship_to_address2, + header.ship_to_city, + header.ship_to_state, + header.ship_to_zip + ); + + const lineRows = lines.map(renderLineHtml).join(''); + + return ` + + + + + + + + + + + + + +
+ ${VORTEQ_LOGO_SVG} +
VORTEQ
+
+
11440 W Addison St
+
Franklin Park, IL 60131
+
+
Bill of Lading
+
Phone: 847-455-7200
+
Fax: 847-455-7608
+
+ + +
+
BOL #: ${header.bol_num}
+
Pack #: ${header.pack_num}
+
Ship Date: ${fmtDate(header.ship_date)}
+
+ + + + + + + +
+
Ship From:
+
${shipFrom}
+
+
Ship To:
+
${shipTo}
+
+ + + + + + + +
+
Ship Via: ${header.ship_via || '-'}
+
+
Customer: ${header.customer_name}
+
+ + + + + + + + + + + + + + + + ${lineRows} + +
LinePart Number/DescriptionRevLot #Qty ShippedUOMWeight
+ + + + + + + + + + + +
Total Lines: + ${header.total_lines} +
Total Weight: + ${fmtQty(header.total_weight)} lbs +
+ +`; +} diff --git a/src/lib/pdf-templates/order-acknowledgement.ts b/src/lib/pdf-templates/order-acknowledgement.ts new file mode 100644 index 0000000..979a143 --- /dev/null +++ b/src/lib/pdf-templates/order-acknowledgement.ts @@ -0,0 +1,278 @@ +/** + * Order Acknowledgement PDF HTML Template + * + * Renders a complete HTML document matching the legacy PDF layout. + * Reference: docs/order-acknowledgement-po-233782.pdf + * + * Uses inline CSS only (no external stylesheets) since Puppeteer + * renders from an in-memory HTML string. + */ + +import type { + OrderAcknowledgementData, + OrderAckLine, + OrderAckRelease, +} from '@/types/orders'; + +function fmtDate(iso: string): string { + if (!iso) return ''; + const d = new Date(iso); + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + const yyyy = d.getFullYear(); + return `${mm}/${dd}/${yyyy}`; +} + +function fmtCurrency(amount: number): string { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(amount); +} + +function fmtQty(qty: number, um: string): string { + return `${qty.toLocaleString('en-US')} ${um}`; +} + +function fmtUnitPrice(price: number): string { + return `.${(price * 100000).toFixed(0).padStart(5, '0')} / 1`; +} + +function fmtAddress( + name: string, + addr1: string, + addr2: string, + city: string, + state: string, + zip: string +): string { + const lines = []; + if (name) lines.push(name); + if (addr1) lines.push(addr1); + if (addr2) lines.push(addr2); + const cityLine = [city, state].filter(Boolean).join(', '); + if (cityLine || zip) lines.push(`${cityLine} ${zip}`.trim()); + return lines.join('
'); +} + +function renderReleasesHtml(releases: OrderAckRelease[]): string { + if (releases.length === 0) return ''; + + const rows = releases + .map( + (r) => ` + + ${r.release_num} + ${fmtDate(r.need_by_date)} + ${r.quantity.toLocaleString('en-US', { minimumFractionDigits: 2 })} + ${r.job_num || ''} + + ` + ) + .join(''); + + return ` + + + + + + + + ${rows} +
RelDateQuantityJob Number
+ `; +} + +function renderLineHtml(line: OrderAckLine): string { + const paintCodes = [line.top_finish, line.bottom_finish] + .filter(Boolean) + .join(' / '); + + const customerPartLine = + line.customer_part + ? `
+ Our Part: ${line.customer_part}${line.customer_part_desc ? ` / ${line.customer_part_desc}` : ''} +
` + : ''; + + const paintLine = paintCodes + ? `
${paintCodes}
` + : ''; + + const commentLine = line.comment + ? `
+ ${line.comment} +
` + : ''; + + return ` + + ${line.order_line} + +
${line.part_num}
+
${line.part_description}
+ ${customerPartLine} + ${paintLine} + ${renderReleasesHtml(line.releases)} + ${commentLine} + + ${line.revision || ''} + ${fmtQty(line.order_qty, line.unit_of_measure)} + ${fmtUnitPrice(line.unit_price)} + ${fmtCurrency(line.extended_price)} + + `; +} + +// Vorteq Q logo as inline SVG matching the brand +const VORTEQ_LOGO_SVG = ` + + + + +`; + +export function renderOrderAckHtml(data: OrderAcknowledgementData): string { + const { header, lines } = data; + + const soldTo = fmtAddress( + header.customer_name, + header.customer_address1, + header.customer_address2, + header.customer_city, + header.customer_state, + header.customer_zip + ); + + const shipTo = fmtAddress( + header.ship_to_name, + header.ship_to_address1, + header.ship_to_address2, + header.ship_to_city, + header.ship_to_state, + header.ship_to_zip + ); + + const lineTotalPrice = lines.reduce((s, l) => s + l.extended_price, 0); + const lineMiscCharges = lines.reduce((s, l) => s + l.line_misc_charges, 0); + const um = lines[0]?.unit_of_measure || 'LB'; + + const lineRows = lines.map(renderLineHtml).join(''); + + return ` + + + + + + + + + + + + + +
+ ${VORTEQ_LOGO_SVG} +
VORTEQ
+
+
11440 W Addison St
+
Franklin Park, IL 60131
+
+
Sales Order Acknowledgment
+
Phone: 847-455-7200
+
Fax: 847-455-7608
+
+ + +
+
${header.order_type}
+
Sales Order: ${header.order_num}
+
+ + + + + + + +
+
Sold To:
+
${soldTo}
+
+
Ship To:
+
${shipTo}
+
+ + + + + + + + +
+
Order Date: ${fmtDate(header.order_date)}
+
Need By: ${fmtDate(header.need_by_date)}
+
Payment Terms: ${header.payment_terms || '-'}
+
+
PO Number: ${header.po_num}
+
Sales Person: ${header.sales_person}
+
Ship Via: ${header.ship_via || '-'}
+
+
FOB: ${header.fob || '-'}
+
+ + +
${header.currency}
+ + + + + + + + + + + + + ${lineRows} + +
LinePart Number/DescriptionRevOrder QtyUnit PriceExt. Price
+ + + + + + + + + + + + + + + + + + + +
Line Total:${fmtCurrency(lineTotalPrice)}
Line Miscellaneous Charges:${fmtCurrency(lineMiscCharges)}
Order Miscellaneous Charges:${fmtCurrency(data.order_misc_charges)}
Order Total: + ${data.order_total_qty.toLocaleString('en-US')}${um}  ${fmtCurrency(data.order_total_price)} +
+ +`; +} diff --git a/src/lib/pdf.ts b/src/lib/pdf.ts new file mode 100644 index 0000000..ddfbf27 --- /dev/null +++ b/src/lib/pdf.ts @@ -0,0 +1,82 @@ +/** + * PDF Generation Service + * + * Provides Puppeteer-based HTML-to-PDF generation. + * Replaces the legacy wkhtmltopdf approach. + * + * Usage: + * import { generatePdfFromHtml } from '@/lib/pdf'; + * const buffer = await generatePdfFromHtml(htmlString); + */ + +import puppeteer from 'puppeteer'; +import type { OrderAcknowledgementData } from '@/types/orders'; +import { renderOrderAckHtml } from '@/lib/pdf-templates/order-acknowledgement'; +import { renderBOLHtml } from '@/lib/pdf-templates/bol'; +import type { BOLData } from '@/types/shipments'; + +export type PdfOptions = { + format?: 'Letter' | 'A4'; + landscape?: boolean; + margin?: { + top?: string; + right?: string; + bottom?: string; + left?: string; + }; +}; + +/** + * Generate a PDF from an HTML string using Puppeteer. + * + * Launches a headless Chromium instance, renders the HTML, + * and returns the PDF as a Buffer. + */ +export async function generatePdfFromHtml( + html: string, + options?: PdfOptions +): Promise { + const browser = await puppeteer.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu'], + }); + + try { + const page = await browser.newPage(); + await page.setContent(html, { waitUntil: 'networkidle0' }); + + const pdfUint8 = await page.pdf({ + format: options?.format ?? 'Letter', + landscape: options?.landscape ?? false, + printBackground: true, + margin: options?.margin ?? { + top: '0.4in', + right: '0.5in', + bottom: '0.4in', + left: '0.5in', + }, + }); + + return Buffer.from(pdfUint8); + } finally { + await browser.close(); + } +} + +/** + * Generate an Order Acknowledgement PDF. + */ +export async function generateOrderAckPdf( + data: OrderAcknowledgementData +): Promise { + const html = renderOrderAckHtml(data); + return generatePdfFromHtml(html, { format: 'Letter' }); +} + +/** + * Generate a BOL PDF from data. + */ +export async function generateBOLPdf(data: BOLData): Promise { + const html = renderBOLHtml(data); + return generatePdfFromHtml(html, { format: 'Letter' }); +} diff --git a/src/services/coil-activity.ts b/src/services/coil-activity.ts new file mode 100644 index 0000000..66b6da1 --- /dev/null +++ b/src/services/coil-activity.ts @@ -0,0 +1,258 @@ +/** + * Coil Activity Service + * + * Fetches coil usage and receipts data from Epicor. + * Ported from legacy PHP: EpicorDatabase.php + CoilActivityHelper.php + */ + +import { execQuery } from '@/lib/epicor'; +import type { CoilUsageRow, CoilReceiptRow } from '@/types/coil-activity'; + +// ============================================================================= +// Coil Usage +// ============================================================================= + +/** + * Get coil usage data for a customer within a date range. + * Returns usage details with weight, on-hand quantity, and job/order information. + * + * HDC customer exception: maps 'HDC' → 'HDM' for the query. + * Post-query deduplication: ensures only the most recent record for each LotNum + * retains its OnHandQty value. + */ +export async function getCoilUsage( + custId: string, + startDate: string, + endDate: string +): Promise { + // HDC → HDM mapping + const queryCustId = custId === 'HDC' ? 'HDM' : custId; + + const sql = ` + SELECT + Part.UserChar2 AS CustID, + [PartTran].[TranDate] AS [DateUsed], + PartTran.[PartNum] AS [VorteqPartNum], + CustXPrt.XPartNum AS [CustomerPartNum], + PartTran.[PartDescription] AS [PartDesc], + [PartTran].[LotNum] AS [LotNum], + SUM([PartTran].[TranQty]) AS [Weight], + [Plant].Name AS [PlantName], + [PartTran].[JobNum] AS [JobNum], + OrderHed.PONum AS [CustomerPO], + PartBin.OnhandQty AS [OnHandQty], + PartLot.MfgLot + FROM Erp.Part AS Part + INNER JOIN Erp.PartTran AS PartTran ON Part.Company = PartTran.Company AND Part.PartNum = PartTran.PartNum + AND (PartTran.TranQty <> 0.0000 AND PartTran.TranType = 'STK-MTL' AND PartTran.UM = 'LB') + LEFT OUTER JOIN Erp.JobHead AS JobHead ON PartTran.Company = JobHead.Company AND PartTran.JobNum = JobHead.JobNum AND PartTran.Plant = JobHead.Plant + LEFT OUTER JOIN Erp.Plant AS Plant ON Plant.Company = PartTran.Company AND Plant.Plant = PartTran.Plant + LEFT OUTER JOIN Erp.CustXPrt AS CustXPrt ON PartTran.Company = CustXPrt.Company AND PartTran.PartNum = CustXPrt.PartNum + LEFT OUTER JOIN Erp.JobProd AS JobProd ON JobHead.Company = JobProd.Company AND JobHead.JobNum = JobProd.JobNum + INNER JOIN Erp.OrderHed AS OrderHed ON JobProd.Company = OrderHed.Company AND JobProd.OrderNum = OrderHed.OrderNum + FULL OUTER JOIN Erp.PartBin AS PartBin ON PartTran.PartNum = PartBin.PartNum AND PartTran.LotNum = PartBin.LotNum + INNER JOIN Erp.JobMtl as JobMtl ON JobHead.Company = JobMtl.Company AND JobHead.JobNum = JobMtl.JobNum AND (JobMtl.IUM = 'LB') + LEFT OUTER JOIN Erp.PartLot as PartLot ON PartBin.LotNum = PartLot.LotNum AND PartBin.PartNum = PartLot.PartNum AND PartLot.MfgLot IS NOT null + WHERE Part.UserChar2 = @CustID AND [PartTran].[TranDate] >= @StartDate AND [PartTran].[TranDate] <= @EndDate + GROUP BY Part.UserChar2, [PartTran].[TranDate], PartTran.[PartNum], CustXPrt.XPartNum, PartTran.[PartDescription], [PartTran].[LotNum], [Plant].Name, [PartTran].[JobNum], OrderHed.PONum, PartBin.OnhandQty, PartLot.MfgLot + ORDER BY [PartTran].[TranDate] DESC + `; + + const rawRows = await execQuery[]>(sql, { + CustID: queryCustId, + StartDate: startDate, + EndDate: endDate, + }); + + // Map to typed rows + const mappedRows = rawRows.map((r) => mapCoilUsageRow(r)); + + // Apply post-query deduplication + return deduplicateOnHandQty(mappedRows); +} + +/** + * Map raw SQL result to CoilUsageRow + */ +function mapCoilUsageRow(raw: Record): CoilUsageRow { + return { + date_used: raw.DateUsed + ? new Date(raw.DateUsed as string).toISOString() + : '', + vorteq_part_num: String(raw.VorteqPartNum ?? ''), + customer_part_num: raw.CustomerPartNum + ? String(raw.CustomerPartNum) + : null, + part_desc: String(raw.PartDesc ?? ''), + lot_num: String(raw.LotNum ?? ''), + mfg_lot: raw.MfgLot ? String(raw.MfgLot) : null, + weight: raw.Weight !== null ? Number(raw.Weight) : null, + plant_name: String(raw.PlantName ?? ''), + job_num: String(raw.JobNum ?? ''), + customer_po: String(raw.CustomerPO ?? ''), + on_hand_qty: raw.OnHandQty !== null ? Number(raw.OnHandQty) : null, + }; +} + +/** + * Deduplicate on_hand_qty per lot_num, keeping only the most recent date. + * Also nulls out zero weights. + * + * Ported from legacy PHP getCoilActivityUsageData() logic: + * - For each row with on_hand_qty > 0: + * - Track lot_num → { date, index } + * - If lot_num already seen, compare dates: + * - Null out on_hand_qty on the older row + * - Keep the newer row's on_hand_qty + * - Set weight to null if it equals 0 + */ +function deduplicateOnHandQty(rows: CoilUsageRow[]): CoilUsageRow[] { + const result: CoilUsageRow[] = rows.map((r) => ({ ...r })); + const foundRows: Record = {}; + + for (let i = 0; i < result.length; i++) { + const row = result[i] as CoilUsageRow; + const onHandQty = row.on_hand_qty; + + if (onHandQty != null && onHandQty > 0) { + const lotNum = row.lot_num; + const rowDate = new Date(row.date_used).getTime(); + const existing = foundRows[lotNum]; + + if (existing) { + if (rowDate < existing.date) { + // Current row is older — null it out + result[i] = { ...row, on_hand_qty: null }; + } else { + // Current row is newer — null out the previous one + const prev = result[existing.index] as CoilUsageRow; + result[existing.index] = { ...prev, on_hand_qty: null }; + foundRows[lotNum] = { date: rowDate, index: i }; + } + } else { + foundRows[lotNum] = { date: rowDate, index: i }; + } + } + + // Null out zero weight + const current = result[i] as CoilUsageRow; + if (current.weight === 0) { + result[i] = { ...current, weight: null }; + } + } + + return result; +} + +// ============================================================================= +// Coil Receipts +// ============================================================================= + +/** + * Get coil receipt data for a customer within a date range. + * Returns receipt details with supplier, alloy, and mill order information. + * + * VGL customer exception: uses a special SQL query instead of the standard view. + * HDC customer exception: maps 'HDC' → 'HDM' for the query. + */ +export async function getCoilReceipts( + custId: string, + startDate: string, + endDate: string +): Promise { + // HDC → HDM mapping + const queryCustId = custId === 'HDC' ? 'HDM' : custId; + + // VGL uses a special query + if (queryCustId === 'VGL') { + return getCoilReceiptsVGL(startDate, endDate); + } + + // Standard query for all other customers + const sql = ` + SELECT DateReceived, VorteqPartNum, CustomerPartNum, PartDesc, ManufacturerLotNum, PlantName, PackingSlip, SupplierName, MillOrderNum, Alloy + FROM dbo.portal_CoilActivityReceipts + WHERE CustID = @CustID AND DateReceived >= @StartDate AND DateReceived <= @EndDate + ORDER BY DateReceived + `; + + const rawRows = await execQuery[]>(sql, { + CustID: queryCustId, + StartDate: startDate, + EndDate: endDate, + }); + + return rawRows.map((r) => mapCoilReceiptRow(r)); +} + +/** + * Get coil receipts for VGL customer using the special SQL query. + * VGL query joins directly to Epicor tables instead of using the portal view. + */ +async function getCoilReceiptsVGL( + startDate: string, + endDate: string +): Promise { + const sql = ` + SELECT + [Part].[UserChar2] AS [CustID], + [Customer].[Name] AS [Customer_Name], + [RcvHead].[ReceiptDate] AS [DateReceived], + [RcvDtl].[PartNum] AS [VorteqPartNum], + [CustXPrt].[XPartNum] AS [CustomerPartNum], + [RcvDtl].[PartDescription] AS [PartDesc], + [RcvDtl].[LotNum] AS [ManufacturerLotNum], + [Plant].[Name] AS [PlantName], + [RcvHead].[PackSlip] AS [PackingSlip], + [PartLot].[PartLotDescription] AS [SupplierName], + [PartLot].[Batch] AS [MillOrderNum], + [PartLot].[MfgLot] AS [Alloy], + [PartLot].[HeatNum] AS [Temper], + [PartLot].[FirmWare] AS [CoilsPerSkid], + [RcvDtl].[OurQty] AS [Weight] + FROM Erp.RcvHead AS RcvHead + INNER JOIN Erp.RcvDtl AS RcvDtl ON RcvHead.Company = RcvDtl.Company AND RcvHead.VendorNum = RcvDtl.VendorNum AND RcvHead.PurPoint = RcvDtl.PurPoint AND RcvHead.PackSlip = RcvDtl.PackSlip + INNER JOIN Erp.Vendor AS Vendor ON RcvHead.Company = Vendor.Company AND RcvHead.VendorNum = Vendor.VendorNum + INNER JOIN Erp.PartLot AS PartLot ON RcvDtl.Company = PartLot.Company AND RcvDtl.PartNum = PartLot.PartNum AND RcvDtl.LotNum = PartLot.LotNum + INNER JOIN Erp.Part AS Part ON RcvDtl.Company = Part.Company AND RcvDtl.PartNum = Part.PartNum AND (Part.UserChar2 = 'VGL') + INNER JOIN Erp.Plant AS Plant ON RcvDtl.Company = Plant.Company AND RcvHead.Plant = Plant.Plant + INNER JOIN Erp.Customer AS Customer ON Part.UserChar2 = Customer.CustID AND RcvDtl.Company = Customer.Company + LEFT OUTER JOIN Erp.CustXPrt AS CustXPrt ON RcvDtl.Company = CustXPrt.Company AND RcvDtl.PartNum = CustXPrt.PartNum AND Part.UserChar2 = CustXPrt.CustID + WHERE [RcvHead].[ReceiptDate] >= @StartDate AND [RcvHead].[ReceiptDate] <= @EndDate + ORDER BY [RcvHead].[ReceiptDate] + `; + + // VGL query only takes date parameters — CustID is hardcoded in the JOIN + const rawRows = await execQuery[]>(sql, { + StartDate: startDate, + EndDate: endDate, + }); + + return rawRows.map((r) => mapCoilReceiptRow(r)); +} + +/** + * Map raw SQL result to CoilReceiptRow. + * Works for both the standard view query and the VGL special query. + * VGL query returns extra columns (Weight, Temper, CoilsPerSkid) but we ignore them. + */ +function mapCoilReceiptRow(raw: Record): CoilReceiptRow { + return { + date_received: raw.DateReceived + ? new Date(raw.DateReceived as string).toISOString() + : '', + vorteq_part_num: String(raw.VorteqPartNum ?? ''), + customer_part_num: raw.CustomerPartNum + ? String(raw.CustomerPartNum) + : null, + part_desc: String(raw.PartDesc ?? ''), + manufacturer_lot_num: raw.ManufacturerLotNum + ? String(raw.ManufacturerLotNum) + : null, + alloy: raw.Alloy ? String(raw.Alloy) : null, + plant_name: String(raw.PlantName ?? ''), + packing_slip: String(raw.PackingSlip ?? ''), + supplier_name: raw.SupplierName ? String(raw.SupplierName) : null, + mill_order_num: raw.MillOrderNum ? String(raw.MillOrderNum) : null, + }; +} diff --git a/src/services/dashboard.ts b/src/services/dashboard.ts index ffd3de6..84be6be 100644 --- a/src/services/dashboard.ts +++ b/src/services/dashboard.ts @@ -1,28 +1,34 @@ /** * Dashboard Service * - * Fetches summary data for the dashboard page + * Fetches summary data for the dashboard page. + * + * IMPORTANT: All rows from execQuery must be JSON-sanitized before returning. + * The mssql driver attaches prototype metadata to recordset rows that causes + * Next.js RSC serialization to blow the stack. We extract only the fields + * we need into plain objects. */ -import { execQuery } from '@/lib/epicor'; +import sql from 'mssql'; +import { execQuery, getPortalDbName } from '@/lib/epicor'; +import { getTop100Shipments } from '@/services/shipments'; export type DashboardOrder = { order_num: string; po_num: string; customer_part: string; vorteq_part: string; - order_date: Date; - need_by_date: Date; + order_date: string; + need_by_date: string; qty: number; status: string; }; export type DashboardShipment = { bol_num: string; - pack_num: string; - ship_date: Date; + ship_date: string; ship_to: string; - carrier: string; + plant: string; weight: number; }; @@ -39,7 +45,7 @@ export type InventorySummary = { export async function getRecentOrders( custId: string ): Promise { - const sql = ` + const query = ` SELECT TOP 5 oh.OrderNum as order_num, oh.PONum as po_num, @@ -47,8 +53,7 @@ export async function getRecentOrders( od.PartNum as vorteq_part, oh.OrderDate as order_date, oh.NeedByDate as need_by_date, - od.SellingQuantity as qty, - oh.OpenOrder as is_open + od.SellingQuantity as qty FROM Erp.OrderHed oh INNER JOIN Erp.OrderDtl od ON oh.Company = od.Company AND oh.OrderNum = od.OrderNum INNER JOIN Erp.Customer c ON oh.Company = c.Company AND oh.CustNum = c.CustNum @@ -57,92 +62,140 @@ export async function getRecentOrders( ORDER BY oh.OrderDate DESC `; - const result = await execQuery(sql, { + const result = await execQuery[]>(query, { custId, }); - return result.map((row) => ({ - ...row, + // Extract only needed fields into plain objects (RSC-safe) + return result.map((r) => ({ + order_num: String(r.order_num ?? ''), + po_num: String(r.po_num ?? ''), + customer_part: String(r.customer_part ?? ''), + vorteq_part: String(r.vorteq_part ?? ''), + order_date: r.order_date + ? new Date(r.order_date as string).toISOString() + : '', + need_by_date: r.need_by_date + ? new Date(r.need_by_date as string).toISOString() + : '', + qty: Number(r.qty ?? 0), status: 'Open', })); } /** - * Get top 5 recent shipments for dashboard + * Get top 5 recent shipments for dashboard. + * + * Reuses getTop100Shipments from the shipments service which already handles + * the portal_GetShipmentsV1 SP and returns RSC-safe plain objects. + * We just take the first 5 (already sorted by date desc). */ export async function getRecentShipments( custId: string ): Promise { - const sql = ` - SELECT TOP 5 - sh.PackNum as bol_num, - sh.PackNum as pack_num, - sh.ShipDate as ship_date, - CONCAT(st.Name, ', ', st.City, ', ', st.State) as ship_to, - COALESCE(sh.CarrierName, 'N/A') as carrier, - sh.Weight as weight - FROM Erp.ShipHead sh - INNER JOIN Erp.Customer c ON sh.Company = c.Company AND sh.CustNum = c.CustNum - LEFT JOIN Erp.ShipTo st ON sh.Company = st.Company AND sh.CustNum = st.CustNum AND sh.ShipToNum = st.ShipToNum - WHERE c.CustID = @custId - AND sh.ShipDate IS NOT NULL - ORDER BY sh.ShipDate DESC - `; + const shipments = await getTop100Shipments(custId); - const result = await execQuery(sql, { - custId, - }); - - return result; + return shipments.slice(0, 5).map((s) => ({ + bol_num: s.bol_num, + ship_date: s.ship_date, + ship_to: s.ship_to, + plant: s.plant, + weight: s.weight, + })); } /** - * Get inventory summary counts for dashboard + * Get inventory summary counts for dashboard. + * + * Calls the real portal inventory stored procedures: + * - PortalWorkInProgressInventorySummaryV6 (@Customer, @DBNAME, @SUBUSER) + * - PortalFinishedGoodsInventorySummaryV6 (@Customer, @DBNAME, @SUBUSER) + * - PortalUnprocessedInventorySummary (@CUSTID, @DBNAME) + * + * Each returns rows per product with Rows (coil count) and OnHandQty (lbs). + * We aggregate them into totals for the dashboard cards. */ export async function getInventorySummary( custId: string ): Promise { - // This is a simplified query - actual implementation would call - // the inventory stored procedures to get accurate counts + const dbName = getPortalDbName(); - const sql = ` - SELECT - COUNT(CASE WHEN jh.JobClosed = 0 THEN 1 END) as wip_count, - COUNT(CASE WHEN pd.OnHandQty > 0 AND jh.JobClosed = 1 THEN 1 END) as finished_goods_count, - SUM(COALESCE(pd.OnHandQty, 0)) as total_weight - FROM Erp.JobHead jh - INNER JOIN Erp.PartDtl pd ON jh.Company = pd.Company AND jh.JobNum = pd.JobNum - INNER JOIN Erp.Customer c ON jh.Company = c.Company AND jh.CustNum = c.CustNum - WHERE c.CustID = @custId - `; - - const result = await execQuery< - Array<{ - wip_count: number; - finished_goods_count: number; - total_weight: number; - }> - >(sql, { - custId, - }); - - if (result.length === 0 || !result[0]) { - return { - wip_count: 0, - finished_goods_count: 0, - unprocessed_count: 0, - total_weight: 0, - }; - } - - const firstResult = result[0]; - - return { - wip_count: firstResult.wip_count || 0, - finished_goods_count: firstResult.finished_goods_count || 0, - total_weight: firstResult.total_weight || 0, - unprocessed_count: 0, // Would need separate query + const config = { + server: process.env.MSSQL_HOST || '', + database: process.env.MSSQL_DATABASE || '', + user: process.env.MSSQL_USER || '', + password: process.env.MSSQL_PASSWORD || '', + port: parseInt(process.env.MSSQL_PORT || '1433', 10), + options: { + encrypt: false, + trustServerCertificate: true, + connectTimeout: 30000, + requestTimeout: 120000, + }, }; + + const pool = await sql.connect(config); + try { + // Run all three inventory SPs in parallel + const [wipResult, fgResult, unprocessedResult] = await Promise.all([ + pool + .request() + .input('Customer', custId) + .input('DBNAME', dbName) + .input('SUBUSER', 0) + .execute('PortalWorkInProgressInventorySummaryV6') + .catch(() => null), + pool + .request() + .input('Customer', custId) + .input('DBNAME', dbName) + .input('SUBUSER', 0) + .execute('PortalFinishedGoodsInventorySummaryV6') + .catch(() => null), + pool + .request() + .input('CUSTID', custId) + .input('DBNAME', dbName) + .execute('PortalUnprocessedInventorySummary') + .catch(() => null), + ]); + + // Aggregate: sum Rows for counts, sum OnHandQty for total weight + let wipCount = 0; + let fgCount = 0; + let unprocessedCount = 0; + let totalWeight = 0; + + if (wipResult) { + for (const r of wipResult.recordset) { + wipCount += Number(r.Rows ?? 0); + totalWeight += Number(r.OnHandQty ?? 0); + } + } + + if (fgResult) { + for (const r of fgResult.recordset) { + fgCount += Number(r.Rows ?? 0); + totalWeight += Number(r.OnHandQty ?? 0); + } + } + + if (unprocessedResult) { + for (const r of unprocessedResult.recordset) { + unprocessedCount += Number(r.Rows ?? 0); + totalWeight += Number(r.OnHandQty ?? 0); + } + } + + return { + wip_count: wipCount, + finished_goods_count: fgCount, + unprocessed_count: unprocessedCount, + total_weight: totalWeight, + }; + } finally { + // Don't close the pool — mssql reuses it globally + } } /** diff --git a/src/services/orders.ts b/src/services/orders.ts index 2b34807..e73175f 100644 --- a/src/services/orders.ts +++ b/src/services/orders.ts @@ -1,9 +1,20 @@ /** * Orders Service - * Handles order data retrieval from Epicor using the portal_Orders view + * Handles order data retrieval from Epicor using the portal_Orders view. + * + * IMPORTANT: All rows from execQuery must be JSON-sanitized before returning. + * The mssql driver attaches prototype metadata to recordset rows that causes + * Next.js RSC serialization to blow the stack. We extract only the fields + * we need into plain objects. */ import { execQuery } from '@/lib/epicor'; +import type { + OrderAcknowledgementData, + OrderAckHeader, + OrderAckLine, + OrderAckRelease, +} from '@/types/orders'; export type OrderRow = { order_num: number; @@ -140,3 +151,283 @@ export async function getOrderDetails(orderNum: number): Promise { return result; } + +// ============================================================================= +// Order Acknowledgement (C-005) +// ============================================================================= + +/** + * Get full order acknowledgement data for a specific order. + * + * Queries Epicor OrderHed/OrderDtl/OrderRel/Customer/ShipTo/Terms/ShipVia + * and groups the flat SQL result into a nested OrderAcknowledgementData structure. + * + * Security: filters by CustID to ensure a customer can only view their own orders. + */ +export async function getOrderAcknowledgement( + orderNum: number, + custId: string +): Promise { + const queryCustId = custId === 'HDC' ? 'HDM' : custId; + + // Main query: header + lines + releases in one shot + const mainSql = ` + SELECT + -- Header + oh.OrderNum, + oh.PONum, + oh.OrderDate, + oh.NeedByDate, + oh.OrderComment AS order_comment, + oh.FOB, + plt.Name AS fob_description, + oh.CurrencyCode, + t.Description AS payment_terms, + sv.Description AS ship_via, + -- Customer (Sold To) + c.Name AS customer_name, + c.CustID AS cust_id, + c.Address1 AS cust_address1, + c.Address2 AS cust_address2, + c.City AS cust_city, + c.State AS cust_state, + c.Zip AS cust_zip, + -- Ship To + st.Name AS ship_to_name, + st.Address1 AS ship_to_address1, + st.Address2 AS ship_to_address2, + st.City AS ship_to_city, + st.State AS ship_to_state, + st.ZIP AS ship_to_zip, + -- Line Detail + od.OrderLine, + od.PartNum, + od.XPartNum, + od.LineDesc, + od.OrderQty, + od.UnitPrice, + (od.OrderQty * od.UnitPrice) AS extended_price, + od.IUM, + od.RevisionNum, + od.OrderComment AS line_comment, + -- Release + orel.OrderRelNum, + orel.ReqDate AS rel_need_by_date, + orel.OurReqQty AS rel_quantity, + jp.JobNum AS rel_job_num + FROM Erp.OrderHed oh + INNER JOIN Erp.OrderDtl od ON oh.Company = od.Company AND oh.OrderNum = od.OrderNum + INNER JOIN Erp.Customer c ON oh.Company = c.Company AND oh.CustNum = c.CustNum + LEFT JOIN Erp.ShipTo st ON oh.Company = st.Company AND oh.CustNum = st.CustNum AND oh.ShipToNum = st.ShipToNum + LEFT JOIN Erp.Terms t ON oh.Company = t.Company AND oh.TermsCode = t.TermsCode + LEFT JOIN Erp.ShipVia sv ON oh.Company = sv.Company AND oh.ShipViaCode = sv.ShipViaCode + LEFT JOIN Erp.Plant plt ON oh.Company = plt.Company AND oh.FOB = plt.Plant + LEFT JOIN Erp.OrderRel orel ON od.Company = orel.Company AND od.OrderNum = orel.OrderNum AND od.OrderLine = orel.OrderLine + LEFT JOIN Erp.JobProd jp ON orel.Company = jp.Company AND orel.OrderNum = jp.OrderNum AND orel.OrderLine = jp.OrderLine AND orel.OrderRelNum = jp.OrderRelNum + WHERE oh.OrderNum = @OrderNum + AND c.CustID = @CustID + ORDER BY od.OrderLine, orel.OrderRelNum + `; + + const rows = await execQuery[]>(mainSql, { + OrderNum: orderNum, + CustID: queryCustId, + }); + + if (rows.length === 0) { + return null; + } + + // Fetch misc charges + const miscSql = ` + SELECT + om.OrderLine, + om.MiscAmt + FROM Erp.OrderMisc om + WHERE om.OrderNum = @OrderNum + `; + + const miscRows = await execQuery[]>(miscSql, { + OrderNum: orderNum, + }).catch(() => [] as Record[]); + + // Aggregate misc charges by line (OrderLine = 0 means order-level) + const lineMiscMap = new Map(); + let orderMiscCharges = 0; + for (const mr of miscRows) { + const line = Number(mr.OrderLine ?? 0); + const amt = Number(mr.MiscAmt ?? 0); + if (line === 0) { + orderMiscCharges += amt; + } else { + lineMiscMap.set(line, (lineMiscMap.get(line) ?? 0) + amt); + } + } + + // Collect unique part numbers for paint code lookup + const partNums = new Set(); + for (const r of rows) { + const partNum = String(r.PartNum ?? ''); + if (partNum) partNums.add(partNum); + } + + // Fetch paint codes + const paintMap = await getPaintCodes(Array.from(partNums)); + + // Build header from first row (we already returned null above if rows is empty) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const first = rows[0]!; + const header: OrderAckHeader = { + order_num: Number(first.OrderNum ?? 0), + order_type: 'Production Order', + po_num: String(first.PONum ?? ''), + order_date: first.OrderDate + ? new Date(first.OrderDate as string).toISOString() + : '', + need_by_date: first.NeedByDate + ? new Date(first.NeedByDate as string).toISOString() + : '', + payment_terms: String(first.payment_terms ?? ''), + sales_person: 'Vorteq Coil Finishers, LLC', + ship_via: String(first.ship_via ?? ''), + fob: first.fob_description + ? (String(first.fob_description).endsWith('Plant') + ? String(first.fob_description) + : `${String(first.fob_description)} Plant`) + : String(first.FOB ?? ''), + currency: String(first.CurrencyCode ?? 'USD'), + order_comment: String(first.order_comment ?? ''), + customer_name: String(first.customer_name ?? ''), + cust_id: String(first.cust_id ?? ''), + customer_address1: String(first.cust_address1 ?? ''), + customer_address2: String(first.cust_address2 ?? ''), + customer_city: String(first.cust_city ?? ''), + customer_state: String(first.cust_state ?? ''), + customer_zip: String(first.cust_zip ?? ''), + ship_to_name: String(first.ship_to_name ?? ''), + ship_to_address1: String(first.ship_to_address1 ?? ''), + ship_to_address2: String(first.ship_to_address2 ?? ''), + ship_to_city: String(first.ship_to_city ?? ''), + ship_to_state: String(first.ship_to_state ?? ''), + ship_to_zip: String(first.ship_to_zip ?? ''), + }; + + // Group rows into lines and releases + const linesMap = new Map(); + + for (const r of rows) { + const lineNum = Number(r.OrderLine ?? 0); + const partNum = String(r.PartNum ?? ''); + const paint = paintMap.get(partNum); + + if (!linesMap.has(lineNum)) { + // Build the customer part description from XPartNum + LineDesc + const xPartNum = String(r.XPartNum ?? ''); + const lineDesc = String(r.LineDesc ?? ''); + // "Our Part" line: customer_part / customer_part_desc + // In the legacy PDF: "Our Part: ACM3ARA01724BKRB / 017 X 24.00 BLACK / BROWN" + const customerPartDesc = lineDesc; + + linesMap.set(lineNum, { + order_line: lineNum, + part_num: partNum, + part_description: lineDesc, + revision: String(r.RevisionNum ?? ''), + order_qty: Number(r.OrderQty ?? 0), + unit_of_measure: String(r.IUM ?? ''), + unit_price: Number(r.UnitPrice ?? 0), + extended_price: Number(r.extended_price ?? 0), + customer_part: xPartNum, + customer_part_desc: customerPartDesc, + top_finish: paint?.top_finish ?? '', + bottom_finish: paint?.bottom_finish ?? '', + comment: String(r.line_comment ?? ''), + releases: [], + line_misc_charges: lineMiscMap.get(lineNum) ?? 0, + }); + } + + // Add release if present + const relNum = r.OrderRelNum; + if (relNum != null && Number(relNum) > 0) { + const line = linesMap.get(lineNum)!; + // Avoid duplicate releases (multiple rows can produce dupes due to JOINs) + const alreadyAdded = line.releases.some( + (rel) => rel.release_num === Number(relNum) + ); + if (!alreadyAdded) { + line.releases.push({ + release_num: Number(relNum), + need_by_date: r.rel_need_by_date + ? new Date(r.rel_need_by_date as string).toISOString() + : '', + quantity: Number(r.rel_quantity ?? 0), + job_num: String(r.rel_job_num ?? ''), + }); + } + } + } + + const lines = Array.from(linesMap.values()); + + // Calculate totals + const orderTotalQty = lines.reduce((sum, l) => sum + l.order_qty, 0); + const orderTotalPrice = lines.reduce((sum, l) => sum + l.extended_price, 0); + + return { + header, + lines, + order_misc_charges: orderMiscCharges, + order_total_qty: orderTotalQty, + order_total_price: orderTotalPrice, + }; +} + +/** + * Batch lookup paint codes for a list of part numbers. + * Queries Erp.Part + Erp.Part_UD joined via SysRowID/ForeignSysRowID. + */ +type PaintCodeInfo = { + top_finish: string; + bottom_finish: string; +}; + +async function getPaintCodes( + partNums: string[] +): Promise> { + const result = new Map(); + if (partNums.length === 0) return result; + + // Build parameterized IN clause: @P0, @P1, @P2, ... + const paramNames = partNums.map((_, i) => `@P${i}`); + const params: Record = {}; + partNums.forEach((pn, i) => { + params[`P${i}`] = pn; + }); + + const sql = ` + SELECT + p.PartNum, + pu.TopcoatPaintCode_c AS top_finish, + pu.BackcoatPaintCode_c AS bottom_finish + FROM Erp.Part p + INNER JOIN Erp.Part_UD pu ON pu.ForeignSysRowID = p.SysRowID + WHERE p.PartNum IN (${paramNames.join(', ')}) + `; + + try { + const rows = await execQuery[]>(sql, params); + for (const r of rows) { + result.set(String(r.PartNum ?? ''), { + top_finish: String(r.top_finish ?? ''), + bottom_finish: String(r.bottom_finish ?? ''), + }); + } + } catch { + // Paint codes are optional — if Part_UD doesn't exist or query fails, + // we just return empty paint codes + console.warn('Paint code lookup failed, continuing without paint codes'); + } + + return result; +} diff --git a/src/services/shipments.ts b/src/services/shipments.ts index 1cf1109..f65aec9 100644 --- a/src/services/shipments.ts +++ b/src/services/shipments.ts @@ -5,52 +5,212 @@ * SP columns are PascalCase; we map them to snake_case for the UI. */ -import { execStoredProc, getPortalDbName } from '@/lib/epicor'; +import sql from 'mssql'; +import { execQuery, getPortalDbName } from '@/lib/epicor'; +import type { BOLData } from '@/types/shipments'; export type ShipmentRow = { - pack_num: string; + bol_num: string; ship_date: string; ship_to: string; - carrier: string; + plant: string; weight: number; - tracking_num: string; - [key: string]: unknown; }; -/** - * Map raw Epicor SP row to our normalized type. - * ShipToLoc comes as comma-separated; we convert to newline-separated for display. - */ -function mapShipmentRow(raw: Record): ShipmentRow { - const shipToLoc = String(raw.ShipToLoc ?? ''); - - return { - pack_num: String(raw.PackNum ?? ''), - ship_date: raw.ShipDate ? String(raw.ShipDate) : '', - ship_to: shipToLoc.replace(/, /g, '\n'), - carrier: String(raw.CarrierName ?? raw.Carrier ?? ''), - weight: Number(raw.Weight ?? 0), - tracking_num: String(raw.TrackingNum ?? raw.TrackingNumber ?? ''), - }; -} - /** * Get top 100 shipments for a customer. - * Uses portal_GetShipmentsV1 SP with CustID and DBNAME params. - * No SUBUSER param — all users see the same shipment data. + * + * Bypasses execStoredProc to avoid holding 7k+ mssql row objects in scope + * (which causes Next.js RSC serialization to blow the stack for large customers). + * Instead, we query directly and immediately extract only the fields we need. */ export async function getTop100Shipments( custId: string ): Promise { const dbName = getPortalDbName(); - const result = await execStoredProc[]>( - 'portal_GetShipmentsV1', - { - CustID: custId, - DBNAME: dbName, - } - ); + const config = { + server: process.env.MSSQL_HOST || '', + database: process.env.MSSQL_DATABASE || '', + user: process.env.MSSQL_USER || '', + password: process.env.MSSQL_PASSWORD || '', + port: parseInt(process.env.MSSQL_PORT || '1433', 10), + options: { + encrypt: false, + trustServerCertificate: true, + connectTimeout: 30000, + requestTimeout: 120000, + }, + }; - return result.map(mapShipmentRow); + const pool = await sql.connect(config); + try { + const result = await pool.request() + .input('CustID', custId) + .input('DBNAME', dbName) + .execute('portal_GetShipmentsV1'); + + // Immediately extract only the fields we need into plain objects. + // This prevents mssql row metadata from leaking into RSC serialization. + const rows: ShipmentRow[] = []; + for (let i = 0; i < result.recordset.length; i++) { + const r = result.recordset[i]; + rows.push({ + bol_num: String(r.BOLNum ?? ''), + ship_date: r.ShipDate ? new Date(r.ShipDate as string).toISOString() : '', + ship_to: String(r.ShipToLoc ?? '').replace(/, /g, '\n'), + plant: String(r.PlantName ?? ''), + weight: Number(r.Pounds ?? 0), + }); + } + + // Sort by ship date descending and take top 100 + rows.sort((a, b) => b.ship_date.localeCompare(a.ship_date)); + return rows.slice(0, 100); + } finally { + // Don't close the pool — mssql reuses it globally + } +} + +/** + * Get BOL (Bill of Lading) detail for a specific BOL number. + * Uses the legacy portal's getBOL.sql query. + */ +export async function getBOLDetail( + bolNum: number, + custId: string +): Promise { + const queryCustId = custId === 'HDC' ? 'HDM' : custId; + + const mainSql = ` + SELECT DISTINCT + BOLDetail.ClassRate, + OurInventoryShipQty AS CoilWeight_c, + [Customer].[CustID] AS [Customer_CustID], + [Customer].[Name] AS [Customer_Name], + [BOLHead].[BOLNum] AS [BOLHead_BOLNum], + [BOLHead].[ShipDate] AS [BOLHead_ShipDate], + [BOLHead].[Carrier] AS [BOLHead_Carrier], + [ShipTo].[Name] AS [ShipTo_Name], + [ShipTo].[Address1] AS [ShipTo_Address1], + [ShipTo].[Address2] AS [ShipTo_Address2], + [ShipTo].[City] AS [ShipTo_City], + [ShipTo].[State] AS [ShipTo_State], + [ShipTo].[ZIP] AS [ShipTo_ZIP], + [Plant].[Name] AS [Plant_Name], + [Plant].[Address1] AS [Plant_Address1], + [Plant].[City] AS [Plant_City], + [Plant].[State] AS [Plant_State], + [Plant].[Zip] AS [Plant_Zip], + [ShipDtl].[PartNum] AS [ShipDtl_PartNum], + [ShipDtl].[LineDesc] AS [ShipDtl_LineDesc], + [ShipDtl].[IUM] AS [ShipDtl_IUM], + [ShipDtl].[PackNum] AS [ShipDtl_PackNum], + CustXPrt.XPartNum AS CustPartNum, + PartLot.LotNum, + erp.OrderHed.OrderNum, + ShipVia.Description AS ShipViaCode, + UD01.ShortChar03 AS CustomerPoNum, + CASE WHEN SkidNum_c IS NULL OR SkidNum_c = '' THEN + BOLDetail.Weight + ELSE + [UD01].[Number02] + END AS Weight + FROM Erp.BOLHead AS BOLHead + INNER JOIN Erp.BOLDetail AS BOLDetail + ON BOLDetail.Company = BOLHead.Company + AND BOLDetail.BOLNum = BOLHead.BOLNum + AND (NOT BOLDetail.ClassRate = 'METAL SCRAP' AND NOT BOLDetail.ClassRate = 'ALUM SCRAP') + INNER JOIN Erp.Plant AS Plant + ON Plant.Company = BOLHead.Company + AND Plant.Plant = BOLHead.Plant + CROSS JOIN Erp.Customer AS Customer + INNER JOIN Erp.ShipTo AS ShipTo + ON BOLHead.CustNum = ShipTo.CustNum + AND BOLHead.ShipToNum = ShipTo.ShipToNum + AND ShipTo.Company = Customer.Company + AND ShipTo.CustNum = Customer.CustNum + INNER JOIN Erp.ShipDtl + ON BOLDetail.ClassRate = CAST(ShipDtl.PackNum AS VARCHAR(50)) + INNER JOIN Erp.PartLot + ON ShipDtl.Company = PartLot.Company + AND ShipDtl.PartNum = PartLot.PartNum + AND ShipDtl.LotNum = PartLot.LotNum + INNER JOIN Erp.PartLot_UD + ON PartLot_UD.ForeignSysRowID = PartLot.SysRowID + INNER JOIN Ice.UD01 + ON PartLot_UD.SkidNum_c = UD01.Key1 + INNER JOIN erp.Part ON PartLot.PartNum = Erp.Part.PartNum + LEFT OUTER JOIN Erp.CustXPrt AS CustXPrt + ON Part.Company = CustXPrt.Company + AND Part.PartNum = CustXPrt.PartNum + LEFT OUTER JOIN erp.OrderHed ON erp.ShipDtl.OrderNum = erp.OrderHed.OrderNum + LEFT OUTER JOIN erp.JobProd ON erp.ShipDtl.JobNum = erp.JobProd.JobNum + LEFT JOIN Erp.ShipVia ON ShipVia.ShipViaCode = OrderHed.ShipViaCode + WHERE BOLHead.BOLNum = @BOLNum AND [Customer].[CustID] = @CustID + ORDER BY BOLDetail.ClassRate, ShipDtl.PartNum + `; + + const rows = await execQuery[]>(mainSql, { + BOLNum: bolNum, + CustID: queryCustId, + }); + + if (rows.length === 0) { + return null; + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const first = rows[0]!; + + // Calculate totals + let totalWeight = 0; + const lines: BOLData['lines'] = []; + + for (const r of rows) { + const weight = Number(r.Weight ?? 0); + totalWeight += weight; + + lines.push({ + pack_line: Number(r.ShipDtl_PackNum ?? 0), + part_num: String(r.ShipDtl_PartNum ?? ''), + part_description: String(r.ShipDtl_LineDesc ?? ''), + order_num: Number(r.OrderNum ?? 0), + order_line: 0, // Not in this query + po_num: String(r.CustomerPoNum ?? ''), + ship_qty: Number(r.CoilWeight_c ?? 0), + lot_num: String(r.LotNum ?? ''), + net_weight: weight, + uom: String(r.ShipDtl_IUM ?? ''), + cust_part_num: String(r.CustPartNum ?? ''), + revision: '', // Not in this query + }); + } + + const header: BOLData['header'] = { + bol_num: Number(first.BOLHead_BOLNum ?? 0), + pack_num: Number(first.ShipDtl_PackNum ?? 0), + ship_date: first.BOLHead_ShipDate + ? new Date(first.BOLHead_ShipDate as string).toISOString() + : '', + customer_name: String(first.Customer_Name ?? ''), + cust_id: String(first.Customer_CustID ?? ''), + ship_to_name: String(first.ShipTo_Name ?? ''), + ship_to_address1: String(first.ShipTo_Address1 ?? ''), + ship_to_address2: String(first.ShipTo_Address2 ?? ''), + ship_to_city: String(first.ShipTo_City ?? ''), + ship_to_state: String(first.ShipTo_State ?? ''), + ship_to_zip: String(first.ShipTo_ZIP ?? ''), + plant_name: String(first.Plant_Name ?? ''), + plant_address1: String(first.Plant_Address1 ?? ''), + plant_address2: '', // Not in this query + plant_city: String(first.Plant_City ?? ''), + plant_state: String(first.Plant_State ?? ''), + plant_zip: String(first.Plant_Zip ?? ''), + ship_via: String(first.ShipViaCode ?? ''), + total_weight: Math.round(totalWeight), + total_lines: lines.length, + }; + + return { header, lines }; } diff --git a/src/types/coil-activity.ts b/src/types/coil-activity.ts new file mode 100644 index 0000000..1526f89 --- /dev/null +++ b/src/types/coil-activity.ts @@ -0,0 +1,33 @@ +/** + * Coil Activity Type Definitions + * + * Types for the Coil Activity Usage and Receipts reports. + * Column names match the legacy portal's Epicor SQL queries. + */ + +export type CoilUsageRow = { + date_used: string; // PartTran.TranDate + vorteq_part_num: string; // PartTran.PartNum + customer_part_num: string | null; // CustXPrt.XPartNum + part_desc: string; // PartTran.PartDescription + lot_num: string; // PartTran.LotNum + mfg_lot: string | null; // PartLot.MfgLot + weight: number | null; // SUM(PartTran.TranQty) — null when 0 + plant_name: string; // Plant.Name + job_num: string; // PartTran.JobNum + customer_po: string; // OrderHed.PONum + on_hand_qty: number | null; // PartBin.OnhandQty — deduplicated per LotNum +}; + +export type CoilReceiptRow = { + date_received: string; // RcvHead.ReceiptDate or view DateReceived + vorteq_part_num: string; // RcvDtl.PartNum or view VorteqPartNum + customer_part_num: string | null; // CustXPrt.XPartNum or view CustomerPartNum + part_desc: string; // RcvDtl.PartDescription or view PartDesc + manufacturer_lot_num: string | null; // RcvDtl.LotNum or view ManufacturerLotNum (labeled "Lot#") + alloy: string | null; // PartLot.MfgLot or view Alloy (labeled "Mfg Lot#") + plant_name: string; // Plant.Name or view PlantName + packing_slip: string; // RcvHead.PackSlip or view PackingSlip + supplier_name: string | null; // PartLot.PartLotDescription or view SupplierName + mill_order_num: string | null; // PartLot.Batch or view MillOrderNum +}; diff --git a/src/types/orders.ts b/src/types/orders.ts new file mode 100644 index 0000000..3b27703 --- /dev/null +++ b/src/types/orders.ts @@ -0,0 +1,73 @@ +/** + * Order Acknowledgement Types + * + * Structured types for the order acknowledgement detail page and PDF. + * Data is fetched from Epicor OrderHed/OrderDtl/OrderRel/Customer/ShipTo + * and grouped into a nested structure. + */ + +/** Header-level data for an order acknowledgement */ +export type OrderAckHeader = { + order_num: number; + order_type: string; // e.g. "Production Order" + po_num: string; + order_date: string; // ISO string + need_by_date: string; // ISO string + payment_terms: string; + sales_person: string; + ship_via: string; + fob: string; + currency: string; // e.g. "USD" + order_comment: string; + // Sold To (Customer) + customer_name: string; + cust_id: string; + customer_address1: string; + customer_address2: string; + customer_city: string; + customer_state: string; + customer_zip: string; + // Ship To + ship_to_name: string; + ship_to_address1: string; + ship_to_address2: string; + ship_to_city: string; + ship_to_state: string; + ship_to_zip: string; +}; + +/** A single line on the order acknowledgement */ +export type OrderAckLine = { + order_line: number; + part_num: string; + part_description: string; + revision: string; + order_qty: number; + unit_of_measure: string; + unit_price: number; + extended_price: number; + customer_part: string; + customer_part_desc: string; + top_finish: string; + bottom_finish: string; + comment: string; + releases: OrderAckRelease[]; + line_misc_charges: number; +}; + +/** Release data for a line item */ +export type OrderAckRelease = { + release_num: number; + need_by_date: string; // ISO string + quantity: number; + job_num: string; +}; + +/** Complete order acknowledgement data */ +export type OrderAcknowledgementData = { + header: OrderAckHeader; + lines: OrderAckLine[]; + order_misc_charges: number; + order_total_qty: number; + order_total_price: number; +}; diff --git a/src/types/shipments.ts b/src/types/shipments.ts new file mode 100644 index 0000000..7c37a21 --- /dev/null +++ b/src/types/shipments.ts @@ -0,0 +1,42 @@ +export type BOLHeader = { + bol_num: number; + pack_num: number; + ship_date: string; + customer_name: string; + cust_id: string; + ship_to_name: string; + ship_to_address1: string; + ship_to_address2: string; + ship_to_city: string; + ship_to_state: string; + ship_to_zip: string; + plant_name: string; + plant_address1: string; + plant_address2: string; + plant_city: string; + plant_state: string; + plant_zip: string; + ship_via: string; + total_weight: number; + total_lines: number; +}; + +export type BOLLine = { + pack_line: number; + part_num: string; + part_description: string; + order_num: number; + order_line: number; + po_num: string; + ship_qty: number; + lot_num: string; + net_weight: number; + uom: string; + cust_part_num: string; + revision: string; +}; + +export type BOLData = { + header: BOLHeader; + lines: BOLLine[]; +};