feat: add order ack detail/PDF, BOL detail/PDF, coil activity usage & receipts
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:
parent
cd95bc4218
commit
53c35cc273
39 changed files with 3894 additions and 355 deletions
18
CLAUDE.md
18
CLAUDE.md
|
|
@ -142,14 +142,12 @@ quest-vorteq/
|
|||
|
||||
### Not Yet Created (Planned)
|
||||
```
|
||||
src/app/(portal)/shipments/
|
||||
src/app/(portal)/coil-activity/
|
||||
src/app/(portal)/invoices/
|
||||
src/app/(portal)/shipment-requests/
|
||||
src/app/(portal)/allocation-requests/
|
||||
src/app/(portal)/jobs/
|
||||
src/app/(portal)/admin/
|
||||
src/services/shipments.ts # Shipment data access
|
||||
src/services/coil-activity.ts # Coil activity data access
|
||||
src/services/ship-requests.ts # Shipment request business logic
|
||||
src/services/alloc-requests.ts # Allocation request business logic
|
||||
|
|
@ -251,6 +249,22 @@ export function ShipmentRequestCart() { ... }
|
|||
- Standard view: `dbo.portal_Orders WHERE CustomerID = @CustID`
|
||||
- HDC exception: uses `dbo.portal_OrdersHDC` view and maps CustID `HDC` → `HDM`
|
||||
|
||||
### RSC Serialization — Critical Pattern for Epicor Data
|
||||
|
||||
**Problem:** Next.js RSC (React Server Components) dev mode serializes ALL server-side data and console output to forward to the browser. The `mssql` package returns `recordset` objects with circular references, metadata, and prototype chains that blow up RSC serialization (`RangeError: Maximum call stack size exceeded` at `Set.add` or `Map.set`). This is especially severe for large result sets (e.g., ACM has 7,633 shipment rows).
|
||||
|
||||
**Solution:** For Epicor data displayed in the UI, use a **client-side fetch → API route** pattern instead of RSC server components:
|
||||
|
||||
1. Create an API route (e.g., `/api/shipments/route.ts`) that calls the service and returns `NextResponse.json(data)`
|
||||
2. Make the page a `'use client'` component that fetches from the API route via `useEffect` + `fetch()`
|
||||
3. The API route handles auth/session checks and returns plain JSON — no RSC serialization involved
|
||||
|
||||
**When RSC works fine:** Small result sets (< ~500 rows) through `execStoredProc` generally work. The `[...result.recordset]` spread in `execStoredProc` detaches the array-level prototype. For safety, services should still `JSON.parse(JSON.stringify(...))` their return values if passing to RSC.
|
||||
|
||||
**When RSC breaks:** Large result sets (1000+ rows), or any scenario where mssql objects remain in scope during server component rendering. Even with `JSON.parse(JSON.stringify())`, Next.js dev mode console forwarding can serialize the function's closure scope including mssql objects.
|
||||
|
||||
**Rule of thumb:** If an Epicor query can return > 500 rows for any customer, use the API route pattern.
|
||||
|
||||
### Column Mapping
|
||||
Epicor SP results use PascalCase column names. Services map them to snake_case:
|
||||
```typescript
|
||||
|
|
|
|||
70
TASKS.md
70
TASKS.md
|
|
@ -128,7 +128,7 @@
|
|||
|
||||
## Phase 2: Core Features (Est. 80-110 hrs)
|
||||
|
||||
**Progress:** 2/16 tasks complete
|
||||
**Progress:** 9/16 tasks complete
|
||||
|
||||
### C-001: Dashboard
|
||||
- [x] Dashboard page at `/(portal)/dashboard/page.tsx`
|
||||
|
|
@ -181,43 +181,53 @@
|
|||
- **Deps:** F-006, F-009 | **Est:** 6 hrs | **Status:** ✅ Complete
|
||||
|
||||
### C-005: Order Acknowledgement Detail + PDF
|
||||
- [ ] `/(portal)/orders/[id]/page.tsx`
|
||||
- [ ] Service: `getAcknowledgement(customer, poNumber?, orderNum?)` using `SalesOrder.sql`
|
||||
- [ ] Detail view with all order line information
|
||||
- [ ] PDF export button (generate PDF server-side)
|
||||
- **Deps:** C-004 | **Est:** 6 hrs
|
||||
- [x] `/(portal)/orders/[orderNum]/page.tsx` — client-side fetch with loading skeleton
|
||||
- [x] Service: `getOrderAcknowledgement(orderNum, custId)` with Epicor SQL (OrderHed/OrderDtl/OrderRel/Customer/ShipTo/Terms/ShipVia/Plant/JobProd)
|
||||
- [x] Detail view matching legacy PDF layout (addresses, order info, line items, releases, paint codes, comments, totals)
|
||||
- [x] PDF export via Puppeteer (`/api/orders/[orderNum]/pdf`)
|
||||
- [x] Orders table: clickable Order # links + PDF download icons
|
||||
- [x] Cross-customer security verified (BAS cannot access ACM orders)
|
||||
- [x] UTC date handling fix for correct date display
|
||||
- [x] FOB plant name resolution (code → description via Erp.Plant)
|
||||
- **Deps:** C-004 | **Est:** 6 hrs | **Status:** ✅ Complete
|
||||
|
||||
### C-006: Shipment List
|
||||
- [ ] `/(portal)/shipments/page.tsx`
|
||||
- [ ] Service: `getTop100Shipments(custId, dbName)` via `portal_GetShipmentsV1`
|
||||
- [ ] ShipToLoc formatting (replace `, ` with line breaks)
|
||||
- [ ] Data table with shipment details
|
||||
- [ ] Click-through to BOL detail
|
||||
- **Deps:** F-006, F-009 | **Est:** 5 hrs
|
||||
- [x] `/(portal)/shipments/page.tsx` — client-side fetch via API route (RSC-safe for large datasets)
|
||||
- [x] Service: `getTop100Shipments(custId)` via `portal_GetShipmentsV1` stored procedure
|
||||
- [x] ShipToLoc formatting (replace `, ` with newlines)
|
||||
- [x] Data table with search, sort, CSV export (teal-700 header)
|
||||
- [x] Click-through to BOL detail (`/shipments/{bol_num}`)
|
||||
- [x] API route at `/api/shipments` with auth + company context
|
||||
- **Deps:** F-006, F-009 | **Est:** 5 hrs | **Status:** ✅ Complete
|
||||
|
||||
### C-007: BOL Detail + PDF
|
||||
- [ ] `/(portal)/shipments/[bol]/page.tsx`
|
||||
- [ ] Service: `getBOL(bolNum, custId)` using `getBOL.sql`
|
||||
- [ ] Detail view with line items
|
||||
- [ ] PDF export
|
||||
- **Deps:** C-006 | **Est:** 4 hrs
|
||||
- [x] `/(portal)/shipments/[bol]/page.tsx`
|
||||
- [x] Service: `getBOL(bolNum, custId)` using `getBOL.sql`
|
||||
- [x] Detail view with line items
|
||||
- [x] PDF export
|
||||
- **Deps:** C-006 | **Est:** 4 hrs | **Status:** ✅ Complete
|
||||
|
||||
### C-008: Coil Activity - Usage Report
|
||||
- [ ] `/(portal)/coil-activity/usage/page.tsx`
|
||||
- [ ] Date range picker (with validation)
|
||||
- [ ] Service: `getCoilActivityUsage(custId, startDate, endDate)` using `portal_CoilActivityUsage.sql`
|
||||
- [ ] Deduplication logic: only show OnHandQty for most recent DateUsed per LotNum
|
||||
- [ ] Zero weights → null display
|
||||
- [ ] Data table with full column set
|
||||
- **Deps:** F-006, F-009 | **Est:** 6 hrs
|
||||
- [x] `/(portal)/coil-activity/usage/page.tsx`
|
||||
- [x] Date range picker (with validation — max 31 days, default last 10 days)
|
||||
- [x] Reusable UI components: Popover, Calendar (react-day-picker v9), DateRangePicker
|
||||
- [x] Service: `getCoilUsage(custId, startDate, endDate)` using legacy `portal_CoilActivityUsage.sql`
|
||||
- [x] Deduplication logic: only show OnHandQty for most recent DateUsed per LotNum
|
||||
- [x] Zero weights → null display
|
||||
- [x] Data table with all 11 columns: Date Used, Vorteq Part#, Cust Part#, Part Description, Lot#, Mfg Lot#, Weight, Plant Name, Job#, Cust PO#, Qty LB
|
||||
- [x] Search, sort, CSV export
|
||||
- [x] HDC→HDM customer ID mapping
|
||||
- **Deps:** F-006, F-009 | **Est:** 6 hrs | **Status:** ✅ Complete
|
||||
|
||||
### C-009: Coil Activity - Receipts Report
|
||||
- [ ] `/(portal)/coil-activity/receipts/page.tsx`
|
||||
- [ ] Date range picker
|
||||
- [ ] Service: `getCoilActivityReceipts(custId, startDate, endDate)`
|
||||
- [ ] VGL customer exception (use special SQL)
|
||||
- [ ] Data table with receipt columns
|
||||
- **Deps:** F-006, F-009 | **Est:** 4 hrs
|
||||
- [x] `/(portal)/coil-activity/receipts/page.tsx`
|
||||
- [x] Date range picker (shared component from C-008)
|
||||
- [x] Service: `getCoilReceipts(custId, startDate, endDate)` with VGL exception
|
||||
- [x] VGL customer exception: uses `VGLCoilReceiptsData.sql`; all others use `dbo.portal_CoilActivityReceipts` view
|
||||
- [x] Data table with all 10 columns: Date Rec, Vorteq Part#, Cust Part#, Part Description, Lot#, Mfg Lot#, Plant Name, Packing Slip, Supplier Name, Mill Order #
|
||||
- [x] Search, sort, CSV export
|
||||
- [x] HDC→HDM customer ID mapping
|
||||
- **Deps:** F-006, F-009 | **Est:** 4 hrs | **Status:** ✅ Complete
|
||||
|
||||
### C-010: Coil-by-Coil Report
|
||||
- [ ] `/(portal)/coil-activity/coil-by-coil/page.tsx`
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ const nextConfig: NextConfig = {
|
|||
reactStrictMode: true,
|
||||
poweredByHeader: false,
|
||||
compress: true,
|
||||
allowedDevOrigins: ['https://dev.quest.vorteq.local'],
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: '10mb',
|
||||
|
|
|
|||
76
package-lock.json
generated
76
package-lock.json
generated
|
|
@ -756,7 +756,6 @@
|
|||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -773,7 +772,6 @@
|
|||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -790,7 +788,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -807,7 +804,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -824,7 +820,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -841,7 +836,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -858,7 +852,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -875,7 +868,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -892,7 +884,6 @@
|
|||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -909,7 +900,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -926,7 +916,6 @@
|
|||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -943,7 +932,6 @@
|
|||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -960,7 +948,6 @@
|
|||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -977,7 +964,6 @@
|
|||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -994,7 +980,6 @@
|
|||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1011,7 +996,6 @@
|
|||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1028,7 +1012,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1045,7 +1028,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1062,7 +1044,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1079,7 +1060,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1096,7 +1076,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1113,7 +1092,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1130,7 +1108,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1147,7 +1124,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1164,7 +1140,6 @@
|
|||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1181,7 +1156,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3760,7 +3734,6 @@
|
|||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3774,7 +3747,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3788,7 +3760,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3802,7 +3773,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3816,7 +3786,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3830,7 +3799,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3844,7 +3812,6 @@
|
|||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3858,7 +3825,6 @@
|
|||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3872,7 +3838,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3886,7 +3851,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3900,7 +3864,6 @@
|
|||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3914,7 +3877,6 @@
|
|||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3928,7 +3890,6 @@
|
|||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3942,7 +3903,6 @@
|
|||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3956,7 +3916,6 @@
|
|||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3970,7 +3929,6 @@
|
|||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3984,7 +3942,6 @@
|
|||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3998,7 +3955,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -4012,7 +3968,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -4026,7 +3981,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -4040,7 +3994,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -4054,7 +4007,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -4068,7 +4020,6 @@
|
|||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -4082,7 +4033,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -4096,7 +4046,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -12540,7 +12489,6 @@
|
|||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
|
|
@ -12964,7 +12912,6 @@
|
|||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -12981,7 +12928,6 @@
|
|||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -12998,7 +12944,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -13015,7 +12960,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -13032,7 +12976,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -13049,7 +12992,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -13066,7 +13008,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -13083,7 +13024,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -13100,7 +13040,6 @@
|
|||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -13117,7 +13056,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -13134,7 +13072,6 @@
|
|||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -13151,7 +13088,6 @@
|
|||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -13168,7 +13104,6 @@
|
|||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -13185,7 +13120,6 @@
|
|||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -13202,7 +13136,6 @@
|
|||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -13219,7 +13152,6 @@
|
|||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -13236,7 +13168,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -13253,7 +13184,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -13270,7 +13200,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -13287,7 +13216,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -13304,7 +13232,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -13321,7 +13248,6 @@
|
|||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -13338,7 +13264,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -13391,7 +13316,6 @@
|
|||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
|
|
|
|||
92
src/app/(portal)/coil-activity/receipts/page.tsx
Normal file
92
src/app/(portal)/coil-activity/receipts/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
src/app/(portal)/coil-activity/usage/page.tsx
Normal file
92
src/app/(portal)/coil-activity/usage/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { Suspense } from 'react';
|
||||
'use client';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Package,
|
||||
ShoppingCart,
|
||||
|
|
@ -16,45 +16,59 @@ import { SummaryCard } from '@/components/dashboard/summary-card';
|
|||
import { QuickNavCard } from '@/components/dashboard/quick-nav-card';
|
||||
import { RecentOrdersTable } from '@/components/dashboard/recent-orders-table';
|
||||
import { RecentShipmentsTable } from '@/components/dashboard/recent-shipments-table';
|
||||
import {
|
||||
getRecentOrders,
|
||||
getRecentShipments,
|
||||
getInventorySummary,
|
||||
import type {
|
||||
DashboardOrder,
|
||||
DashboardShipment,
|
||||
InventorySummary,
|
||||
} from '@/services/dashboard';
|
||||
import { getQuestSession, getActiveCompany } from '@/lib/permissions';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
async function DashboardData() {
|
||||
const session = await getQuestSession();
|
||||
const activeCompany = await getActiveCompany();
|
||||
type DashboardData = {
|
||||
orders: DashboardOrder[];
|
||||
shipments: DashboardShipment[];
|
||||
inventorySummary: InventorySummary;
|
||||
unreadNotifications: number;
|
||||
};
|
||||
|
||||
if (!session || !activeCompany) {
|
||||
redirect('/select-company');
|
||||
}
|
||||
function DashboardSkeleton() {
|
||||
return (
|
||||
<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
|
||||
const [orders, shipments, inventorySummary, unreadNotifications] =
|
||||
await Promise.all([
|
||||
getRecentOrders(activeCompany.epicor_cust_id).catch(() => []),
|
||||
getRecentShipments(activeCompany.epicor_cust_id).catch(() => []),
|
||||
getInventorySummary(activeCompany.epicor_cust_id).catch(() => ({
|
||||
wip_count: 0,
|
||||
finished_goods_count: 0,
|
||||
unprocessed_count: 0,
|
||||
total_weight: 0,
|
||||
})),
|
||||
db.quest_notification
|
||||
.count({
|
||||
where: {
|
||||
is_alert: true,
|
||||
created_at: {
|
||||
gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), // Last 7 days
|
||||
},
|
||||
},
|
||||
})
|
||||
.catch(() => 0),
|
||||
]);
|
||||
function DashboardContent({ data }: { data: DashboardData }) {
|
||||
const { orders, shipments, inventorySummary, unreadNotifications } = data;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -176,51 +190,34 @@ async function DashboardData() {
|
|||
);
|
||||
}
|
||||
|
||||
function DashboardSkeleton() {
|
||||
return (
|
||||
<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() {
|
||||
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 (
|
||||
<div>
|
||||
<h1 className="mb-6 text-3xl font-bold">Dashboard</h1>
|
||||
<Suspense fallback={<DashboardSkeleton />}>
|
||||
<DashboardData />
|
||||
</Suspense>
|
||||
{error ? (
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center text-destructive">
|
||||
Failed to load dashboard: {error}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : data === null ? (
|
||||
<DashboardSkeleton />
|
||||
) : (
|
||||
<DashboardContent data={data} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
146
src/app/(portal)/orders/[orderNum]/page.tsx
Normal file
146
src/app/(portal)/orders/[orderNum]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
128
src/app/(portal)/shipments/[bol]/page.tsx
Normal file
128
src/app/(portal)/shipments/[bol]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,30 +1,10 @@
|
|||
import { Suspense } from 'react';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getTop100Shipments } from '@/services/shipments';
|
||||
import { getQuestSession, getActiveCompany } from '@/lib/permissions';
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ShipmentRow } from '@/services/shipments';
|
||||
import { ShipmentsTable } from '@/components/shipments/shipments-table';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
async function ShipmentsData() {
|
||||
const session = await getQuestSession();
|
||||
const activeCompany = await getActiveCompany();
|
||||
|
||||
if (!session || !activeCompany) {
|
||||
redirect('/select-company');
|
||||
}
|
||||
|
||||
const shipments = await getTop100Shipments(
|
||||
activeCompany.epicor_cust_id
|
||||
).catch((err) => {
|
||||
console.error('Failed to fetch shipments:', err);
|
||||
return [];
|
||||
});
|
||||
|
||||
return <ShipmentsTable data={shipments} />;
|
||||
}
|
||||
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<Card>
|
||||
|
|
@ -43,6 +23,19 @@ function LoadingSkeleton() {
|
|||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<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
|
||||
</p>
|
||||
|
||||
<Suspense fallback={<LoadingSkeleton />}>
|
||||
<ShipmentsData />
|
||||
</Suspense>
|
||||
{error ? (
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center text-destructive">
|
||||
Failed to load shipments: {error}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : shipments === null ? (
|
||||
<LoadingSkeleton />
|
||||
) : (
|
||||
<ShipmentsTable data={shipments} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
67
src/app/api/coil-activity/receipts/route.ts
Normal file
67
src/app/api/coil-activity/receipts/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
69
src/app/api/coil-activity/usage/route.ts
Normal file
69
src/app/api/coil-activity/usage/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
53
src/app/api/dashboard/route.ts
Normal file
53
src/app/api/dashboard/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
56
src/app/api/orders/[orderNum]/pdf/route.ts
Normal file
56
src/app/api/orders/[orderNum]/pdf/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
47
src/app/api/orders/[orderNum]/route.ts
Normal file
47
src/app/api/orders/[orderNum]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
44
src/app/api/shipments/[bol]/pdf/route.ts
Normal file
44
src/app/api/shipments/[bol]/pdf/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
48
src/app/api/shipments/[bol]/route.ts
Normal file
48
src/app/api/shipments/[bol]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
22
src/app/api/shipments/route.ts
Normal file
22
src/app/api/shipments/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
248
src/components/coil-activity/receipts-table.tsx
Normal file
248
src/components/coil-activity/receipts-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
262
src/components/coil-activity/usage-table.tsx
Normal file
262
src/components/coil-activity/usage-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -30,7 +30,7 @@ export function RecentShipmentsTable({ shipments }: RecentShipmentsTableProps) {
|
|||
<TableHead>BOL #</TableHead>
|
||||
<TableHead>Ship To</TableHead>
|
||||
<TableHead>Ship Date</TableHead>
|
||||
<TableHead>Carrier</TableHead>
|
||||
<TableHead>Plant</TableHead>
|
||||
<TableHead className="text-right">Weight</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
|
@ -49,7 +49,7 @@ export function RecentShipmentsTable({ shipments }: RecentShipmentsTableProps) {
|
|||
{shipment.ship_to}
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(shipment.ship_date)}</TableCell>
|
||||
<TableCell>{shipment.carrier}</TableCell>
|
||||
<TableCell>{shipment.plant}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{shipment.weight.toLocaleString()} lbs
|
||||
</TableCell>
|
||||
|
|
|
|||
|
|
@ -79,7 +79,8 @@ export function PortalHeader({
|
|||
body: JSON.stringify({ companyId }),
|
||||
});
|
||||
if (response.ok) {
|
||||
window.location.href = '/dashboard';
|
||||
// Refresh current page so data reloads with new company
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error switching company:', error);
|
||||
|
|
|
|||
356
src/components/orders/order-acknowledgement-detail.tsx
Normal file
356
src/components/orders/order-acknowledgement-detail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import type { OrderRow } from '@/services/orders';
|
||||
import {
|
||||
Card,
|
||||
|
|
@ -17,7 +18,7 @@ import {
|
|||
TableCell,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Download, Search } from 'lucide-react';
|
||||
import { Download, FileText, Search } from 'lucide-react';
|
||||
import {
|
||||
SortableTableHead,
|
||||
useSortableTable,
|
||||
|
|
@ -183,13 +184,16 @@ export function OrdersTable({ data }: Props) {
|
|||
>
|
||||
Job #
|
||||
</SortableTableHead>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">
|
||||
<span className="sr-only">PDF</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<TableBody>
|
||||
{sortedData.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={9}
|
||||
colSpan={10}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
No orders found
|
||||
|
|
@ -199,7 +203,12 @@ export function OrdersTable({ data }: Props) {
|
|||
sortedData.map((row, i) => (
|
||||
<TableRow key={i} className={i % 2 === 0 ? 'bg-muted/30' : ''}>
|
||||
<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>{row.customer_po || '-'}</TableCell>
|
||||
<TableCell className="font-mono">
|
||||
|
|
@ -219,6 +228,17 @@ export function OrdersTable({ data }: Props) {
|
|||
: '-'}
|
||||
</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>
|
||||
))
|
||||
)}
|
||||
|
|
|
|||
200
src/components/shipments/bol-detail.tsx
Normal file
200
src/components/shipments/bol-detail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -18,7 +18,7 @@ import {
|
|||
TableCell,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Download, Search } from 'lucide-react';
|
||||
import { Download, FileText, Search } from 'lucide-react';
|
||||
import {
|
||||
SortableTableHead,
|
||||
useSortableTable,
|
||||
|
|
@ -35,10 +35,9 @@ export function ShipmentsTable({ data }: Props) {
|
|||
const filteredData = data.filter((row) => {
|
||||
const s = searchTerm.toLowerCase();
|
||||
return (
|
||||
row.pack_num.toLowerCase().includes(s) ||
|
||||
row.bol_num.toLowerCase().includes(s) ||
|
||||
row.ship_to.toLowerCase().includes(s) ||
|
||||
row.carrier.toLowerCase().includes(s) ||
|
||||
row.tracking_num.toLowerCase().includes(s)
|
||||
row.plant.toLowerCase().includes(s)
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -50,20 +49,18 @@ export function ShipmentsTable({ data }: Props) {
|
|||
'BOL #',
|
||||
'Ship Date',
|
||||
'Ship To',
|
||||
'Carrier',
|
||||
'Weight',
|
||||
'Tracking #',
|
||||
'Plant',
|
||||
'Weight (lbs)',
|
||||
];
|
||||
|
||||
const rows = sortedData.map((row) => [
|
||||
row.pack_num,
|
||||
row.bol_num,
|
||||
row.ship_date
|
||||
? new Date(row.ship_date).toLocaleDateString()
|
||||
: '',
|
||||
row.ship_to.replace(/\n/g, ', '),
|
||||
row.carrier,
|
||||
row.plant,
|
||||
row.weight,
|
||||
row.tracking_num,
|
||||
]);
|
||||
|
||||
const csvContent = [headers, ...rows]
|
||||
|
|
@ -90,7 +87,7 @@ export function ShipmentsTable({ data }: Props) {
|
|||
<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 BOL #, ship to, carrier, or tracking #..."
|
||||
placeholder="Search BOL #, ship to, or plant..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-8"
|
||||
|
|
@ -107,7 +104,7 @@ export function ShipmentsTable({ data }: Props) {
|
|||
<thead>
|
||||
<tr className="bg-teal-700 text-white">
|
||||
<SortableTableHead
|
||||
sortKey="pack_num"
|
||||
sortKey="bol_num"
|
||||
currentSortKey={sortKey}
|
||||
currentDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
|
|
@ -131,12 +128,12 @@ export function ShipmentsTable({ data }: Props) {
|
|||
Ship To
|
||||
</SortableTableHead>
|
||||
<SortableTableHead
|
||||
sortKey="carrier"
|
||||
sortKey="plant"
|
||||
currentSortKey={sortKey}
|
||||
currentDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
>
|
||||
Carrier
|
||||
Plant
|
||||
</SortableTableHead>
|
||||
<SortableTableHead
|
||||
sortKey="weight"
|
||||
|
|
@ -147,14 +144,7 @@ export function ShipmentsTable({ data }: Props) {
|
|||
>
|
||||
Weight
|
||||
</SortableTableHead>
|
||||
<SortableTableHead
|
||||
sortKey="tracking_num"
|
||||
currentSortKey={sortKey}
|
||||
currentDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
>
|
||||
Tracking #
|
||||
</SortableTableHead>
|
||||
<th className="w-10 px-2 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<TableBody>
|
||||
|
|
@ -175,10 +165,10 @@ export function ShipmentsTable({ data }: Props) {
|
|||
>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/shipments/${row.pack_num}`}
|
||||
href={`/shipments/${row.bol_num}`}
|
||||
className="font-medium text-blue-600 hover:underline"
|
||||
>
|
||||
{row.pack_num}
|
||||
{row.bol_num}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
|
@ -187,13 +177,23 @@ export function ShipmentsTable({ data }: Props) {
|
|||
<TableCell className="max-w-xs whitespace-pre-line">
|
||||
{row.ship_to || '-'}
|
||||
</TableCell>
|
||||
<TableCell>{row.carrier || '-'}</TableCell>
|
||||
<TableCell>{row.plant || '-'}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{row.weight
|
||||
? `${row.weight.toLocaleString()} lbs`
|
||||
: '-'}
|
||||
</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>
|
||||
))
|
||||
)}
|
||||
|
|
|
|||
74
src/components/ui/calendar.tsx
Normal file
74
src/components/ui/calendar.tsx
Normal 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 };
|
||||
85
src/components/ui/date-range-picker.tsx
Normal file
85
src/components/ui/date-range-picker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -63,7 +63,7 @@ const config: EpicorConfig = {
|
|||
encrypt: false,
|
||||
trustServerCertificate: true,
|
||||
connectTimeout: 30000, // 30 seconds
|
||||
requestTimeout: 60000, // 60 seconds
|
||||
requestTimeout: 120000, // 120 seconds — large customers (ACM: 7k+ rows) need time over VPN
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -133,7 +133,9 @@ export async function execStoredProc<T = unknown>(
|
|||
}
|
||||
|
||||
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) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('timeout')) {
|
||||
|
|
@ -168,7 +170,7 @@ export async function execQuery<T = unknown>(
|
|||
}
|
||||
|
||||
const result: IResult<T> = await request.query(query);
|
||||
return result.recordset as T;
|
||||
return [...result.recordset] as T;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('timeout')) {
|
||||
|
|
|
|||
210
src/lib/pdf-templates/bol.ts
Normal file
210
src/lib/pdf-templates/bol.ts
Normal 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>`;
|
||||
}
|
||||
278
src/lib/pdf-templates/order-acknowledgement.ts
Normal file
278
src/lib/pdf-templates/order-acknowledgement.ts
Normal 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} ${fmtCurrency(data.order_total_price)}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
82
src/lib/pdf.ts
Normal file
82
src/lib/pdf.ts
Normal 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' });
|
||||
}
|
||||
258
src/services/coil-activity.ts
Normal file
258
src/services/coil-activity.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,28 +1,34 @@
|
|||
/**
|
||||
* Dashboard Service
|
||||
*
|
||||
* Fetches summary data for the dashboard page
|
||||
* Fetches summary data for the dashboard page.
|
||||
*
|
||||
* IMPORTANT: All rows from execQuery must be JSON-sanitized before returning.
|
||||
* The mssql driver attaches prototype metadata to recordset rows that causes
|
||||
* Next.js RSC serialization to blow the stack. We extract only the fields
|
||||
* we need into plain objects.
|
||||
*/
|
||||
|
||||
import { execQuery } from '@/lib/epicor';
|
||||
import sql from 'mssql';
|
||||
import { execQuery, getPortalDbName } from '@/lib/epicor';
|
||||
import { getTop100Shipments } from '@/services/shipments';
|
||||
|
||||
export type DashboardOrder = {
|
||||
order_num: string;
|
||||
po_num: string;
|
||||
customer_part: string;
|
||||
vorteq_part: string;
|
||||
order_date: Date;
|
||||
need_by_date: Date;
|
||||
order_date: string;
|
||||
need_by_date: string;
|
||||
qty: number;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DashboardShipment = {
|
||||
bol_num: string;
|
||||
pack_num: string;
|
||||
ship_date: Date;
|
||||
ship_date: string;
|
||||
ship_to: string;
|
||||
carrier: string;
|
||||
plant: string;
|
||||
weight: number;
|
||||
};
|
||||
|
||||
|
|
@ -39,7 +45,7 @@ export type InventorySummary = {
|
|||
export async function getRecentOrders(
|
||||
custId: string
|
||||
): Promise<DashboardOrder[]> {
|
||||
const sql = `
|
||||
const query = `
|
||||
SELECT TOP 5
|
||||
oh.OrderNum as order_num,
|
||||
oh.PONum as po_num,
|
||||
|
|
@ -47,8 +53,7 @@ export async function getRecentOrders(
|
|||
od.PartNum as vorteq_part,
|
||||
oh.OrderDate as order_date,
|
||||
oh.NeedByDate as need_by_date,
|
||||
od.SellingQuantity as qty,
|
||||
oh.OpenOrder as is_open
|
||||
od.SellingQuantity as qty
|
||||
FROM Erp.OrderHed oh
|
||||
INNER JOIN Erp.OrderDtl od ON oh.Company = od.Company AND oh.OrderNum = od.OrderNum
|
||||
INNER JOIN Erp.Customer c ON oh.Company = c.Company AND oh.CustNum = c.CustNum
|
||||
|
|
@ -57,92 +62,140 @@ export async function getRecentOrders(
|
|||
ORDER BY oh.OrderDate DESC
|
||||
`;
|
||||
|
||||
const result = await execQuery<DashboardOrder[]>(sql, {
|
||||
const result = await execQuery<Record<string, unknown>[]>(query, {
|
||||
custId,
|
||||
});
|
||||
|
||||
return result.map((row) => ({
|
||||
...row,
|
||||
// Extract only needed fields into plain objects (RSC-safe)
|
||||
return result.map((r) => ({
|
||||
order_num: String(r.order_num ?? ''),
|
||||
po_num: String(r.po_num ?? ''),
|
||||
customer_part: String(r.customer_part ?? ''),
|
||||
vorteq_part: String(r.vorteq_part ?? ''),
|
||||
order_date: r.order_date
|
||||
? new Date(r.order_date as string).toISOString()
|
||||
: '',
|
||||
need_by_date: r.need_by_date
|
||||
? new Date(r.need_by_date as string).toISOString()
|
||||
: '',
|
||||
qty: Number(r.qty ?? 0),
|
||||
status: 'Open',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get top 5 recent shipments for dashboard
|
||||
* Get top 5 recent shipments for dashboard.
|
||||
*
|
||||
* Reuses getTop100Shipments from the shipments service which already handles
|
||||
* the portal_GetShipmentsV1 SP and returns RSC-safe plain objects.
|
||||
* We just take the first 5 (already sorted by date desc).
|
||||
*/
|
||||
export async function getRecentShipments(
|
||||
custId: string
|
||||
): Promise<DashboardShipment[]> {
|
||||
const sql = `
|
||||
SELECT TOP 5
|
||||
sh.PackNum as bol_num,
|
||||
sh.PackNum as pack_num,
|
||||
sh.ShipDate as ship_date,
|
||||
CONCAT(st.Name, ', ', st.City, ', ', st.State) as ship_to,
|
||||
COALESCE(sh.CarrierName, 'N/A') as carrier,
|
||||
sh.Weight as weight
|
||||
FROM Erp.ShipHead sh
|
||||
INNER JOIN Erp.Customer c ON sh.Company = c.Company AND sh.CustNum = c.CustNum
|
||||
LEFT JOIN Erp.ShipTo st ON sh.Company = st.Company AND sh.CustNum = st.CustNum AND sh.ShipToNum = st.ShipToNum
|
||||
WHERE c.CustID = @custId
|
||||
AND sh.ShipDate IS NOT NULL
|
||||
ORDER BY sh.ShipDate DESC
|
||||
`;
|
||||
const shipments = await getTop100Shipments(custId);
|
||||
|
||||
const result = await execQuery<DashboardShipment[]>(sql, {
|
||||
custId,
|
||||
});
|
||||
|
||||
return result;
|
||||
return shipments.slice(0, 5).map((s) => ({
|
||||
bol_num: s.bol_num,
|
||||
ship_date: s.ship_date,
|
||||
ship_to: s.ship_to,
|
||||
plant: s.plant,
|
||||
weight: s.weight,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get inventory summary counts for dashboard
|
||||
* Get inventory summary counts for dashboard.
|
||||
*
|
||||
* Calls the real portal inventory stored procedures:
|
||||
* - PortalWorkInProgressInventorySummaryV6 (@Customer, @DBNAME, @SUBUSER)
|
||||
* - PortalFinishedGoodsInventorySummaryV6 (@Customer, @DBNAME, @SUBUSER)
|
||||
* - PortalUnprocessedInventorySummary (@CUSTID, @DBNAME)
|
||||
*
|
||||
* Each returns rows per product with Rows (coil count) and OnHandQty (lbs).
|
||||
* We aggregate them into totals for the dashboard cards.
|
||||
*/
|
||||
export async function getInventorySummary(
|
||||
custId: string
|
||||
): Promise<InventorySummary> {
|
||||
// This is a simplified query - actual implementation would call
|
||||
// the inventory stored procedures to get accurate counts
|
||||
const dbName = getPortalDbName();
|
||||
|
||||
const sql = `
|
||||
SELECT
|
||||
COUNT(CASE WHEN jh.JobClosed = 0 THEN 1 END) as wip_count,
|
||||
COUNT(CASE WHEN pd.OnHandQty > 0 AND jh.JobClosed = 1 THEN 1 END) as finished_goods_count,
|
||||
SUM(COALESCE(pd.OnHandQty, 0)) as total_weight
|
||||
FROM Erp.JobHead jh
|
||||
INNER JOIN Erp.PartDtl pd ON jh.Company = pd.Company AND jh.JobNum = pd.JobNum
|
||||
INNER JOIN Erp.Customer c ON jh.Company = c.Company AND jh.CustNum = c.CustNum
|
||||
WHERE c.CustID = @custId
|
||||
`;
|
||||
|
||||
const result = await execQuery<
|
||||
Array<{
|
||||
wip_count: number;
|
||||
finished_goods_count: number;
|
||||
total_weight: number;
|
||||
}>
|
||||
>(sql, {
|
||||
custId,
|
||||
});
|
||||
|
||||
if (result.length === 0 || !result[0]) {
|
||||
return {
|
||||
wip_count: 0,
|
||||
finished_goods_count: 0,
|
||||
unprocessed_count: 0,
|
||||
total_weight: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const firstResult = result[0];
|
||||
|
||||
return {
|
||||
wip_count: firstResult.wip_count || 0,
|
||||
finished_goods_count: firstResult.finished_goods_count || 0,
|
||||
total_weight: firstResult.total_weight || 0,
|
||||
unprocessed_count: 0, // Would need separate query
|
||||
const config = {
|
||||
server: process.env.MSSQL_HOST || '',
|
||||
database: process.env.MSSQL_DATABASE || '',
|
||||
user: process.env.MSSQL_USER || '',
|
||||
password: process.env.MSSQL_PASSWORD || '',
|
||||
port: parseInt(process.env.MSSQL_PORT || '1433', 10),
|
||||
options: {
|
||||
encrypt: false,
|
||||
trustServerCertificate: true,
|
||||
connectTimeout: 30000,
|
||||
requestTimeout: 120000,
|
||||
},
|
||||
};
|
||||
|
||||
const pool = await sql.connect(config);
|
||||
try {
|
||||
// Run all three inventory SPs in parallel
|
||||
const [wipResult, fgResult, unprocessedResult] = await Promise.all([
|
||||
pool
|
||||
.request()
|
||||
.input('Customer', custId)
|
||||
.input('DBNAME', dbName)
|
||||
.input('SUBUSER', 0)
|
||||
.execute('PortalWorkInProgressInventorySummaryV6')
|
||||
.catch(() => null),
|
||||
pool
|
||||
.request()
|
||||
.input('Customer', custId)
|
||||
.input('DBNAME', dbName)
|
||||
.input('SUBUSER', 0)
|
||||
.execute('PortalFinishedGoodsInventorySummaryV6')
|
||||
.catch(() => null),
|
||||
pool
|
||||
.request()
|
||||
.input('CUSTID', custId)
|
||||
.input('DBNAME', dbName)
|
||||
.execute('PortalUnprocessedInventorySummary')
|
||||
.catch(() => null),
|
||||
]);
|
||||
|
||||
// Aggregate: sum Rows for counts, sum OnHandQty for total weight
|
||||
let wipCount = 0;
|
||||
let fgCount = 0;
|
||||
let unprocessedCount = 0;
|
||||
let totalWeight = 0;
|
||||
|
||||
if (wipResult) {
|
||||
for (const r of wipResult.recordset) {
|
||||
wipCount += Number(r.Rows ?? 0);
|
||||
totalWeight += Number(r.OnHandQty ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
if (fgResult) {
|
||||
for (const r of fgResult.recordset) {
|
||||
fgCount += Number(r.Rows ?? 0);
|
||||
totalWeight += Number(r.OnHandQty ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
if (unprocessedResult) {
|
||||
for (const r of unprocessedResult.recordset) {
|
||||
unprocessedCount += Number(r.Rows ?? 0);
|
||||
totalWeight += Number(r.OnHandQty ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
wip_count: wipCount,
|
||||
finished_goods_count: fgCount,
|
||||
unprocessed_count: unprocessedCount,
|
||||
total_weight: totalWeight,
|
||||
};
|
||||
} finally {
|
||||
// Don't close the pool — mssql reuses it globally
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,9 +1,20 @@
|
|||
/**
|
||||
* Orders Service
|
||||
* Handles order data retrieval from Epicor using the portal_Orders view
|
||||
* Handles order data retrieval from Epicor using the portal_Orders view.
|
||||
*
|
||||
* IMPORTANT: All rows from execQuery must be JSON-sanitized before returning.
|
||||
* The mssql driver attaches prototype metadata to recordset rows that causes
|
||||
* Next.js RSC serialization to blow the stack. We extract only the fields
|
||||
* we need into plain objects.
|
||||
*/
|
||||
|
||||
import { execQuery } from '@/lib/epicor';
|
||||
import type {
|
||||
OrderAcknowledgementData,
|
||||
OrderAckHeader,
|
||||
OrderAckLine,
|
||||
OrderAckRelease,
|
||||
} from '@/types/orders';
|
||||
|
||||
export type OrderRow = {
|
||||
order_num: number;
|
||||
|
|
@ -140,3 +151,283 @@ export async function getOrderDetails(orderNum: number): Promise<OrderRow[]> {
|
|||
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,52 +5,212 @@
|
|||
* SP columns are PascalCase; we map them to snake_case for the UI.
|
||||
*/
|
||||
|
||||
import { execStoredProc, getPortalDbName } from '@/lib/epicor';
|
||||
import sql from 'mssql';
|
||||
import { execQuery, getPortalDbName } from '@/lib/epicor';
|
||||
import type { BOLData } from '@/types/shipments';
|
||||
|
||||
export type ShipmentRow = {
|
||||
pack_num: string;
|
||||
bol_num: string;
|
||||
ship_date: string;
|
||||
ship_to: string;
|
||||
carrier: string;
|
||||
plant: string;
|
||||
weight: number;
|
||||
tracking_num: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Map raw Epicor SP row to our normalized type.
|
||||
* ShipToLoc comes as comma-separated; we convert to newline-separated for display.
|
||||
*/
|
||||
function mapShipmentRow(raw: Record<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.
|
||||
* Uses portal_GetShipmentsV1 SP with CustID and DBNAME params.
|
||||
* No SUBUSER param — all users see the same shipment data.
|
||||
*
|
||||
* Bypasses execStoredProc to avoid holding 7k+ mssql row objects in scope
|
||||
* (which causes Next.js RSC serialization to blow the stack for large customers).
|
||||
* Instead, we query directly and immediately extract only the fields we need.
|
||||
*/
|
||||
export async function getTop100Shipments(
|
||||
custId: string
|
||||
): Promise<ShipmentRow[]> {
|
||||
const dbName = getPortalDbName();
|
||||
|
||||
const result = await execStoredProc<Record<string, unknown>[]>(
|
||||
'portal_GetShipmentsV1',
|
||||
{
|
||||
CustID: custId,
|
||||
DBNAME: dbName,
|
||||
}
|
||||
);
|
||||
const config = {
|
||||
server: process.env.MSSQL_HOST || '',
|
||||
database: process.env.MSSQL_DATABASE || '',
|
||||
user: process.env.MSSQL_USER || '',
|
||||
password: process.env.MSSQL_PASSWORD || '',
|
||||
port: parseInt(process.env.MSSQL_PORT || '1433', 10),
|
||||
options: {
|
||||
encrypt: false,
|
||||
trustServerCertificate: true,
|
||||
connectTimeout: 30000,
|
||||
requestTimeout: 120000,
|
||||
},
|
||||
};
|
||||
|
||||
return result.map(mapShipmentRow);
|
||||
const pool = await sql.connect(config);
|
||||
try {
|
||||
const result = await pool.request()
|
||||
.input('CustID', custId)
|
||||
.input('DBNAME', dbName)
|
||||
.execute('portal_GetShipmentsV1');
|
||||
|
||||
// Immediately extract only the fields we need into plain objects.
|
||||
// This prevents mssql row metadata from leaking into RSC serialization.
|
||||
const rows: ShipmentRow[] = [];
|
||||
for (let i = 0; i < result.recordset.length; i++) {
|
||||
const r = result.recordset[i];
|
||||
rows.push({
|
||||
bol_num: String(r.BOLNum ?? ''),
|
||||
ship_date: r.ShipDate ? new Date(r.ShipDate as string).toISOString() : '',
|
||||
ship_to: String(r.ShipToLoc ?? '').replace(/, /g, '\n'),
|
||||
plant: String(r.PlantName ?? ''),
|
||||
weight: Number(r.Pounds ?? 0),
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by ship date descending and take top 100
|
||||
rows.sort((a, b) => b.ship_date.localeCompare(a.ship_date));
|
||||
return rows.slice(0, 100);
|
||||
} finally {
|
||||
// Don't close the pool — mssql reuses it globally
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get BOL (Bill of Lading) detail for a specific BOL number.
|
||||
* Uses the legacy portal's getBOL.sql query.
|
||||
*/
|
||||
export async function getBOLDetail(
|
||||
bolNum: number,
|
||||
custId: string
|
||||
): Promise<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 };
|
||||
}
|
||||
|
|
|
|||
33
src/types/coil-activity.ts
Normal file
33
src/types/coil-activity.ts
Normal 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
73
src/types/orders.ts
Normal 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
42
src/types/shipments.ts
Normal 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[];
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue