feat: add order ack detail/PDF, BOL detail/PDF, coil activity usage & receipts
Some checks failed
Build and Deploy / build (push) Successful in 10m53s
Build and Deploy / deploy (push) Failing after 2s

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 <noreply@anthropic.com>
This commit is contained in:
Lorentz Hinrichsen 2026-02-17 09:14:52 -05:00
parent cd95bc4218
commit 53c35cc273
39 changed files with 3894 additions and 355 deletions

View file

@ -142,14 +142,12 @@ quest-vorteq/
### Not Yet Created (Planned) ### Not Yet Created (Planned)
``` ```
src/app/(portal)/shipments/
src/app/(portal)/coil-activity/ src/app/(portal)/coil-activity/
src/app/(portal)/invoices/ src/app/(portal)/invoices/
src/app/(portal)/shipment-requests/ src/app/(portal)/shipment-requests/
src/app/(portal)/allocation-requests/ src/app/(portal)/allocation-requests/
src/app/(portal)/jobs/ src/app/(portal)/jobs/
src/app/(portal)/admin/ src/app/(portal)/admin/
src/services/shipments.ts # Shipment data access
src/services/coil-activity.ts # Coil activity data access src/services/coil-activity.ts # Coil activity data access
src/services/ship-requests.ts # Shipment request business logic src/services/ship-requests.ts # Shipment request business logic
src/services/alloc-requests.ts # Allocation 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` - Standard view: `dbo.portal_Orders WHERE CustomerID = @CustID`
- HDC exception: uses `dbo.portal_OrdersHDC` view and maps CustID `HDC``HDM` - 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 ### Column Mapping
Epicor SP results use PascalCase column names. Services map them to snake_case: Epicor SP results use PascalCase column names. Services map them to snake_case:
```typescript ```typescript

View file

@ -128,7 +128,7 @@
## Phase 2: Core Features (Est. 80-110 hrs) ## Phase 2: Core Features (Est. 80-110 hrs)
**Progress:** 2/16 tasks complete **Progress:** 9/16 tasks complete
### C-001: Dashboard ### C-001: Dashboard
- [x] Dashboard page at `/(portal)/dashboard/page.tsx` - [x] Dashboard page at `/(portal)/dashboard/page.tsx`
@ -181,43 +181,53 @@
- **Deps:** F-006, F-009 | **Est:** 6 hrs | **Status:** ✅ Complete - **Deps:** F-006, F-009 | **Est:** 6 hrs | **Status:** ✅ Complete
### C-005: Order Acknowledgement Detail + PDF ### C-005: Order Acknowledgement Detail + PDF
- [ ] `/(portal)/orders/[id]/page.tsx` - [x] `/(portal)/orders/[orderNum]/page.tsx` — client-side fetch with loading skeleton
- [ ] Service: `getAcknowledgement(customer, poNumber?, orderNum?)` using `SalesOrder.sql` - [x] Service: `getOrderAcknowledgement(orderNum, custId)` with Epicor SQL (OrderHed/OrderDtl/OrderRel/Customer/ShipTo/Terms/ShipVia/Plant/JobProd)
- [ ] Detail view with all order line information - [x] Detail view matching legacy PDF layout (addresses, order info, line items, releases, paint codes, comments, totals)
- [ ] PDF export button (generate PDF server-side) - [x] PDF export via Puppeteer (`/api/orders/[orderNum]/pdf`)
- **Deps:** C-004 | **Est:** 6 hrs - [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 ### C-006: Shipment List
- [ ] `/(portal)/shipments/page.tsx` - [x] `/(portal)/shipments/page.tsx` — client-side fetch via API route (RSC-safe for large datasets)
- [ ] Service: `getTop100Shipments(custId, dbName)` via `portal_GetShipmentsV1` - [x] Service: `getTop100Shipments(custId)` via `portal_GetShipmentsV1` stored procedure
- [ ] ShipToLoc formatting (replace `, ` with line breaks) - [x] ShipToLoc formatting (replace `, ` with newlines)
- [ ] Data table with shipment details - [x] Data table with search, sort, CSV export (teal-700 header)
- [ ] Click-through to BOL detail - [x] Click-through to BOL detail (`/shipments/{bol_num}`)
- **Deps:** F-006, F-009 | **Est:** 5 hrs - [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 ### C-007: BOL Detail + PDF
- [ ] `/(portal)/shipments/[bol]/page.tsx` - [x] `/(portal)/shipments/[bol]/page.tsx`
- [ ] Service: `getBOL(bolNum, custId)` using `getBOL.sql` - [x] Service: `getBOL(bolNum, custId)` using `getBOL.sql`
- [ ] Detail view with line items - [x] Detail view with line items
- [ ] PDF export - [x] PDF export
- **Deps:** C-006 | **Est:** 4 hrs - **Deps:** C-006 | **Est:** 4 hrs | **Status:** ✅ Complete
### C-008: Coil Activity - Usage Report ### C-008: Coil Activity - Usage Report
- [ ] `/(portal)/coil-activity/usage/page.tsx` - [x] `/(portal)/coil-activity/usage/page.tsx`
- [ ] Date range picker (with validation) - [x] Date range picker (with validation — max 31 days, default last 10 days)
- [ ] Service: `getCoilActivityUsage(custId, startDate, endDate)` using `portal_CoilActivityUsage.sql` - [x] Reusable UI components: Popover, Calendar (react-day-picker v9), DateRangePicker
- [ ] Deduplication logic: only show OnHandQty for most recent DateUsed per LotNum - [x] Service: `getCoilUsage(custId, startDate, endDate)` using legacy `portal_CoilActivityUsage.sql`
- [ ] Zero weights → null display - [x] Deduplication logic: only show OnHandQty for most recent DateUsed per LotNum
- [ ] Data table with full column set - [x] Zero weights → null display
- **Deps:** F-006, F-009 | **Est:** 6 hrs - [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 ### C-009: Coil Activity - Receipts Report
- [ ] `/(portal)/coil-activity/receipts/page.tsx` - [x] `/(portal)/coil-activity/receipts/page.tsx`
- [ ] Date range picker - [x] Date range picker (shared component from C-008)
- [ ] Service: `getCoilActivityReceipts(custId, startDate, endDate)` - [x] Service: `getCoilReceipts(custId, startDate, endDate)` with VGL exception
- [ ] VGL customer exception (use special SQL) - [x] VGL customer exception: uses `VGLCoilReceiptsData.sql`; all others use `dbo.portal_CoilActivityReceipts` view
- [ ] Data table with receipt columns - [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 #
- **Deps:** F-006, F-009 | **Est:** 4 hrs - [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 ### C-010: Coil-by-Coil Report
- [ ] `/(portal)/coil-activity/coil-by-coil/page.tsx` - [ ] `/(portal)/coil-activity/coil-by-coil/page.tsx`

View file

@ -5,6 +5,7 @@ const nextConfig: NextConfig = {
reactStrictMode: true, reactStrictMode: true,
poweredByHeader: false, poweredByHeader: false,
compress: true, compress: true,
allowedDevOrigins: ['https://dev.quest.vorteq.local'],
experimental: { experimental: {
serverActions: { serverActions: {
bodySizeLimit: '10mb', bodySizeLimit: '10mb',

76
package-lock.json generated
View file

@ -756,7 +756,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -773,7 +772,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -790,7 +788,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -807,7 +804,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -824,7 +820,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -841,7 +836,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -858,7 +852,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -875,7 +868,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -892,7 +884,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -909,7 +900,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -926,7 +916,6 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -943,7 +932,6 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -960,7 +948,6 @@
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -977,7 +964,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -994,7 +980,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1011,7 +996,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1028,7 +1012,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1045,7 +1028,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1062,7 +1044,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1079,7 +1060,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1096,7 +1076,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1113,7 +1092,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1130,7 +1108,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1147,7 +1124,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1164,7 +1140,6 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1181,7 +1156,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3760,7 +3734,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3774,7 +3747,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3788,7 +3760,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3802,7 +3773,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3816,7 +3786,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3830,7 +3799,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3844,7 +3812,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3858,7 +3825,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3872,7 +3838,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3886,7 +3851,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3900,7 +3864,6 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3914,7 +3877,6 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3928,7 +3890,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3942,7 +3903,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3956,7 +3916,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3970,7 +3929,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3984,7 +3942,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3998,7 +3955,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -4012,7 +3968,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -4026,7 +3981,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -4040,7 +3994,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -4054,7 +4007,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -4068,7 +4020,6 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -4082,7 +4033,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -4096,7 +4046,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -12540,7 +12489,6 @@
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@ -12964,7 +12912,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -12981,7 +12928,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -12998,7 +12944,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -13015,7 +12960,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -13032,7 +12976,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -13049,7 +12992,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -13066,7 +13008,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -13083,7 +13024,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -13100,7 +13040,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -13117,7 +13056,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -13134,7 +13072,6 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -13151,7 +13088,6 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -13168,7 +13104,6 @@
"cpu": [ "cpu": [
"mips64el" "mips64el"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -13185,7 +13120,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -13202,7 +13136,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -13219,7 +13152,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -13236,7 +13168,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -13253,7 +13184,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -13270,7 +13200,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -13287,7 +13216,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -13304,7 +13232,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -13321,7 +13248,6 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -13338,7 +13264,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -13391,7 +13316,6 @@
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,

View file

@ -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 (
<Card>
<CardContent className="p-6">
<div className="space-y-4">
{[...Array(10)].map((_, i) => (
<div
key={i}
className="h-12 w-full animate-pulse rounded bg-muted"
/>
))}
</div>
</CardContent>
</Card>
);
}
export default function CoilReceiptsPage() {
const [data, setData] = useState<CoilReceiptRow[] | null>(null);
const [error, setError] = useState<string | null>(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 (
<div>
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="mb-2 text-3xl font-bold">Coil Activity Receipts</h1>
<p className="text-muted-foreground">
View coil material receipts for the selected date range
</p>
</div>
<DateRangePicker
from={dateRange.from}
to={dateRange.to}
onUpdate={setDateRange}
maxDays={31}
/>
</div>
{error ? (
<Card>
<CardContent className="p-6 text-center text-destructive">
Failed to load receipts data: {error}
</CardContent>
</Card>
) : loading || data === null ? (
<LoadingSkeleton />
) : (
<ReceiptsTable data={data} />
)}
</div>
);
}

View file

@ -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 (
<Card>
<CardContent className="p-6">
<div className="space-y-4">
{[...Array(10)].map((_, i) => (
<div
key={i}
className="h-12 w-full animate-pulse rounded bg-muted"
/>
))}
</div>
</CardContent>
</Card>
);
}
export default function CoilUsagePage() {
const [data, setData] = useState<CoilUsageRow[] | null>(null);
const [error, setError] = useState<string | null>(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 (
<div>
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="mb-2 text-3xl font-bold">Coil Activity Usage</h1>
<p className="text-muted-foreground">
View coil material usage for the selected date range
</p>
</div>
<DateRangePicker
from={dateRange.from}
to={dateRange.to}
onUpdate={setDateRange}
maxDays={31}
/>
</div>
{error ? (
<Card>
<CardContent className="p-6 text-center text-destructive">
Failed to load usage data: {error}
</CardContent>
</Card>
) : loading || data === null ? (
<LoadingSkeleton />
) : (
<UsageTable data={data} />
)}
</div>
);
}

View file

@ -1,6 +1,6 @@
import { Suspense } from 'react'; 'use client';
export const dynamic = 'force-dynamic'; import { useEffect, useState } from 'react';
import { import {
Package, Package,
ShoppingCart, ShoppingCart,
@ -16,45 +16,59 @@ import { SummaryCard } from '@/components/dashboard/summary-card';
import { QuickNavCard } from '@/components/dashboard/quick-nav-card'; import { QuickNavCard } from '@/components/dashboard/quick-nav-card';
import { RecentOrdersTable } from '@/components/dashboard/recent-orders-table'; import { RecentOrdersTable } from '@/components/dashboard/recent-orders-table';
import { RecentShipmentsTable } from '@/components/dashboard/recent-shipments-table'; import { RecentShipmentsTable } from '@/components/dashboard/recent-shipments-table';
import { import type {
getRecentOrders, DashboardOrder,
getRecentShipments, DashboardShipment,
getInventorySummary, InventorySummary,
} from '@/services/dashboard'; } from '@/services/dashboard';
import { getQuestSession, getActiveCompany } from '@/lib/permissions';
import { redirect } from 'next/navigation';
import { db } from '@/lib/db';
async function DashboardData() { type DashboardData = {
const session = await getQuestSession(); orders: DashboardOrder[];
const activeCompany = await getActiveCompany(); shipments: DashboardShipment[];
inventorySummary: InventorySummary;
unreadNotifications: number;
};
if (!session || !activeCompany) { function DashboardSkeleton() {
redirect('/select-company'); return (
} <div className="space-y-8">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Card key={i}>
<CardHeader className="space-y-2">
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
</CardHeader>
<CardContent>
<div className="h-8 w-16 animate-pulse rounded bg-muted" />
</CardContent>
</Card>
))}
</div>
<div className="grid gap-6 lg:grid-cols-2">
{[...Array(2)].map((_, i) => (
<Card key={i}>
<CardHeader>
<div className="h-6 w-32 animate-pulse rounded bg-muted" />
</CardHeader>
<CardContent>
<div className="space-y-2">
{[...Array(5)].map((_, j) => (
<div
key={j}
className="h-12 w-full animate-pulse rounded bg-muted"
/>
))}
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}
// Fetch dashboard data in parallel function DashboardContent({ data }: { data: DashboardData }) {
const [orders, shipments, inventorySummary, unreadNotifications] = const { orders, shipments, inventorySummary, unreadNotifications } = data;
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),
]);
return ( return (
<> <>
@ -176,51 +190,34 @@ async function DashboardData() {
); );
} }
function DashboardSkeleton() {
return (
<div className="space-y-8">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Card key={i}>
<CardHeader className="space-y-2">
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
</CardHeader>
<CardContent>
<div className="h-8 w-16 animate-pulse rounded bg-muted" />
</CardContent>
</Card>
))}
</div>
<div className="grid gap-6 lg:grid-cols-2">
{[...Array(2)].map((_, i) => (
<Card key={i}>
<CardHeader>
<div className="h-6 w-32 animate-pulse rounded bg-muted" />
</CardHeader>
<CardContent>
<div className="space-y-2">
{[...Array(5)].map((_, j) => (
<div
key={j}
className="h-12 w-full animate-pulse rounded bg-muted"
/>
))}
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}
export default function DashboardPage() { export default function DashboardPage() {
const [data, setData] = useState<DashboardData | null>(null);
const [error, setError] = useState<string | null>(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 ( return (
<div> <div>
<h1 className="mb-6 text-3xl font-bold">Dashboard</h1> <h1 className="mb-6 text-3xl font-bold">Dashboard</h1>
<Suspense fallback={<DashboardSkeleton />}> {error ? (
<DashboardData /> <Card>
</Suspense> <CardContent className="p-6 text-center text-destructive">
Failed to load dashboard: {error}
</CardContent>
</Card>
) : data === null ? (
<DashboardSkeleton />
) : (
<DashboardContent data={data} />
)}
</div> </div>
); );
} }

View file

@ -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 (
<div className="space-y-6">
{/* Header skeleton */}
<div className="flex items-center justify-between">
<div className="h-8 w-64 animate-pulse rounded bg-muted" />
<div className="h-10 w-36 animate-pulse rounded bg-muted" />
</div>
{/* Address cards skeleton */}
<div className="grid gap-4 md:grid-cols-2">
{[...Array(2)].map((_, i) => (
<Card key={i}>
<CardContent className="p-6">
<div className="space-y-2">
<div className="h-4 w-20 animate-pulse rounded bg-muted" />
<div className="h-4 w-48 animate-pulse rounded bg-muted" />
<div className="h-4 w-40 animate-pulse rounded bg-muted" />
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
</div>
</CardContent>
</Card>
))}
</div>
{/* Order info skeleton */}
<Card>
<CardContent className="p-6">
<div className="grid grid-cols-3 gap-4">
{[...Array(9)].map((_, i) => (
<div key={i} className="space-y-1">
<div className="h-3 w-16 animate-pulse rounded bg-muted" />
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
</div>
))}
</div>
</CardContent>
</Card>
{/* Table skeleton */}
<Card>
<CardContent className="p-6">
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<div
key={i}
className="h-12 w-full animate-pulse rounded bg-muted"
/>
))}
</div>
</CardContent>
</Card>
</div>
);
}
export default function OrderDetailPage() {
const params = useParams();
const orderNum = params.orderNum as string;
const [data, setData] = useState<OrderAcknowledgementData | null>(null);
const [error, setError] = useState<string | null>(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 (
<div>
{/* Back link */}
<Link
href="/orders"
className="mb-4 inline-flex items-center text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="mr-1 h-4 w-4" />
Back to Orders
</Link>
{error ? (
<Card>
<CardContent className="p-6 text-center text-destructive">
Failed to load order: {error}
</CardContent>
</Card>
) : data === null ? (
<DetailSkeleton />
) : (
<>
{/* Page header */}
<div className="mb-6 flex items-center justify-between">
<h1 className="text-3xl font-bold">
Order #{data.header.order_num}
</h1>
<Button onClick={handleDownloadPdf} disabled={pdfLoading}>
{pdfLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
Download PDF
</Button>
</div>
<OrderAcknowledgementDetail data={data} />
</>
)}
</div>
);
}

View file

@ -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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="h-8 w-64 animate-pulse rounded bg-muted" />
<div className="h-10 w-36 animate-pulse rounded bg-muted" />
</div>
<div className="grid gap-4 md:grid-cols-2">
{[...Array(2)].map((_, i) => (
<Card key={i}>
<CardContent className="p-6">
<div className="space-y-2">
<div className="h-4 w-20 animate-pulse rounded bg-muted" />
<div className="h-4 w-48 animate-pulse rounded bg-muted" />
<div className="h-4 w-40 animate-pulse rounded bg-muted" />
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
</div>
</CardContent>
</Card>
))}
</div>
<Card>
<CardContent className="p-6">
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<div
key={i}
className="h-12 w-full animate-pulse rounded bg-muted"
/>
))}
</div>
</CardContent>
</Card>
</div>
);
}
export default function BOLDetailPage() {
const params = useParams();
const bol = params.bol as string;
const [data, setData] = useState<BOLData | null>(null);
const [error, setError] = useState<string | null>(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 (
<div>
<Link
href="/shipments"
className="mb-4 inline-flex items-center text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="mr-1 h-4 w-4" />
Back to Shipments
</Link>
{error ? (
<Card>
<CardContent className="p-6 text-center text-destructive">
Failed to load BOL: {error}
</CardContent>
</Card>
) : data === null ? (
<DetailSkeleton />
) : (
<>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-3xl font-bold">
BOL #{data.header.bol_num}
</h1>
<Button onClick={handleDownloadPdf} disabled={pdfLoading}>
{pdfLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
Download PDF
</Button>
</div>
<BOLDetailView data={data} />
</>
)}
</div>
);
}

View file

@ -1,30 +1,10 @@
import { Suspense } from 'react'; 'use client';
import { redirect } from 'next/navigation';
import { getTop100Shipments } from '@/services/shipments'; import { useEffect, useState } from 'react';
import { getQuestSession, getActiveCompany } from '@/lib/permissions'; import type { ShipmentRow } from '@/services/shipments';
import { ShipmentsTable } from '@/components/shipments/shipments-table'; import { ShipmentsTable } from '@/components/shipments/shipments-table';
import { Card, CardContent } from '@/components/ui/card'; 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 <ShipmentsTable data={shipments} />;
}
function LoadingSkeleton() { function LoadingSkeleton() {
return ( return (
<Card> <Card>
@ -43,6 +23,19 @@ function LoadingSkeleton() {
} }
export default function ShipmentsPage() { export default function ShipmentsPage() {
const [shipments, setShipments] = useState<ShipmentRow[] | null>(null);
const [error, setError] = useState<string | null>(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 ( return (
<div> <div>
<h1 className="mb-2 text-3xl font-bold">Shipments</h1> <h1 className="mb-2 text-3xl font-bold">Shipments</h1>
@ -50,9 +43,17 @@ export default function ShipmentsPage() {
View your most recent shipments and BOL details View your most recent shipments and BOL details
</p> </p>
<Suspense fallback={<LoadingSkeleton />}> {error ? (
<ShipmentsData /> <Card>
</Suspense> <CardContent className="p-6 text-center text-destructive">
Failed to load shipments: {error}
</CardContent>
</Card>
) : shipments === null ? (
<LoadingSkeleton />
) : (
<ShipmentsTable data={shipments} />
)}
</div> </div>
); );
} }

View file

@ -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 });
}
}

View file

@ -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 });
}
}

View file

@ -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 });
}
}

View file

@ -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 });
}
}

View file

@ -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 });
}
}

View file

@ -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 });
}
}

View file

@ -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 }
);
}
}

View file

@ -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 });
}
}

View file

@ -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 (
<Card>
<CardHeader>
<CardTitle>Receipts Data</CardTitle>
<CardDescription>Showing {sortedData.length} results</CardDescription>
</CardHeader>
<CardContent>
<div className="mb-4 flex items-center gap-4">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search part#, lot#, plant, supplier..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
<Button onClick={handleExportCSV} variant="outline">
<Download className="mr-2 h-4 w-4" />
Export CSV
</Button>
</div>
<div className="overflow-hidden rounded-md border">
<Table>
<thead>
<tr className="bg-teal-700 text-white">
<SortableTableHead
sortKey="date_received"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Date Rec
</SortableTableHead>
<SortableTableHead
sortKey="vorteq_part_num"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Vorteq Part#
</SortableTableHead>
<SortableTableHead
sortKey="customer_part_num"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Cust Part#
</SortableTableHead>
<SortableTableHead
sortKey="part_desc"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Part Description
</SortableTableHead>
<SortableTableHead
sortKey="manufacturer_lot_num"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Lot#
</SortableTableHead>
<SortableTableHead
sortKey="alloy"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Mfg Lot#
</SortableTableHead>
<SortableTableHead
sortKey="plant_name"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Plant Name
</SortableTableHead>
<SortableTableHead
sortKey="packing_slip"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Packing Slip
</SortableTableHead>
<SortableTableHead
sortKey="supplier_name"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Supplier Name
</SortableTableHead>
<SortableTableHead
sortKey="mill_order_num"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Mill Order #
</SortableTableHead>
</tr>
</thead>
<TableBody>
{sortedData.length === 0 ? (
<TableRow>
<TableCell
colSpan={10}
className="text-center text-muted-foreground"
>
No receipts found
</TableCell>
</TableRow>
) : (
sortedData.map((row, i) => (
<TableRow
key={i}
className={i % 2 === 0 ? 'bg-muted/30' : ''}
>
<TableCell>
{row.date_received
? formatDate(new Date(row.date_received))
: '-'}
</TableCell>
<TableCell>{row.vorteq_part_num || '-'}</TableCell>
<TableCell>{row.customer_part_num || '-'}</TableCell>
<TableCell className="max-w-xs">
{row.part_desc || '-'}
</TableCell>
<TableCell>{row.manufacturer_lot_num || '-'}</TableCell>
<TableCell>{row.alloy || '-'}</TableCell>
<TableCell>{row.plant_name || '-'}</TableCell>
<TableCell>{row.packing_slip || '-'}</TableCell>
<TableCell>{row.supplier_name || '-'}</TableCell>
<TableCell>{row.mill_order_num || '-'}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<div className="mt-4 text-sm text-muted-foreground">
Showing {sortedData.length} of {data.length} receipts
</div>
</CardContent>
</Card>
);
}

View file

@ -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 (
<Card>
<CardHeader>
<CardTitle>Usage Data</CardTitle>
<CardDescription>Showing {sortedData.length} results</CardDescription>
</CardHeader>
<CardContent>
<div className="mb-4 flex items-center gap-4">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search part numbers, descriptions, lots, plants, jobs..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
<Button onClick={handleExportCSV} variant="outline">
<Download className="mr-2 h-4 w-4" />
Export CSV
</Button>
</div>
<div className="overflow-hidden rounded-md border">
<Table>
<thead>
<tr className="bg-teal-700 text-white">
<SortableTableHead
sortKey="date_used"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Date Used
</SortableTableHead>
<SortableTableHead
sortKey="vorteq_part_num"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Vorteq Part#
</SortableTableHead>
<SortableTableHead
sortKey="customer_part_num"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Cust Part#
</SortableTableHead>
<SortableTableHead
sortKey="part_desc"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Part Description
</SortableTableHead>
<SortableTableHead
sortKey="lot_num"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Lot#
</SortableTableHead>
<SortableTableHead
sortKey="mfg_lot"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Mfg Lot#
</SortableTableHead>
<SortableTableHead
sortKey="weight"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
className="text-right"
>
Weight
</SortableTableHead>
<SortableTableHead
sortKey="plant_name"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Plant Name
</SortableTableHead>
<SortableTableHead
sortKey="job_num"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Job#
</SortableTableHead>
<SortableTableHead
sortKey="customer_po"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Cust PO#
</SortableTableHead>
<SortableTableHead
sortKey="on_hand_qty"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
className="text-right"
>
Qty LB
</SortableTableHead>
</tr>
</thead>
<TableBody>
{sortedData.length === 0 ? (
<TableRow>
<TableCell
colSpan={11}
className="text-center text-muted-foreground"
>
No usage data found
</TableCell>
</TableRow>
) : (
sortedData.map((row, i) => (
<TableRow
key={i}
className={i % 2 === 0 ? 'bg-muted/30' : ''}
>
<TableCell>
{row.date_used ? formatDate(new Date(row.date_used)) : '-'}
</TableCell>
<TableCell>{row.vorteq_part_num || '-'}</TableCell>
<TableCell>{row.customer_part_num || '-'}</TableCell>
<TableCell>{row.part_desc || '-'}</TableCell>
<TableCell>{row.lot_num || '-'}</TableCell>
<TableCell>{row.mfg_lot || '-'}</TableCell>
<TableCell className="text-right">
{row.weight != null
? row.weight.toLocaleString()
: '-'}
</TableCell>
<TableCell>{row.plant_name || '-'}</TableCell>
<TableCell>{row.job_num || '-'}</TableCell>
<TableCell>{row.customer_po || '-'}</TableCell>
<TableCell className="text-right">
{row.on_hand_qty != null
? row.on_hand_qty.toLocaleString()
: '-'}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<div className="mt-4 text-sm text-muted-foreground">
Showing {sortedData.length} of {data.length} records
</div>
</CardContent>
</Card>
);
}

View file

@ -30,7 +30,7 @@ export function RecentShipmentsTable({ shipments }: RecentShipmentsTableProps) {
<TableHead>BOL #</TableHead> <TableHead>BOL #</TableHead>
<TableHead>Ship To</TableHead> <TableHead>Ship To</TableHead>
<TableHead>Ship Date</TableHead> <TableHead>Ship Date</TableHead>
<TableHead>Carrier</TableHead> <TableHead>Plant</TableHead>
<TableHead className="text-right">Weight</TableHead> <TableHead className="text-right">Weight</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@ -49,7 +49,7 @@ export function RecentShipmentsTable({ shipments }: RecentShipmentsTableProps) {
{shipment.ship_to} {shipment.ship_to}
</TableCell> </TableCell>
<TableCell>{formatDate(shipment.ship_date)}</TableCell> <TableCell>{formatDate(shipment.ship_date)}</TableCell>
<TableCell>{shipment.carrier}</TableCell> <TableCell>{shipment.plant}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
{shipment.weight.toLocaleString()} lbs {shipment.weight.toLocaleString()} lbs
</TableCell> </TableCell>

View file

@ -79,7 +79,8 @@ export function PortalHeader({
body: JSON.stringify({ companyId }), body: JSON.stringify({ companyId }),
}); });
if (response.ok) { if (response.ok) {
window.location.href = '/dashboard'; // Refresh current page so data reloads with new company
window.location.reload();
} }
} catch (error) { } catch (error) {
console.error('Error switching company:', error); console.error('Error switching company:', error);

View file

@ -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 */}
<TableRow className="border-t">
<TableCell className="align-top font-medium">
{line.order_line}
</TableCell>
<TableCell className="align-top" colSpan={2}>
<div>
<div className="font-medium">{line.part_num}</div>
<div className="text-sm text-muted-foreground">
{line.part_description}
</div>
</div>
</TableCell>
<TableCell className="align-top text-center">
{line.revision || '-'}
</TableCell>
<TableCell className="align-top text-right">
{formatQty(line.order_qty, line.unit_of_measure)}
</TableCell>
<TableCell className="align-top text-right">
{formatUnitPrice(line.unit_price, line.unit_of_measure)}
</TableCell>
<TableCell className="align-top text-right font-medium">
{formatCurrency(line.extended_price)}
</TableCell>
</TableRow>
{/* Customer part row */}
{line.customer_part && (
<TableRow>
<TableCell />
<TableCell colSpan={6} className="py-1">
<div className="text-sm">
<span className="text-muted-foreground">Our Part: </span>
<span>
{line.customer_part}
{line.customer_part_desc
? ` / ${line.customer_part_desc}`
: ''}
</span>
</div>
{paintCodes && (
<div className="text-sm text-muted-foreground">{paintCodes}</div>
)}
</TableCell>
</TableRow>
)}
{/* Releases sub-table */}
{line.releases.length > 0 && (
<TableRow>
<TableCell />
<TableCell colSpan={6} className="py-1">
<div className="ml-2">
<div className="flex gap-8 text-xs font-semibold text-muted-foreground">
<span className="w-10">Rel</span>
<span className="w-24">Date</span>
<span className="w-28 text-right">Quantity</span>
<span>Job Number</span>
</div>
{line.releases.map((rel) => (
<div
key={rel.release_num}
className="flex gap-8 text-sm"
>
<span className="w-10">{rel.release_num}</span>
<span className="w-24">{formatDate(rel.need_by_date)}</span>
<span className="w-28 text-right">
{rel.quantity.toLocaleString(undefined, {
minimumFractionDigits: 2,
})}
</span>
<span>{rel.job_num || '-'}</span>
</div>
))}
</div>
</TableCell>
</TableRow>
)}
{/* Comment row */}
{line.comment && (
<TableRow>
<TableCell />
<TableCell colSpan={6} className="py-2">
<div className="rounded border border-dashed p-2 text-sm">
{line.comment}
</div>
</TableCell>
</TableRow>
)}
</>
);
}
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 (
<div className="space-y-6">
{/* Order type + number badge */}
<div className="flex items-center gap-3">
<Badge variant="outline">{header.order_type}</Badge>
<span className="text-sm text-muted-foreground">
Sales Order: {header.order_num}
</span>
</div>
{/* Sold To / Ship To */}
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardContent className="p-4">
<div className="mb-2 text-sm font-semibold">Sold To:</div>
{soldToLines.map((line, i) => (
<div key={i} className="text-sm">
{line}
</div>
))}
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="mb-2 text-sm font-semibold">Ship To:</div>
{shipToLines.map((line, i) => (
<div key={i} className="text-sm">
{line}
</div>
))}
</CardContent>
</Card>
</div>
{/* Order Info Bar */}
<Card>
<CardContent className="p-4">
<div className="grid grid-cols-3 gap-x-6 gap-y-3 md:grid-cols-3">
<div>
<div className="text-xs font-medium text-muted-foreground">
Order Date
</div>
<div className="text-sm">{formatDate(header.order_date)}</div>
</div>
<div>
<div className="text-xs font-medium text-muted-foreground">
PO Number
</div>
<div className="text-sm font-medium">{header.po_num}</div>
</div>
<div>
<div className="text-xs font-medium text-muted-foreground">
FOB
</div>
<div className="text-sm">{header.fob || '-'}</div>
</div>
<div>
<div className="text-xs font-medium text-muted-foreground">
Need By
</div>
<div className="text-sm">{formatDate(header.need_by_date)}</div>
</div>
<div>
<div className="text-xs font-medium text-muted-foreground">
Sales Person
</div>
<div className="text-sm">{header.sales_person}</div>
</div>
<div>
<div className="text-xs font-medium text-muted-foreground">
Currency
</div>
<div className="text-sm">{header.currency}</div>
</div>
<div>
<div className="text-xs font-medium text-muted-foreground">
Payment Terms
</div>
<div className="text-sm">{header.payment_terms || '-'}</div>
</div>
<div>
<div className="text-xs font-medium text-muted-foreground">
Ship Via
</div>
<div className="text-sm">{header.ship_via || '-'}</div>
</div>
</div>
</CardContent>
</Card>
{/* Order comment (if present) */}
{header.order_comment && (
<Card>
<CardContent className="p-4">
<div className="text-xs font-medium text-muted-foreground mb-1">
Order Notes
</div>
<div className="text-sm">{header.order_comment}</div>
</CardContent>
</Card>
)}
{/* Line Items Table */}
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-slate-700 text-white hover:bg-slate-700">
<TableHead className="w-16 text-white">Line</TableHead>
<TableHead className="text-white" colSpan={2}>
Part Number/Description
</TableHead>
<TableHead className="w-16 text-center text-white">
Rev
</TableHead>
<TableHead className="w-32 text-right text-white">
Order Qty
</TableHead>
<TableHead className="w-28 text-right text-white">
Unit Price
</TableHead>
<TableHead className="w-32 text-right text-white">
Ext. Price
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line) => (
<LineItemRow key={line.order_line} line={line} />
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* Totals */}
<div className="flex justify-end">
<div className="w-80 space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Line Total:</span>
<span className="font-medium">{formatCurrency(lineTotalPrice)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">
Line Miscellaneous Charges:
</span>
<span>{formatCurrency(lineMiscCharges)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">
Order Miscellaneous Charges:
</span>
<span>{formatCurrency(data.order_misc_charges)}</span>
</div>
<div className="flex justify-between border-t pt-1 font-bold">
<span>Order Total:</span>
<span>
{data.order_total_qty.toLocaleString()}
{lines[0]?.unit_of_measure || 'LB'}{' '}
{formatCurrency(data.order_total_price)}
</span>
</div>
</div>
</div>
</div>
);
}

View file

@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import Link from 'next/link';
import type { OrderRow } from '@/services/orders'; import type { OrderRow } from '@/services/orders';
import { import {
Card, Card,
@ -17,7 +18,7 @@ import {
TableCell, TableCell,
TableRow, TableRow,
} from '@/components/ui/table'; } from '@/components/ui/table';
import { Download, Search } from 'lucide-react'; import { Download, FileText, Search } from 'lucide-react';
import { import {
SortableTableHead, SortableTableHead,
useSortableTable, useSortableTable,
@ -183,13 +184,16 @@ export function OrdersTable({ data }: Props) {
> >
Job # Job #
</SortableTableHead> </SortableTableHead>
<th className="px-4 py-3 text-left text-sm font-medium">
<span className="sr-only">PDF</span>
</th>
</tr> </tr>
</thead> </thead>
<TableBody> <TableBody>
{sortedData.length === 0 ? ( {sortedData.length === 0 ? (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={9} colSpan={10}
className="text-center text-muted-foreground" className="text-center text-muted-foreground"
> >
No orders found No orders found
@ -199,7 +203,12 @@ export function OrdersTable({ data }: Props) {
sortedData.map((row, i) => ( sortedData.map((row, i) => (
<TableRow key={i} className={i % 2 === 0 ? 'bg-muted/30' : ''}> <TableRow key={i} className={i % 2 === 0 ? 'bg-muted/30' : ''}>
<TableCell className="font-medium"> <TableCell className="font-medium">
{row.order_num} <Link
href={`/orders/${row.order_num}`}
className="text-blue-600 hover:underline"
>
{row.order_num}
</Link>
</TableCell> </TableCell>
<TableCell>{row.customer_po || '-'}</TableCell> <TableCell>{row.customer_po || '-'}</TableCell>
<TableCell className="font-mono"> <TableCell className="font-mono">
@ -219,6 +228,17 @@ export function OrdersTable({ data }: Props) {
: '-'} : '-'}
</TableCell> </TableCell>
<TableCell>{row.job_num || '-'}</TableCell> <TableCell>{row.job_num || '-'}</TableCell>
<TableCell>
<a
href={`/api/orders/${row.order_num}/pdf`}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground"
title="Download Order Acknowledgement PDF"
>
<FileText className="h-4 w-4" />
</a>
</TableCell>
</TableRow> </TableRow>
)) ))
)} )}

View file

@ -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 (
<div className="space-y-6">
{/* Order type badges */}
<div className="flex items-center gap-2">
<Badge variant="secondary">Pack #{header.pack_num}</Badge>
<span className="text-muted-foreground">
{header.customer_name}
</span>
</div>
{/* Ship From / Ship To */}
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardContent className="p-6">
<p className="mb-2 text-sm font-semibold text-muted-foreground">
Ship From:
</p>
<p className="font-medium">{header.plant_name}</p>
{shipFromLines.map((line, i) => (
<p key={i} className="text-sm">
{line}
</p>
))}
</CardContent>
</Card>
<Card className="border-teal-200 bg-teal-50/30">
<CardContent className="p-6">
<p className="mb-2 text-sm font-semibold text-muted-foreground">
Ship To:
</p>
<p className="font-medium">{header.ship_to_name}</p>
{shipToLines.map((line, i) => (
<p key={i} className="text-sm">
{line}
</p>
))}
</CardContent>
</Card>
</div>
{/* Shipment Info */}
<Card>
<CardContent className="grid grid-cols-2 gap-4 p-6 md:grid-cols-4">
<div>
<p className="text-sm font-semibold text-teal-700">Ship Date</p>
<p className="text-sm">{formatDate(header.ship_date)}</p>
</div>
<div>
<p className="text-sm font-semibold text-teal-700">Ship Via</p>
<p className="text-sm">{header.ship_via || '-'}</p>
</div>
<div>
<p className="text-sm font-semibold text-teal-700">PO Number(s)</p>
<p className="text-sm">{poNumbers.join(', ') || '-'}</p>
</div>
<div>
<p className="text-sm font-semibold text-teal-700">Total Weight</p>
<p className="text-sm">
{header.total_weight.toLocaleString()} lbs
</p>
</div>
</CardContent>
</Card>
{/* Line Items */}
<div className="overflow-hidden rounded-md border">
<Table>
<TableHeader>
<TableRow className="bg-teal-700 text-white hover:bg-teal-700">
<TableHead className="text-white">Line</TableHead>
<TableHead className="text-white">Part Number/Description</TableHead>
<TableHead className="text-white">Rev</TableHead>
<TableHead className="text-white">Lot #</TableHead>
<TableHead className="text-right text-white">Qty Shipped</TableHead>
<TableHead className="text-white">UOM</TableHead>
<TableHead className="text-right text-white">Weight (lbs)</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line, i) => (
<TableRow key={i} className={i % 2 === 0 ? 'bg-muted/30' : ''}>
<TableCell className="font-medium">{line.pack_line}</TableCell>
<TableCell>
<div>
<span className="font-medium">{line.part_num}</span>
{line.part_description && (
<p className="text-sm text-muted-foreground">
{line.part_description}
</p>
)}
{line.cust_part_num && (
<p className="mt-1 text-xs text-muted-foreground">
Cust Part: {line.cust_part_num}
</p>
)}
{line.order_num > 0 && (
<p className="text-xs text-muted-foreground">
Order: {line.order_num} / Line: {line.order_line}
{line.po_num ? ` / PO: ${line.po_num}` : ''}
</p>
)}
</div>
</TableCell>
<TableCell>{line.revision || '-'}</TableCell>
<TableCell>{line.lot_num || '-'}</TableCell>
<TableCell className="text-right">
{line.ship_qty.toLocaleString()}
</TableCell>
<TableCell>{line.uom || '-'}</TableCell>
<TableCell className="text-right">
{line.net_weight > 0
? line.net_weight.toLocaleString()
: '-'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Totals */}
<div className="flex justify-end">
<div className="w-72 space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Total Lines:</span>
<span>{header.total_lines}</span>
</div>
<div className="flex justify-between border-t pt-1 text-base font-bold">
<span>Total Weight:</span>
<span>{header.total_weight.toLocaleString()} lbs</span>
</div>
</div>
</div>
</div>
);
}

View file

@ -18,7 +18,7 @@ import {
TableCell, TableCell,
TableRow, TableRow,
} from '@/components/ui/table'; } from '@/components/ui/table';
import { Download, Search } from 'lucide-react'; import { Download, FileText, Search } from 'lucide-react';
import { import {
SortableTableHead, SortableTableHead,
useSortableTable, useSortableTable,
@ -35,10 +35,9 @@ export function ShipmentsTable({ data }: Props) {
const filteredData = data.filter((row) => { const filteredData = data.filter((row) => {
const s = searchTerm.toLowerCase(); const s = searchTerm.toLowerCase();
return ( return (
row.pack_num.toLowerCase().includes(s) || row.bol_num.toLowerCase().includes(s) ||
row.ship_to.toLowerCase().includes(s) || row.ship_to.toLowerCase().includes(s) ||
row.carrier.toLowerCase().includes(s) || row.plant.toLowerCase().includes(s)
row.tracking_num.toLowerCase().includes(s)
); );
}); });
@ -50,20 +49,18 @@ export function ShipmentsTable({ data }: Props) {
'BOL #', 'BOL #',
'Ship Date', 'Ship Date',
'Ship To', 'Ship To',
'Carrier', 'Plant',
'Weight', 'Weight (lbs)',
'Tracking #',
]; ];
const rows = sortedData.map((row) => [ const rows = sortedData.map((row) => [
row.pack_num, row.bol_num,
row.ship_date row.ship_date
? new Date(row.ship_date).toLocaleDateString() ? new Date(row.ship_date).toLocaleDateString()
: '', : '',
row.ship_to.replace(/\n/g, ', '), row.ship_to.replace(/\n/g, ', '),
row.carrier, row.plant,
row.weight, row.weight,
row.tracking_num,
]); ]);
const csvContent = [headers, ...rows] const csvContent = [headers, ...rows]
@ -90,7 +87,7 @@ export function ShipmentsTable({ data }: Props) {
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
placeholder="Search BOL #, ship to, carrier, or tracking #..." placeholder="Search BOL #, ship to, or plant..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8" className="pl-8"
@ -107,7 +104,7 @@ export function ShipmentsTable({ data }: Props) {
<thead> <thead>
<tr className="bg-teal-700 text-white"> <tr className="bg-teal-700 text-white">
<SortableTableHead <SortableTableHead
sortKey="pack_num" sortKey="bol_num"
currentSortKey={sortKey} currentSortKey={sortKey}
currentDirection={sortDirection} currentDirection={sortDirection}
onSort={handleSort} onSort={handleSort}
@ -131,12 +128,12 @@ export function ShipmentsTable({ data }: Props) {
Ship To Ship To
</SortableTableHead> </SortableTableHead>
<SortableTableHead <SortableTableHead
sortKey="carrier" sortKey="plant"
currentSortKey={sortKey} currentSortKey={sortKey}
currentDirection={sortDirection} currentDirection={sortDirection}
onSort={handleSort} onSort={handleSort}
> >
Carrier Plant
</SortableTableHead> </SortableTableHead>
<SortableTableHead <SortableTableHead
sortKey="weight" sortKey="weight"
@ -147,14 +144,7 @@ export function ShipmentsTable({ data }: Props) {
> >
Weight Weight
</SortableTableHead> </SortableTableHead>
<SortableTableHead <th className="w-10 px-2 py-3"></th>
sortKey="tracking_num"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Tracking #
</SortableTableHead>
</tr> </tr>
</thead> </thead>
<TableBody> <TableBody>
@ -175,10 +165,10 @@ export function ShipmentsTable({ data }: Props) {
> >
<TableCell> <TableCell>
<Link <Link
href={`/shipments/${row.pack_num}`} href={`/shipments/${row.bol_num}`}
className="font-medium text-blue-600 hover:underline" className="font-medium text-blue-600 hover:underline"
> >
{row.pack_num} {row.bol_num}
</Link> </Link>
</TableCell> </TableCell>
<TableCell> <TableCell>
@ -187,13 +177,23 @@ export function ShipmentsTable({ data }: Props) {
<TableCell className="max-w-xs whitespace-pre-line"> <TableCell className="max-w-xs whitespace-pre-line">
{row.ship_to || '-'} {row.ship_to || '-'}
</TableCell> </TableCell>
<TableCell>{row.carrier || '-'}</TableCell> <TableCell>{row.plant || '-'}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
{row.weight {row.weight
? `${row.weight.toLocaleString()} lbs` ? `${row.weight.toLocaleString()} lbs`
: '-'} : '-'}
</TableCell> </TableCell>
<TableCell>{row.tracking_num || '-'}</TableCell> <TableCell className="w-10 px-2">
<a
href={`/api/shipments/${row.bol_num}/pdf`}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground"
title="Download BOL PDF"
>
<FileText className="h-4 w-4" />
</a>
</TableCell>
</TableRow> </TableRow>
)) ))
)} )}

View file

@ -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<typeof DayPicker>;
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn('p-3', className)}
classNames={{
months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
month: 'space-y-4',
month_caption: 'flex justify-center pt-1 relative items-center',
caption_label: 'text-sm font-medium',
nav: 'space-x-1 flex items-center',
button_previous: cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute left-1'
),
button_next: cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute right-1'
),
month_grid: 'w-full border-collapse space-y-1',
weekdays: 'flex',
weekday: 'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]',
week: 'flex w-full mt-2',
day: cn(
'relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md',
props.mode === 'range'
? '[&:has(>.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 <Icon className="h-4 w-4" />;
},
}}
{...props}
/>
);
}
Calendar.displayName = 'Calendar';
export { Calendar };

View file

@ -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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
'w-[280px] justify-start text-left font-normal',
!from && 'text-muted-foreground',
className
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{from ? (
to ? (
<>
{format(from, 'LLL dd, y')} {format(to, 'LLL dd, y')}
</>
) : (
format(from, 'LLL dd, y')
)
) : (
<span>Pick a date range</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="range"
defaultMonth={from}
selected={{ from, to }}
onSelect={handleSelect}
numberOfMonths={2}
/>
<div className="border-t px-4 py-2 text-xs text-muted-foreground">
Max range: {maxDays} days
</div>
</PopoverContent>
</Popover>
);
}

View file

@ -63,7 +63,7 @@ const config: EpicorConfig = {
encrypt: false, encrypt: false,
trustServerCertificate: true, trustServerCertificate: true,
connectTimeout: 30000, // 30 seconds 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<T = unknown>(
} }
const result: IProcedureResult<T> = await request.execute(procedureName); const result: IProcedureResult<T> = 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) { } catch (error) {
if (error instanceof Error) { if (error instanceof Error) {
if (error.message.includes('timeout')) { if (error.message.includes('timeout')) {
@ -168,7 +170,7 @@ export async function execQuery<T = unknown>(
} }
const result: IResult<T> = await request.query(query); const result: IResult<T> = await request.query(query);
return result.recordset as T; return [...result.recordset] as T;
} catch (error) { } catch (error) {
if (error instanceof Error) { if (error instanceof Error) {
if (error.message.includes('timeout')) { if (error.message.includes('timeout')) {

View file

@ -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('<br/>');
}
function fmtQty(qty: number): string {
return qty.toLocaleString('en-US', { minimumFractionDigits: 2 });
}
function renderLineHtml(line: BOLLine): string {
const customerPartLine =
line.cust_part_num
? `<div style="margin-left: 20px; font-size: 9px; margin-top: 4px;">
Customer Part: ${line.cust_part_num}
</div>`
: '';
const orderRefLine =
line.order_num && line.order_line
? `<div style="margin-left: 20px; font-size: 9px;">
Order ${line.order_num} / Line ${line.order_line}${line.po_num ? ` / PO ${line.po_num}` : ''}
</div>`
: '';
return `
<tr style="border-top: 1px solid #ccc;">
<td style="padding: 4px 6px; vertical-align: top; text-align: center;">${line.pack_line}</td>
<td style="padding: 4px 6px; vertical-align: top;" colspan="2">
<div><strong>${line.part_num}</strong></div>
<div style="font-size: 9px;">${line.part_description}</div>
${customerPartLine}
${orderRefLine}
</td>
<td style="padding: 4px 6px; vertical-align: top; text-align: center;">${line.revision || ''}</td>
<td style="padding: 4px 6px; vertical-align: top; text-align: center;">${line.lot_num || ''}</td>
<td style="padding: 4px 6px; vertical-align: top; text-align: right;">${fmtQty(line.ship_qty)}</td>
<td style="padding: 4px 6px; vertical-align: top; text-align: center;">${line.uom}</td>
<td style="padding: 4px 6px; vertical-align: top; text-align: right;"><strong>${fmtQty(line.net_weight)}</strong></td>
</tr>
`;
}
// Vorteq Q logo as inline SVG matching the brand
const VORTEQ_LOGO_SVG = `
<svg width="60" height="60" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="45" r="35" fill="none" stroke="#8BC34A" stroke-width="8"/>
<line x1="65" y1="65" x2="90" y2="90" stroke="#8BC34A" stroke-width="8" stroke-linecap="round"/>
</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 `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: Arial, Helvetica, sans-serif;
font-size: 10px;
color: #333;
line-height: 1.4;
}
table { border-collapse: collapse; }
.accent { color: #0d9488; }
.accent-bg { background: #0d9488; color: white; }
</style>
</head>
<body>
<!-- Header -->
<table style="width: 100%; margin-bottom: 10px;">
<tr>
<td style="width: 80px; vertical-align: top;">
${VORTEQ_LOGO_SVG}
<div style="font-weight: bold; font-size: 14px; letter-spacing: 3px; margin-top: 2px;">VORTEQ</div>
</td>
<td style="vertical-align: top; padding-left: 10px;">
<div>11440 W Addison St</div>
<div>Franklin Park, IL 60131</div>
</td>
<td style="text-align: right; vertical-align: top;">
<div style="font-size: 16px; font-weight: bold; color: #0d9488;">Bill of Lading</div>
<div>Phone: 847-455-7200</div>
<div>Fax: 847-455-7608</div>
</td>
</tr>
</table>
<!-- BOL Info -->
<div style="text-align: right; margin-bottom: 8px;">
<div style="font-size: 11px;"><strong>BOL #:</strong> ${header.bol_num}</div>
<div style="font-size: 11px;"><strong>Pack #:</strong> ${header.pack_num}</div>
<div style="font-size: 11px;"><strong>Ship Date:</strong> ${fmtDate(header.ship_date)}</div>
</div>
<!-- Ship From / Ship To -->
<table style="width: 100%; border: 1px solid #999; margin-bottom: 12px;">
<tr>
<td style="width: 50%; padding: 8px; border-right: 1px solid #999; vertical-align: top;">
<div style="font-weight: bold; margin-bottom: 4px; color: #0d9488;">Ship From:</div>
<div style="padding-left: 8px;">${shipFrom}</div>
</td>
<td style="width: 50%; padding: 8px; vertical-align: top;">
<div style="font-weight: bold; margin-bottom: 4px; color: #0d9488;">Ship To:</div>
<div style="padding-left: 8px;">${shipTo}</div>
</td>
</tr>
</table>
<!-- Shipment Info -->
<table style="width: 100%; border: 1px solid #999; margin-bottom: 16px;">
<tr>
<td style="padding: 8px; width: 50%; vertical-align: top;">
<div><strong>Ship Via:</strong> ${header.ship_via || '-'}</div>
</td>
<td style="padding: 8px; width: 50%; vertical-align: top;">
<div><strong>Customer:</strong> ${header.customer_name}</div>
</td>
</tr>
</table>
<!-- Line Items Table -->
<table style="width: 100%; border: 1px solid #999;">
<thead>
<tr style="background: #f0f0f0; border-bottom: 1px solid #999;">
<th style="padding: 4px 6px; text-align: center; width: 40px; border-right: 1px solid #ddd;">Line</th>
<th style="padding: 4px 6px; text-align: left;" colspan="2">Part Number/Description</th>
<th style="padding: 4px 6px; text-align: center; width: 40px; border-left: 1px solid #ddd;">Rev</th>
<th style="padding: 4px 6px; text-align: center; width: 80px; border-left: 1px solid #ddd;">Lot #</th>
<th style="padding: 4px 6px; text-align: right; width: 80px; border-left: 1px solid #ddd;">Qty Shipped</th>
<th style="padding: 4px 6px; text-align: center; width: 50px; border-left: 1px solid #ddd;">UOM</th>
<th style="padding: 4px 6px; text-align: right; width: 80px; border-left: 1px solid #ddd;">Weight</th>
</tr>
</thead>
<tbody>
${lineRows}
</tbody>
</table>
<!-- Totals -->
<table style="width: 300px; margin-left: auto; margin-top: 12px; font-size: 10px;">
<tr style="font-weight: bold;">
<td style="text-align: right; padding: 4px 8px; border: 1px solid #999; background: #f0f0f0;">Total Lines:</td>
<td style="text-align: right; padding: 4px 4px; border: 1px solid #999;">
${header.total_lines}
</td>
</tr>
<tr style="font-weight: bold;">
<td style="text-align: right; padding: 4px 8px; border: 1px solid #999; background: #f0f0f0;">Total Weight:</td>
<td style="text-align: right; padding: 4px 4px; border: 1px solid #999;">
${fmtQty(header.total_weight)} lbs
</td>
</tr>
</table>
</body>
</html>`;
}

View file

@ -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('<br/>');
}
function renderReleasesHtml(releases: OrderAckRelease[]): string {
if (releases.length === 0) return '';
const rows = releases
.map(
(r) => `
<tr>
<td style="padding: 1px 4px;">${r.release_num}</td>
<td style="padding: 1px 4px;">${fmtDate(r.need_by_date)}</td>
<td style="padding: 1px 4px; text-align: right;">${r.quantity.toLocaleString('en-US', { minimumFractionDigits: 2 })}</td>
<td style="padding: 1px 4px;">${r.job_num || ''}</td>
</tr>
`
)
.join('');
return `
<table style="margin-left: 20px; font-size: 9px; margin-top: 2px;">
<tr style="font-weight: bold;">
<td style="padding: 1px 4px;">Rel</td>
<td style="padding: 1px 4px;">Date</td>
<td style="padding: 1px 4px; text-align: right;">Quantity</td>
<td style="padding: 1px 4px;">Job Number</td>
</tr>
${rows}
</table>
`;
}
function renderLineHtml(line: OrderAckLine): string {
const paintCodes = [line.top_finish, line.bottom_finish]
.filter(Boolean)
.join(' / ');
const customerPartLine =
line.customer_part
? `<div style="margin-left: 20px; font-size: 9px; margin-top: 4px;">
Our Part: ${line.customer_part}${line.customer_part_desc ? ` / ${line.customer_part_desc}` : ''}
</div>`
: '';
const paintLine = paintCodes
? `<div style="margin-left: 20px; font-size: 9px;">${paintCodes}</div>`
: '';
const commentLine = line.comment
? `<div style="margin: 6px 8px; padding: 4px 8px; border: 1px solid #999; font-size: 8px;">
${line.comment}
</div>`
: '';
return `
<tr style="border-top: 1px solid #ccc;">
<td style="padding: 4px 6px; vertical-align: top; text-align: center;">${line.order_line}</td>
<td style="padding: 4px 6px; vertical-align: top;" colspan="2">
<div><strong>${line.part_num}</strong></div>
<div style="font-size: 9px;">${line.part_description}</div>
${customerPartLine}
${paintLine}
${renderReleasesHtml(line.releases)}
${commentLine}
</td>
<td style="padding: 4px 6px; vertical-align: top; text-align: center;">${line.revision || ''}</td>
<td style="padding: 4px 6px; vertical-align: top; text-align: right;">${fmtQty(line.order_qty, line.unit_of_measure)}</td>
<td style="padding: 4px 6px; vertical-align: top; text-align: right;">${fmtUnitPrice(line.unit_price)}</td>
<td style="padding: 4px 6px; vertical-align: top; text-align: right;"><strong>${fmtCurrency(line.extended_price)}</strong></td>
</tr>
`;
}
// Vorteq Q logo as inline SVG matching the brand
const VORTEQ_LOGO_SVG = `
<svg width="60" height="60" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="45" r="35" fill="none" stroke="#8BC34A" stroke-width="8"/>
<line x1="65" y1="65" x2="90" y2="90" stroke="#8BC34A" stroke-width="8" stroke-linecap="round"/>
</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 `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: Arial, Helvetica, sans-serif;
font-size: 10px;
color: #333;
line-height: 1.4;
}
table { border-collapse: collapse; }
</style>
</head>
<body>
<!-- Header -->
<table style="width: 100%; margin-bottom: 10px;">
<tr>
<td style="width: 80px; vertical-align: top;">
${VORTEQ_LOGO_SVG}
<div style="font-weight: bold; font-size: 14px; letter-spacing: 3px; margin-top: 2px;">VORTEQ</div>
</td>
<td style="vertical-align: top; padding-left: 10px;">
<div>11440 W Addison St</div>
<div>Franklin Park, IL 60131</div>
</td>
<td style="text-align: right; vertical-align: top;">
<div style="font-size: 16px; font-weight: bold;">Sales Order Acknowledgment</div>
<div>Phone: 847-455-7200</div>
<div>Fax: 847-455-7608</div>
</td>
</tr>
</table>
<!-- Order type + number -->
<div style="text-align: right; margin-bottom: 8px;">
<div>${header.order_type}</div>
<div style="font-size: 14px; font-weight: bold;">Sales Order: ${header.order_num}</div>
</div>
<!-- Sold To / Ship To -->
<table style="width: 100%; border: 1px solid #999; margin-bottom: 12px;">
<tr>
<td style="width: 50%; padding: 8px; border-right: 1px solid #999; vertical-align: top;">
<div style="font-weight: bold; margin-bottom: 4px;">Sold To:</div>
<div style="padding-left: 8px;">${soldTo}</div>
</td>
<td style="width: 50%; padding: 8px; vertical-align: top;">
<div style="font-weight: bold; margin-bottom: 4px;">Ship To:</div>
<div style="padding-left: 8px;">${shipTo}</div>
</td>
</tr>
</table>
<!-- Order Info -->
<table style="width: 100%; border: 1px solid #999; margin-bottom: 16px;">
<tr>
<td style="padding: 8px; width: 33%; vertical-align: top;">
<div>Order Date: ${fmtDate(header.order_date)}</div>
<div>Need By: ${fmtDate(header.need_by_date)}</div>
<div>Payment Terms: ${header.payment_terms || '-'}</div>
</td>
<td style="padding: 8px; width: 34%; vertical-align: top;">
<div>PO Number: ${header.po_num}</div>
<div>Sales Person: ${header.sales_person}</div>
<div>Ship Via: ${header.ship_via || '-'}</div>
</td>
<td style="padding: 8px; width: 33%; vertical-align: top;">
<div>FOB: ${header.fob || '-'}</div>
</td>
</tr>
</table>
<!-- Line Items Table -->
<div style="text-align: right; font-size: 9px; margin-bottom: 2px;">${header.currency}</div>
<table style="width: 100%; border: 1px solid #999;">
<thead>
<tr style="background: #f0f0f0; border-bottom: 1px solid #999;">
<th style="padding: 4px 6px; text-align: center; width: 40px; border-right: 1px solid #ddd;">Line</th>
<th style="padding: 4px 6px; text-align: left;" colspan="2">Part Number/Description</th>
<th style="padding: 4px 6px; text-align: center; width: 40px; border-left: 1px solid #ddd;">Rev</th>
<th style="padding: 4px 6px; text-align: right; width: 90px; border-left: 1px solid #ddd;">Order Qty</th>
<th style="padding: 4px 6px; text-align: right; width: 80px; border-left: 1px solid #ddd;">Unit Price</th>
<th style="padding: 4px 6px; text-align: right; width: 90px; border-left: 1px solid #ddd;">Ext. Price</th>
</tr>
</thead>
<tbody>
${lineRows}
</tbody>
</table>
<!-- Totals -->
<table style="width: 300px; margin-left: auto; margin-top: 12px; font-size: 10px;">
<tr>
<td style="text-align: right; padding: 2px 8px;">Line Total:</td>
<td style="text-align: right; padding: 2px 0;">${fmtCurrency(lineTotalPrice)}</td>
</tr>
<tr>
<td style="text-align: right; padding: 2px 8px;">Line Miscellaneous Charges:</td>
<td style="text-align: right; padding: 2px 0;">${fmtCurrency(lineMiscCharges)}</td>
</tr>
<tr>
<td style="text-align: right; padding: 2px 8px;">Order Miscellaneous Charges:</td>
<td style="text-align: right; padding: 2px 0; border-bottom: 1px solid #999;">${fmtCurrency(data.order_misc_charges)}</td>
</tr>
<tr style="font-weight: bold;">
<td style="text-align: right; padding: 4px 8px; border: 1px solid #999;">Order Total:</td>
<td style="text-align: right; padding: 4px 4px; border: 1px solid #999;">
${data.order_total_qty.toLocaleString('en-US')}${um}&nbsp;&nbsp;${fmtCurrency(data.order_total_price)}
</td>
</tr>
</table>
</body>
</html>`;
}

82
src/lib/pdf.ts Normal file
View file

@ -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<Buffer> {
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<Buffer> {
const html = renderOrderAckHtml(data);
return generatePdfFromHtml(html, { format: 'Letter' });
}
/**
* Generate a BOL PDF from data.
*/
export async function generateBOLPdf(data: BOLData): Promise<Buffer> {
const html = renderBOLHtml(data);
return generatePdfFromHtml(html, { format: 'Letter' });
}

View file

@ -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<CoilUsageRow[]> {
// 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<Record<string, unknown>[]>(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<string, unknown>): 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<string, { date: number; index: number }> = {};
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<CoilReceiptRow[]> {
// 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<Record<string, unknown>[]>(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<CoilReceiptRow[]> {
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<Record<string, unknown>[]>(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<string, unknown>): 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,
};
}

View file

@ -1,28 +1,34 @@
/** /**
* Dashboard Service * 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 = { export type DashboardOrder = {
order_num: string; order_num: string;
po_num: string; po_num: string;
customer_part: string; customer_part: string;
vorteq_part: string; vorteq_part: string;
order_date: Date; order_date: string;
need_by_date: Date; need_by_date: string;
qty: number; qty: number;
status: string; status: string;
}; };
export type DashboardShipment = { export type DashboardShipment = {
bol_num: string; bol_num: string;
pack_num: string; ship_date: string;
ship_date: Date;
ship_to: string; ship_to: string;
carrier: string; plant: string;
weight: number; weight: number;
}; };
@ -39,7 +45,7 @@ export type InventorySummary = {
export async function getRecentOrders( export async function getRecentOrders(
custId: string custId: string
): Promise<DashboardOrder[]> { ): Promise<DashboardOrder[]> {
const sql = ` const query = `
SELECT TOP 5 SELECT TOP 5
oh.OrderNum as order_num, oh.OrderNum as order_num,
oh.PONum as po_num, oh.PONum as po_num,
@ -47,8 +53,7 @@ export async function getRecentOrders(
od.PartNum as vorteq_part, od.PartNum as vorteq_part,
oh.OrderDate as order_date, oh.OrderDate as order_date,
oh.NeedByDate as need_by_date, oh.NeedByDate as need_by_date,
od.SellingQuantity as qty, od.SellingQuantity as qty
oh.OpenOrder as is_open
FROM Erp.OrderHed oh FROM Erp.OrderHed oh
INNER JOIN Erp.OrderDtl od ON oh.Company = od.Company AND oh.OrderNum = od.OrderNum 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 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 ORDER BY oh.OrderDate DESC
`; `;
const result = await execQuery<DashboardOrder[]>(sql, { const result = await execQuery<Record<string, unknown>[]>(query, {
custId, custId,
}); });
return result.map((row) => ({ // Extract only needed fields into plain objects (RSC-safe)
...row, 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', 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( export async function getRecentShipments(
custId: string custId: string
): Promise<DashboardShipment[]> { ): Promise<DashboardShipment[]> {
const sql = ` const shipments = await getTop100Shipments(custId);
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 result = await execQuery<DashboardShipment[]>(sql, { return shipments.slice(0, 5).map((s) => ({
custId, bol_num: s.bol_num,
}); ship_date: s.ship_date,
ship_to: s.ship_to,
return result; 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( export async function getInventorySummary(
custId: string custId: string
): Promise<InventorySummary> { ): Promise<InventorySummary> {
// This is a simplified query - actual implementation would call const dbName = getPortalDbName();
// the inventory stored procedures to get accurate counts
const sql = ` const config = {
SELECT server: process.env.MSSQL_HOST || '',
COUNT(CASE WHEN jh.JobClosed = 0 THEN 1 END) as wip_count, database: process.env.MSSQL_DATABASE || '',
COUNT(CASE WHEN pd.OnHandQty > 0 AND jh.JobClosed = 1 THEN 1 END) as finished_goods_count, user: process.env.MSSQL_USER || '',
SUM(COALESCE(pd.OnHandQty, 0)) as total_weight password: process.env.MSSQL_PASSWORD || '',
FROM Erp.JobHead jh port: parseInt(process.env.MSSQL_PORT || '1433', 10),
INNER JOIN Erp.PartDtl pd ON jh.Company = pd.Company AND jh.JobNum = pd.JobNum options: {
INNER JOIN Erp.Customer c ON jh.Company = c.Company AND jh.CustNum = c.CustNum encrypt: false,
WHERE c.CustID = @custId trustServerCertificate: true,
`; connectTimeout: 30000,
requestTimeout: 120000,
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 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
}
} }
/** /**

View file

@ -1,9 +1,20 @@
/** /**
* Orders Service * 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 { execQuery } from '@/lib/epicor';
import type {
OrderAcknowledgementData,
OrderAckHeader,
OrderAckLine,
OrderAckRelease,
} from '@/types/orders';
export type OrderRow = { export type OrderRow = {
order_num: number; order_num: number;
@ -140,3 +151,283 @@ export async function getOrderDetails(orderNum: number): Promise<OrderRow[]> {
return result; 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<OrderAcknowledgementData | null> {
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<Record<string, unknown>[]>(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<Record<string, unknown>[]>(miscSql, {
OrderNum: orderNum,
}).catch(() => [] as Record<string, unknown>[]);
// Aggregate misc charges by line (OrderLine = 0 means order-level)
const lineMiscMap = new Map<number, number>();
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<string>();
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<number, OrderAckLine>();
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<Map<string, PaintCodeInfo>> {
const result = new Map<string, PaintCodeInfo>();
if (partNums.length === 0) return result;
// Build parameterized IN clause: @P0, @P1, @P2, ...
const paramNames = partNums.map((_, i) => `@P${i}`);
const params: Record<string, unknown> = {};
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<Record<string, unknown>[]>(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;
}

View file

@ -5,52 +5,212 @@
* SP columns are PascalCase; we map them to snake_case for the UI. * 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 = { export type ShipmentRow = {
pack_num: string; bol_num: string;
ship_date: string; ship_date: string;
ship_to: string; ship_to: string;
carrier: string; plant: string;
weight: number; 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<string, unknown>): 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. * 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( export async function getTop100Shipments(
custId: string custId: string
): Promise<ShipmentRow[]> { ): Promise<ShipmentRow[]> {
const dbName = getPortalDbName(); const dbName = getPortalDbName();
const result = await execStoredProc<Record<string, unknown>[]>( const config = {
'portal_GetShipmentsV1', server: process.env.MSSQL_HOST || '',
{ database: process.env.MSSQL_DATABASE || '',
CustID: custId, user: process.env.MSSQL_USER || '',
DBNAME: dbName, 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<BOLData | null> {
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<Record<string, unknown>[]>(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 };
} }

View file

@ -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
};

73
src/types/orders.ts Normal file
View file

@ -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;
};

42
src/types/shipments.ts Normal file
View file

@ -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[];
};