From e60e07c9a5d7e819bf8b8a6751fbd5ec1bf89fb7 Mon Sep 17 00:00:00 2001 From: Lorentz Hinrichsen Date: Tue, 17 Feb 2026 10:37:38 -0500 Subject: [PATCH] feat: add shipment request (C-013) and allocation request (C-014) cart workflows Multi-step cart workflows for both shipment and allocation requests: - Ship-to address selection from Epicor portal_CustomerShipToAddresses - Inventory browser dialog (shipment) / coil browser dialog (allocation) - Cart persistence in DB with duplicate detection - Details form (order#, PO#, pickup date, instructions, email recipients) - Review & submit with confirmation dialog - Cancel with permission gate and confirmation - Shared components: stepper, ship-to selector, cart table, header form, review - 16 API routes, 4 services, 13 components, 6 pages - Phase 2 complete: 16/16 tasks done Co-Authored-By: Claude Opus 4.6 --- TASKS.md | 54 ++- .../allocation-requests/[id]/page.tsx | 252 ++++++++++ .../(portal)/allocation-requests/new/page.tsx | 27 ++ src/app/(portal)/allocation-requests/page.tsx | 59 +++ .../(portal)/shipment-requests/[id]/page.tsx | 250 ++++++++++ .../(portal)/shipment-requests/new/page.tsx | 12 + src/app/(portal)/shipment-requests/page.tsx | 59 +++ .../allocation-requests/[id]/cancel/route.ts | 36 ++ src/app/api/allocation-requests/[id]/route.ts | 41 ++ .../cart/items/[detailId]/route.ts | 42 ++ .../allocation-requests/cart/items/route.ts | 84 ++++ src/app/api/allocation-requests/cart/route.ts | 74 +++ .../allocation-requests/cart/submit/route.ts | 36 ++ src/app/api/allocation-requests/route.ts | 29 ++ src/app/api/available-coils/route.ts | 45 ++ src/app/api/ship-to-addresses/route.ts | 34 ++ .../shipment-requests/[id]/cancel/route.ts | 34 ++ src/app/api/shipment-requests/[id]/route.ts | 41 ++ .../cart/items/[detailId]/route.ts | 42 ++ .../api/shipment-requests/cart/items/route.ts | 84 ++++ src/app/api/shipment-requests/cart/route.ts | 67 +++ .../shipment-requests/cart/submit/route.ts | 36 ++ src/app/api/shipment-requests/route.ts | 29 ++ .../requests/allocation-request-cart.tsx | 332 +++++++++++++ .../requests/allocation-request-list.tsx | 207 +++++++++ src/components/requests/cart-header-form.tsx | 221 +++++++++ src/components/requests/cart-items-table.tsx | 246 ++++++++++ src/components/requests/cart-review.tsx | 278 +++++++++++ .../requests/coil-browser-dialog.tsx | 197 ++++++++ .../requests/inventory-browser-dialog.tsx | 219 +++++++++ .../requests/request-cancel-dialog.tsx | 70 +++ .../requests/request-status-badge.tsx | 24 + src/components/requests/request-stepper.tsx | 81 ++++ src/components/requests/ship-to-selector.tsx | 148 ++++++ .../requests/shipment-request-cart.tsx | 259 +++++++++++ .../requests/shipment-request-list.tsx | 202 ++++++++ src/services/alloc-requests.ts | 439 ++++++++++++++++++ src/services/available-coils.ts | 70 +++ src/services/ship-requests.ts | 439 ++++++++++++++++++ src/services/ship-to-addresses.ts | 60 +++ src/types/requests.ts | 167 +++++++ 41 files changed, 5106 insertions(+), 20 deletions(-) create mode 100644 src/app/(portal)/allocation-requests/[id]/page.tsx create mode 100644 src/app/(portal)/allocation-requests/new/page.tsx create mode 100644 src/app/(portal)/allocation-requests/page.tsx create mode 100644 src/app/(portal)/shipment-requests/[id]/page.tsx create mode 100644 src/app/(portal)/shipment-requests/new/page.tsx create mode 100644 src/app/(portal)/shipment-requests/page.tsx create mode 100644 src/app/api/allocation-requests/[id]/cancel/route.ts create mode 100644 src/app/api/allocation-requests/[id]/route.ts create mode 100644 src/app/api/allocation-requests/cart/items/[detailId]/route.ts create mode 100644 src/app/api/allocation-requests/cart/items/route.ts create mode 100644 src/app/api/allocation-requests/cart/route.ts create mode 100644 src/app/api/allocation-requests/cart/submit/route.ts create mode 100644 src/app/api/allocation-requests/route.ts create mode 100644 src/app/api/available-coils/route.ts create mode 100644 src/app/api/ship-to-addresses/route.ts create mode 100644 src/app/api/shipment-requests/[id]/cancel/route.ts create mode 100644 src/app/api/shipment-requests/[id]/route.ts create mode 100644 src/app/api/shipment-requests/cart/items/[detailId]/route.ts create mode 100644 src/app/api/shipment-requests/cart/items/route.ts create mode 100644 src/app/api/shipment-requests/cart/route.ts create mode 100644 src/app/api/shipment-requests/cart/submit/route.ts create mode 100644 src/app/api/shipment-requests/route.ts create mode 100644 src/components/requests/allocation-request-cart.tsx create mode 100644 src/components/requests/allocation-request-list.tsx create mode 100644 src/components/requests/cart-header-form.tsx create mode 100644 src/components/requests/cart-items-table.tsx create mode 100644 src/components/requests/cart-review.tsx create mode 100644 src/components/requests/coil-browser-dialog.tsx create mode 100644 src/components/requests/inventory-browser-dialog.tsx create mode 100644 src/components/requests/request-cancel-dialog.tsx create mode 100644 src/components/requests/request-status-badge.tsx create mode 100644 src/components/requests/request-stepper.tsx create mode 100644 src/components/requests/ship-to-selector.tsx create mode 100644 src/components/requests/shipment-request-cart.tsx create mode 100644 src/components/requests/shipment-request-list.tsx create mode 100644 src/services/alloc-requests.ts create mode 100644 src/services/available-coils.ts create mode 100644 src/services/ship-requests.ts create mode 100644 src/services/ship-to-addresses.ts create mode 100644 src/types/requests.ts diff --git a/TASKS.md b/TASKS.md index 5c3c505..c1eb613 100644 --- a/TASKS.md +++ b/TASKS.md @@ -128,7 +128,7 @@ ## Phase 2: Core Features (Est. 80-110 hrs) -**Progress:** 14/16 tasks complete +**Progress:** 16/16 tasks complete ✅ ### C-001: Dashboard - [x] Dashboard page at `/(portal)/dashboard/page.tsx` @@ -259,27 +259,41 @@ - **Deps:** F-006 | **Est:** 4 hrs | **Status:** ✅ Complete ### C-013: Shipment Request Cart Workflow -- [ ] `/(portal)/shipment-requests/page.tsx` — list existing requests -- [ ] `/(portal)/shipment-requests/new/page.tsx` — new request flow -- [ ] `/(portal)/shipment-requests/[id]/page.tsx` — edit/view request -- [ ] Step 1: Select ship-to address (from `portal_CustomerShipToAddresses`) - - [ ] Sub-user filter: only '%NB HANDY%' addresses -- [ ] Step 2: Add inventory items to cart - - [ ] Browse available inventory, select items - - [ ] Set quantity per line item -- [ ] Step 3: Enter details (Order#, PO#, Release#, Pickup Date, Instructions, Email Recipients) -- [ ] Submit: create `ship_request` + `ship_request_detail` records, send emails -- [ ] Cancel: set `IsCancelled=true`, record canceller, send cancellation emails -- [ ] Cart persistence: save progress to DB, show `LastCartActivity` -- [ ] Confirmation page after submit -- **Deps:** F-005, C-003 | **Est:** 12 hrs +- [x] `/(portal)/shipment-requests/page.tsx` — list existing requests with search, sort, status badges +- [x] `/(portal)/shipment-requests/new/page.tsx` — multi-step cart workflow +- [x] `/(portal)/shipment-requests/[id]/page.tsx` — view submitted/cancelled request detail +- [x] Step 1: Select ship-to address (from `portal_CustomerShipToAddresses` view) + - [x] Sub-user filter: only '%NB HANDY%' addresses + - [x] Search and radio selection UI +- [x] Step 2: Add inventory items to cart + - [x] Browse available inventory via dialog (FG, WIP, Processed Other categories) + - [x] Set quantity per line item, inline edit + - [x] Duplicate detection (part+lot+plant+warehouse) +- [x] Step 3: Enter details (Order#, PO#, Release#, Pickup Date, Instructions, Email Recipients) + - [x] Email recipients with tag-style input +- [x] Step 4: Review & submit with confirmation dialog +- [x] Cart persistence: save progress to DB via `ship_request` + `ship_request_detail` tables +- [x] Cancel: permission-gated with confirmation dialog +- [x] Email notification stubs (console.log — J-007 email service not built yet) +- [x] Service: `src/services/ship-requests.ts` — full CRUD + submit/cancel +- [x] Service: `src/services/ship-to-addresses.ts` — Epicor view query +- [x] 8 API routes: list, cart CRUD, items CRUD, submit, cancel, detail +- **Deps:** F-005, C-003 | **Est:** 12 hrs | **Status:** ✅ Complete ### C-014: Coil Allocation Request Cart Workflow -- [ ] Mirror of C-013 but for allocation requests -- [ ] Additional field: JobNum (from Epicor) -- [ ] Uses `alloc_request` + `alloc_request_detail` tables -- [ ] `getCoilAllocationsByJobNumber()` for allocation data -- **Deps:** F-005, C-003 | **Est:** 8 hrs +- [x] Mirror of C-013 but for allocation requests with job number requirement +- [x] `/(portal)/allocation-requests/page.tsx` — list with job# column +- [x] `/(portal)/allocation-requests/new/page.tsx` — job# entry → multi-step cart +- [x] `/(portal)/allocation-requests/[id]/page.tsx` — view detail +- [x] Job number input step before cart creation +- [x] Coil browser dialog (queries `portal_CoilAllocation` view, graceful fallback) +- [x] Uses `alloc_request` + `alloc_request_detail` tables with `coil_number` field +- [x] Duplicate detection (part+lot+coil_number) +- [x] Service: `src/services/alloc-requests.ts` — full CRUD + submit/cancel +- [x] Service: `src/services/available-coils.ts` — Epicor coil query with graceful fallback +- [x] 8 API routes: list, cart CRUD, items CRUD, submit, cancel, detail +- [x] Shared components with C-013: stepper, ship-to selector, cart form, review +- **Deps:** F-005, C-003 | **Est:** 8 hrs | **Status:** ✅ Complete ### C-015: Invoice Viewing (Customer) - [x] `/(portal)/invoices/page.tsx` — client-side fetch with loading skeleton diff --git a/src/app/(portal)/allocation-requests/[id]/page.tsx b/src/app/(portal)/allocation-requests/[id]/page.tsx new file mode 100644 index 0000000..8f9c051 --- /dev/null +++ b/src/app/(portal)/allocation-requests/[id]/page.tsx @@ -0,0 +1,252 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import type { AllocRequestHeader } from '@/types/requests'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { + Table, + TableBody, + TableCell, + TableRow, +} from '@/components/ui/table'; +import { RequestStatusBadge } from '@/components/requests/request-status-badge'; +import { RequestCancelDialog } from '@/components/requests/request-cancel-dialog'; +import { ArrowLeft, Loader2 } from 'lucide-react'; +import { formatDate } from '@/lib/utils'; +import Link from 'next/link'; +import { useToast } from '@/hooks/use-toast'; + +export default function AllocationRequestDetailPage() { + const params = useParams(); + const router = useRouter(); + const { toast } = useToast(); + const [request, setRequest] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + fetch(`/api/allocation-requests/${params.id}`) + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }) + .then((data) => setRequest(data)) + .catch((err) => setError(err.message)) + .finally(() => setLoading(false)); + }, [params.id]); + + const handleCancel = async () => { + if (!request) return; + const res = await fetch(`/api/allocation-requests/${request.id}/cancel`, { + method: 'POST', + }); + if (!res.ok) { + const err = await res.json(); + toast({ + title: 'Error', + description: err.error || 'Failed to cancel', + variant: 'destructive', + }); + return; + } + toast({ title: 'Request cancelled' }); + router.push('/allocation-requests'); + }; + + if (loading) { + return ( +
+

Allocation Request

+ + + + + +
+ ); + } + + if (error || !request) { + return ( +
+

Allocation Request

+ + + {error || 'Request not found'} + + +
+ ); + } + + return ( +
+
+

Allocation Request

+
+ + {request.is_submitted && !request.is_cancelled && ( + + )} +
+
+ + + + Request Details + + Created {formatDate(new Date(request.created_at))} + {request.submitted_at && + ` | Submitted ${formatDate(new Date(request.submitted_at))}`} + {request.cancelled_at && + ` | Cancelled ${formatDate(new Date(request.cancelled_at))}`} + + + +
+
+ + Job Number + +

{request.job_number}

+
+
+ + Ship-To Address + +

{request.ship_to_address || '-'}

+
+
+ + Order # + +

{request.order_number || '-'}

+
+
+ + PO # + +

{request.po_number || '-'}

+
+
+ + Release # + +

{request.release_number || '-'}

+
+
+ + Pickup Date + +

+ {request.pickup_date + ? formatDate(new Date(request.pickup_date)) + : '-'} +

+
+
+ + {request.instructions && ( +
+ + Instructions + +

+ {request.instructions} +

+
+ )} + + {request.email_recipients.length > 0 && ( +
+ + Email Recipients + +
+ {request.email_recipients.map((email) => ( + + {email} + + ))} +
+
+ )} + + {/* Items */} +
+

+ Items ({request.details.length}) +

+
+ + + + + + + + + + + + {request.details.map((item, i) => ( + + + {item.part_num} + + {item.lot_num || '-'} + {item.coil_number || '-'} + + {item.quantity} + + {item.notes || '-'} + + ))} + +
+ Part # + + Lot # + + Coil # + + Qty + + Notes +
+
+
+ +
+ + + +
+
+
+
+ ); +} diff --git a/src/app/(portal)/allocation-requests/new/page.tsx b/src/app/(portal)/allocation-requests/new/page.tsx new file mode 100644 index 0000000..3e4fd1c --- /dev/null +++ b/src/app/(portal)/allocation-requests/new/page.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { Suspense } from 'react'; +import { AllocationRequestCart } from '@/components/requests/allocation-request-cart'; +import { Card, CardContent } from '@/components/ui/card'; +import { Loader2 } from 'lucide-react'; + +function CartFallback() { + return ( + + + + + + ); +} + +export default function NewAllocationRequestPage() { + return ( +
+

New Allocation Request

+ }> + + +
+ ); +} diff --git a/src/app/(portal)/allocation-requests/page.tsx b/src/app/(portal)/allocation-requests/page.tsx new file mode 100644 index 0000000..7e8a34f --- /dev/null +++ b/src/app/(portal)/allocation-requests/page.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Card, CardContent } from '@/components/ui/card'; +import { AllocationRequestList } from '@/components/requests/allocation-request-list'; +import type { AllocRequestListItem } from '@/types/requests'; + +function AllocationRequestsSkeleton() { + return ( + + +
+
+
+
+ {[...Array(5)].map((_, i) => ( +
+ ))} +
+
+ + + ); +} + +export default function AllocationRequestsPage() { + const [requests, setRequests] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + fetch('/api/allocation-requests') + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }) + .then((data) => setRequests(data)) + .catch((err) => setError(err.message)); + }, []); + + return ( +
+

Allocation Requests

+ {error ? ( + + + Failed to load allocation requests: {error} + + + ) : requests === null ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/app/(portal)/shipment-requests/[id]/page.tsx b/src/app/(portal)/shipment-requests/[id]/page.tsx new file mode 100644 index 0000000..56b616f --- /dev/null +++ b/src/app/(portal)/shipment-requests/[id]/page.tsx @@ -0,0 +1,250 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import type { ShipRequestHeader } from '@/types/requests'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { + Table, + TableBody, + TableCell, + TableRow, +} from '@/components/ui/table'; +import { RequestStatusBadge } from '@/components/requests/request-status-badge'; +import { RequestCancelDialog } from '@/components/requests/request-cancel-dialog'; +import { ArrowLeft, Loader2 } from 'lucide-react'; +import { formatDate } from '@/lib/utils'; +import Link from 'next/link'; +import { useToast } from '@/hooks/use-toast'; + +export default function ShipmentRequestDetailPage() { + const params = useParams(); + const router = useRouter(); + const { toast } = useToast(); + const [request, setRequest] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + fetch(`/api/shipment-requests/${params.id}`) + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }) + .then((data) => setRequest(data)) + .catch((err) => setError(err.message)) + .finally(() => setLoading(false)); + }, [params.id]); + + const handleCancel = async () => { + if (!request) return; + const res = await fetch(`/api/shipment-requests/${request.id}/cancel`, { + method: 'POST', + }); + if (!res.ok) { + const err = await res.json(); + toast({ + title: 'Error', + description: err.error || 'Failed to cancel', + variant: 'destructive', + }); + return; + } + toast({ title: 'Request cancelled' }); + router.push('/shipment-requests'); + }; + + if (loading) { + return ( +
+

Shipment Request

+ + + + + +
+ ); + } + + if (error || !request) { + return ( +
+

Shipment Request

+ + + {error || 'Request not found'} + + +
+ ); + } + + return ( +
+
+

Shipment Request

+
+ + {request.is_submitted && !request.is_cancelled && ( + + )} +
+
+ + + + Request Details + + Created {formatDate(new Date(request.created_at))} + {request.submitted_at && + ` | Submitted ${formatDate(new Date(request.submitted_at))}`} + {request.cancelled_at && + ` | Cancelled ${formatDate(new Date(request.cancelled_at))}`} + + + +
+
+ + Ship-To Address + +

{request.ship_to_address || '-'}

+
+
+ + Order # + +

{request.order_number || '-'}

+
+
+ + PO # + +

{request.po_number || '-'}

+
+
+ + Release # + +

{request.release_number || '-'}

+
+
+ + Pickup Date + +

+ {request.pickup_date + ? formatDate(new Date(request.pickup_date)) + : '-'} +

+
+
+ + {request.instructions && ( +
+ + Instructions + +

+ {request.instructions} +

+
+ )} + + {request.email_recipients.length > 0 && ( +
+ + Email Recipients + +
+ {request.email_recipients.map((email) => ( + + {email} + + ))} +
+
+ )} + + {/* Items */} +
+

+ Items ({request.details.length}) +

+
+ + + + + + + + + + + + + {request.details.map((item, i) => ( + + + {item.part_num} + + {item.lot_num || '-'} + {item.plant} + {item.warehouse || '-'} + + {item.quantity} + + {item.notes || '-'} + + ))} + +
+ Part # + + Lot # + + Plant + + Warehouse + + Qty + + Notes +
+
+
+ +
+ + + +
+
+
+
+ ); +} diff --git a/src/app/(portal)/shipment-requests/new/page.tsx b/src/app/(portal)/shipment-requests/new/page.tsx new file mode 100644 index 0000000..927125e --- /dev/null +++ b/src/app/(portal)/shipment-requests/new/page.tsx @@ -0,0 +1,12 @@ +'use client'; + +import { ShipmentRequestCart } from '@/components/requests/shipment-request-cart'; + +export default function NewShipmentRequestPage() { + return ( +
+

New Shipment Request

+ +
+ ); +} diff --git a/src/app/(portal)/shipment-requests/page.tsx b/src/app/(portal)/shipment-requests/page.tsx new file mode 100644 index 0000000..67c6bdd --- /dev/null +++ b/src/app/(portal)/shipment-requests/page.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Card, CardContent } from '@/components/ui/card'; +import { ShipmentRequestList } from '@/components/requests/shipment-request-list'; +import type { ShipRequestListItem } from '@/types/requests'; + +function ShipmentRequestsSkeleton() { + return ( + + +
+
+
+
+ {[...Array(5)].map((_, i) => ( +
+ ))} +
+
+ + + ); +} + +export default function ShipmentRequestsPage() { + const [requests, setRequests] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + fetch('/api/shipment-requests') + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }) + .then((data) => setRequests(data)) + .catch((err) => setError(err.message)); + }, []); + + return ( +
+

Shipment Requests

+ {error ? ( + + + Failed to load shipment requests: {error} + + + ) : requests === null ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/app/api/allocation-requests/[id]/cancel/route.ts b/src/app/api/allocation-requests/[id]/cancel/route.ts new file mode 100644 index 0000000..c8ec56b --- /dev/null +++ b/src/app/api/allocation-requests/[id]/cancel/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from 'next/server'; +import { getQuestSession, getActiveCompany, requirePermission } from '@/lib/permissions'; +import { cancelRequest } from '@/services/alloc-requests'; + +/** + * POST: Cancel an allocation request + */ +export async function POST( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const session = await getQuestSession(); + const activeCompany = await getActiveCompany(); + + if (!session || !activeCompany) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + // Note: using cancel_shipment_request permission for now + // If a separate cancel_allocation_request permission is needed, add it later + await requirePermission('cancel_shipment_request'); + } catch { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + try { + const { id } = await params; + const cancelled = await cancelRequest(id, activeCompany.id, session.user.id); + return NextResponse.json(cancelled); + } catch (error) { + console.error('Error cancelling allocation request:', error); + const message = error instanceof Error ? error.message : 'Internal server error'; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/allocation-requests/[id]/route.ts b/src/app/api/allocation-requests/[id]/route.ts new file mode 100644 index 0000000..bc73dad --- /dev/null +++ b/src/app/api/allocation-requests/[id]/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from 'next/server'; +import { getQuestSession, getActiveCompany, requirePermission } from '@/lib/permissions'; +import { getRequestById } from '@/services/alloc-requests'; + +export const dynamic = 'force-dynamic'; + +/** + * GET: Get a specific allocation request by ID + */ +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const session = await getQuestSession(); + const activeCompany = await getActiveCompany(); + + if (!session || !activeCompany) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + await requirePermission('create_allocation_request'); + } catch { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + try { + const { id } = await params; + const request = await getRequestById(id, activeCompany.id); + + if (!request) { + return NextResponse.json({ error: 'Request not found' }, { status: 404 }); + } + + return NextResponse.json(request); + } catch (error) { + console.error('Error fetching allocation request:', error); + const message = error instanceof Error ? error.message : 'Internal server error'; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/allocation-requests/cart/items/[detailId]/route.ts b/src/app/api/allocation-requests/cart/items/[detailId]/route.ts new file mode 100644 index 0000000..28529d1 --- /dev/null +++ b/src/app/api/allocation-requests/cart/items/[detailId]/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getQuestSession, getActiveCompany, requirePermission } from '@/lib/permissions'; +import { updateCartItem } from '@/services/alloc-requests'; +import type { UpdateCartItemPayload } from '@/types/requests'; + +/** + * PATCH: Update an allocation cart item (quantity, notes) + */ +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ detailId: string }> } +) { + const session = await getQuestSession(); + const activeCompany = await getActiveCompany(); + + if (!session || !activeCompany) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + await requirePermission('create_allocation_request'); + } catch { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + try { + const { detailId } = await params; + const body = (await request.json()) as UpdateCartItemPayload & { request_id: string }; + const { request_id, ...data } = body; + + if (!request_id) { + return NextResponse.json({ error: 'request_id is required' }, { status: 400 }); + } + + const updated = await updateCartItem(detailId, request_id, activeCompany.id, data); + return NextResponse.json(updated); + } catch (error) { + console.error('Error updating allocation cart item:', error); + const message = error instanceof Error ? error.message : 'Internal server error'; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/allocation-requests/cart/items/route.ts b/src/app/api/allocation-requests/cart/items/route.ts new file mode 100644 index 0000000..c5f100d --- /dev/null +++ b/src/app/api/allocation-requests/cart/items/route.ts @@ -0,0 +1,84 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getQuestSession, getActiveCompany, requirePermission } from '@/lib/permissions'; +import { addCartItem, removeCartItem } from '@/services/alloc-requests'; +import type { AddAllocCartItemPayload } from '@/types/requests'; + +/** + * POST: Add an item to the allocation request cart + */ +export async function POST(request: NextRequest) { + const session = await getQuestSession(); + const activeCompany = await getActiveCompany(); + + if (!session || !activeCompany) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + await requirePermission('create_allocation_request'); + } catch { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + try { + const body = (await request.json()) as AddAllocCartItemPayload & { request_id: string }; + const { request_id, ...item } = body; + + if (!request_id) { + return NextResponse.json({ error: 'Request ID is required' }, { status: 400 }); + } + + if (!item.part_num || !item.quantity) { + return NextResponse.json( + { error: 'part_num and quantity are required' }, + { status: 400 } + ); + } + + const detail = await addCartItem(request_id, activeCompany.id, item); + return NextResponse.json(detail, { status: 201 }); + } catch (error) { + console.error('Error adding allocation cart item:', error); + const message = error instanceof Error ? error.message : 'Internal server error'; + const status = message.includes('already exists') ? 409 : 500; + return NextResponse.json({ error: message }, { status }); + } +} + +/** + * DELETE: Remove an item from the allocation request cart + */ +export async function DELETE(request: NextRequest) { + const session = await getQuestSession(); + const activeCompany = await getActiveCompany(); + + if (!session || !activeCompany) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + await requirePermission('create_allocation_request'); + } catch { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + try { + const { searchParams } = request.nextUrl; + const detailId = searchParams.get('detailId'); + const requestId = searchParams.get('requestId'); + + if (!detailId || !requestId) { + return NextResponse.json( + { error: 'detailId and requestId are required' }, + { status: 400 } + ); + } + + await removeCartItem(detailId, requestId, activeCompany.id); + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error removing allocation cart item:', error); + const message = error instanceof Error ? error.message : 'Internal server error'; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/allocation-requests/cart/route.ts b/src/app/api/allocation-requests/cart/route.ts new file mode 100644 index 0000000..64fecd2 --- /dev/null +++ b/src/app/api/allocation-requests/cart/route.ts @@ -0,0 +1,74 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getQuestSession, getActiveCompany, requirePermission } from '@/lib/permissions'; +import { getOrCreateCart, updateCartHeader } from '@/services/alloc-requests'; +import type { UpdateCartHeaderPayload } from '@/types/requests'; + +export const dynamic = 'force-dynamic'; + +/** + * GET: Get or create an active allocation request cart. + * Requires ?jobNumber= query parameter. + */ +export async function GET(request: NextRequest) { + const session = await getQuestSession(); + const activeCompany = await getActiveCompany(); + + if (!session || !activeCompany) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + await requirePermission('create_allocation_request'); + } catch { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + try { + const jobNumber = request.nextUrl.searchParams.get('jobNumber'); + + if (!jobNumber) { + return NextResponse.json({ error: 'jobNumber is required' }, { status: 400 }); + } + + const cart = await getOrCreateCart(session.user.id, activeCompany.id, jobNumber); + return NextResponse.json(cart); + } catch (error) { + console.error('Error getting allocation cart:', error); + const message = error instanceof Error ? error.message : 'Internal server error'; + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +/** + * PATCH: Update cart header fields + */ +export async function PATCH(request: NextRequest) { + const session = await getQuestSession(); + const activeCompany = await getActiveCompany(); + + if (!session || !activeCompany) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + await requirePermission('create_allocation_request'); + } catch { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + try { + const body = (await request.json()) as UpdateCartHeaderPayload & { id: string }; + const { id, ...data } = body; + + if (!id) { + return NextResponse.json({ error: 'Cart ID is required' }, { status: 400 }); + } + + const updated = await updateCartHeader(id, activeCompany.id, data); + return NextResponse.json(updated); + } catch (error) { + console.error('Error updating allocation cart:', error); + const message = error instanceof Error ? error.message : 'Internal server error'; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/allocation-requests/cart/submit/route.ts b/src/app/api/allocation-requests/cart/submit/route.ts new file mode 100644 index 0000000..98e7c97 --- /dev/null +++ b/src/app/api/allocation-requests/cart/submit/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getQuestSession, getActiveCompany, requirePermission } from '@/lib/permissions'; +import { submitCart } from '@/services/alloc-requests'; + +/** + * POST: Submit the allocation request cart + */ +export async function POST(request: NextRequest) { + const session = await getQuestSession(); + const activeCompany = await getActiveCompany(); + + if (!session || !activeCompany) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + await requirePermission('create_allocation_request'); + } catch { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + try { + const body = (await request.json()) as { id: string }; + + if (!body.id) { + return NextResponse.json({ error: 'Cart ID is required' }, { status: 400 }); + } + + const submitted = await submitCart(body.id, activeCompany.id, session.user.id); + return NextResponse.json(submitted); + } catch (error) { + console.error('Error submitting allocation request:', error); + const message = error instanceof Error ? error.message : 'Internal server error'; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/allocation-requests/route.ts b/src/app/api/allocation-requests/route.ts new file mode 100644 index 0000000..4998797 --- /dev/null +++ b/src/app/api/allocation-requests/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from 'next/server'; +import { getQuestSession, getActiveCompany, requirePermission } from '@/lib/permissions'; +import { getRequests } from '@/services/alloc-requests'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + const session = await getQuestSession(); + const activeCompany = await getActiveCompany(); + + if (!session || !activeCompany) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + await requirePermission('create_allocation_request'); + } catch { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + try { + const requests = await getRequests(activeCompany.id); + return NextResponse.json(requests); + } catch (error) { + console.error('Error fetching allocation requests:', error); + const message = error instanceof Error ? error.message : 'Internal server error'; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/available-coils/route.ts b/src/app/api/available-coils/route.ts new file mode 100644 index 0000000..451f24a --- /dev/null +++ b/src/app/api/available-coils/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getQuestSession, getActiveCompany, requirePermission } from '@/lib/permissions'; +import { getAvailableCoils } from '@/services/available-coils'; + +export const dynamic = 'force-dynamic'; + +/** + * GET: Get available coils for allocation to a specific job + */ +export async function GET(request: NextRequest) { + const session = await getQuestSession(); + const activeCompany = await getActiveCompany(); + + if (!session || !activeCompany) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + await requirePermission('create_allocation_request'); + } catch { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + try { + const jobNumber = request.nextUrl.searchParams.get('jobNumber'); + + if (!jobNumber) { + return NextResponse.json({ error: 'jobNumber is required' }, { status: 400 }); + } + + const dbName = `[${process.env.PORTAL_DB_NAME || 'VorteqPortal'}]`; + + const coils = await getAvailableCoils( + activeCompany.epicor_cust_id, + dbName, + jobNumber + ); + + return NextResponse.json(coils); + } catch (error) { + console.error('Error fetching available coils:', error); + const message = error instanceof Error ? error.message : 'Internal server error'; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/ship-to-addresses/route.ts b/src/app/api/ship-to-addresses/route.ts new file mode 100644 index 0000000..06d02b0 --- /dev/null +++ b/src/app/api/ship-to-addresses/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from 'next/server'; +import { getQuestSession, getActiveCompany, isSubUser } from '@/lib/permissions'; +import { getShipToAddresses } from '@/services/ship-to-addresses'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + try { + const session = await getQuestSession(); + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const activeCompany = await getActiveCompany(); + if (!activeCompany) { + return NextResponse.json({ error: 'No active company' }, { status: 401 }); + } + + const dbName = `[${process.env.PORTAL_DB_NAME || 'VorteqPortal'}]`; + const sub = await isSubUser(); + + const addresses = await getShipToAddresses( + activeCompany.epicor_cust_id, + dbName, + sub + ); + + return NextResponse.json(addresses); + } catch (error) { + console.error('Error fetching ship-to addresses:', error); + const message = error instanceof Error ? error.message : 'Internal server error'; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/shipment-requests/[id]/cancel/route.ts b/src/app/api/shipment-requests/[id]/cancel/route.ts new file mode 100644 index 0000000..f3da4e3 --- /dev/null +++ b/src/app/api/shipment-requests/[id]/cancel/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from 'next/server'; +import { getQuestSession, getActiveCompany, requirePermission } from '@/lib/permissions'; +import { cancelRequest } from '@/services/ship-requests'; + +/** + * POST: Cancel a shipment request + */ +export async function POST( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const session = await getQuestSession(); + const activeCompany = await getActiveCompany(); + + if (!session || !activeCompany) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + await requirePermission('cancel_shipment_request'); + } catch { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + try { + const { id } = await params; + const cancelled = await cancelRequest(id, activeCompany.id, session.user.id); + return NextResponse.json(cancelled); + } catch (error) { + console.error('Error cancelling shipment request:', error); + const message = error instanceof Error ? error.message : 'Internal server error'; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/shipment-requests/[id]/route.ts b/src/app/api/shipment-requests/[id]/route.ts new file mode 100644 index 0000000..1d6cc75 --- /dev/null +++ b/src/app/api/shipment-requests/[id]/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from 'next/server'; +import { getQuestSession, getActiveCompany, requirePermission } from '@/lib/permissions'; +import { getRequestById } from '@/services/ship-requests'; + +export const dynamic = 'force-dynamic'; + +/** + * GET: Get a specific shipment request by ID + */ +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const session = await getQuestSession(); + const activeCompany = await getActiveCompany(); + + if (!session || !activeCompany) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + await requirePermission('create_shipment_request'); + } catch { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + try { + const { id } = await params; + const request = await getRequestById(id, activeCompany.id); + + if (!request) { + return NextResponse.json({ error: 'Request not found' }, { status: 404 }); + } + + return NextResponse.json(request); + } catch (error) { + console.error('Error fetching shipment request:', error); + const message = error instanceof Error ? error.message : 'Internal server error'; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/shipment-requests/cart/items/[detailId]/route.ts b/src/app/api/shipment-requests/cart/items/[detailId]/route.ts new file mode 100644 index 0000000..77224fc --- /dev/null +++ b/src/app/api/shipment-requests/cart/items/[detailId]/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getQuestSession, getActiveCompany, requirePermission } from '@/lib/permissions'; +import { updateCartItem } from '@/services/ship-requests'; +import type { UpdateCartItemPayload } from '@/types/requests'; + +/** + * PATCH: Update a cart item (quantity, notes) + */ +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ detailId: string }> } +) { + const session = await getQuestSession(); + const activeCompany = await getActiveCompany(); + + if (!session || !activeCompany) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + await requirePermission('create_shipment_request'); + } catch { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + try { + const { detailId } = await params; + const body = (await request.json()) as UpdateCartItemPayload & { request_id: string }; + const { request_id, ...data } = body; + + if (!request_id) { + return NextResponse.json({ error: 'request_id is required' }, { status: 400 }); + } + + const updated = await updateCartItem(detailId, request_id, activeCompany.id, data); + return NextResponse.json(updated); + } catch (error) { + console.error('Error updating cart item:', error); + const message = error instanceof Error ? error.message : 'Internal server error'; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/shipment-requests/cart/items/route.ts b/src/app/api/shipment-requests/cart/items/route.ts new file mode 100644 index 0000000..229bf48 --- /dev/null +++ b/src/app/api/shipment-requests/cart/items/route.ts @@ -0,0 +1,84 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getQuestSession, getActiveCompany, requirePermission } from '@/lib/permissions'; +import { addCartItem, removeCartItem } from '@/services/ship-requests'; +import type { AddShipCartItemPayload } from '@/types/requests'; + +/** + * POST: Add an item to the shipment request cart + */ +export async function POST(request: NextRequest) { + const session = await getQuestSession(); + const activeCompany = await getActiveCompany(); + + if (!session || !activeCompany) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + await requirePermission('create_shipment_request'); + } catch { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + try { + const body = (await request.json()) as AddShipCartItemPayload & { request_id: string }; + const { request_id, ...item } = body; + + if (!request_id) { + return NextResponse.json({ error: 'Request ID is required' }, { status: 400 }); + } + + if (!item.part_num || !item.plant || !item.quantity) { + return NextResponse.json( + { error: 'part_num, plant, and quantity are required' }, + { status: 400 } + ); + } + + const detail = await addCartItem(request_id, activeCompany.id, item); + return NextResponse.json(detail, { status: 201 }); + } catch (error) { + console.error('Error adding cart item:', error); + const message = error instanceof Error ? error.message : 'Internal server error'; + const status = message.includes('already exists') ? 409 : 500; + return NextResponse.json({ error: message }, { status }); + } +} + +/** + * DELETE: Remove an item from the shipment request cart + */ +export async function DELETE(request: NextRequest) { + const session = await getQuestSession(); + const activeCompany = await getActiveCompany(); + + if (!session || !activeCompany) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + await requirePermission('create_shipment_request'); + } catch { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + try { + const { searchParams } = request.nextUrl; + const detailId = searchParams.get('detailId'); + const requestId = searchParams.get('requestId'); + + if (!detailId || !requestId) { + return NextResponse.json( + { error: 'detailId and requestId are required' }, + { status: 400 } + ); + } + + await removeCartItem(detailId, requestId, activeCompany.id); + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error removing cart item:', error); + const message = error instanceof Error ? error.message : 'Internal server error'; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/shipment-requests/cart/route.ts b/src/app/api/shipment-requests/cart/route.ts new file mode 100644 index 0000000..2386ef3 --- /dev/null +++ b/src/app/api/shipment-requests/cart/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getQuestSession, getActiveCompany, requirePermission } from '@/lib/permissions'; +import { getOrCreateCart, updateCartHeader } from '@/services/ship-requests'; +import type { UpdateCartHeaderPayload } from '@/types/requests'; + +export const dynamic = 'force-dynamic'; + +/** + * GET: Get or create an active shipment request cart + */ +export async function GET() { + const session = await getQuestSession(); + const activeCompany = await getActiveCompany(); + + if (!session || !activeCompany) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + await requirePermission('create_shipment_request'); + } catch { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + try { + const cart = await getOrCreateCart(session.user.id, activeCompany.id); + return NextResponse.json(cart); + } catch (error) { + console.error('Error getting shipment cart:', error); + const message = error instanceof Error ? error.message : 'Internal server error'; + return NextResponse.json({ error: message }, { status: 500 }); + } +} + +/** + * PATCH: Update cart header fields (ship-to, order#, PO#, etc.) + */ +export async function PATCH(request: NextRequest) { + const session = await getQuestSession(); + const activeCompany = await getActiveCompany(); + + if (!session || !activeCompany) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + await requirePermission('create_shipment_request'); + } catch { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + try { + const body = (await request.json()) as UpdateCartHeaderPayload & { id: string }; + const { id, ...data } = body; + + if (!id) { + return NextResponse.json({ error: 'Cart ID is required' }, { status: 400 }); + } + + const updated = await updateCartHeader(id, activeCompany.id, data); + return NextResponse.json(updated); + } catch (error) { + console.error('Error updating shipment cart:', error); + const message = error instanceof Error ? error.message : 'Internal server error'; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/shipment-requests/cart/submit/route.ts b/src/app/api/shipment-requests/cart/submit/route.ts new file mode 100644 index 0000000..ea4a3de --- /dev/null +++ b/src/app/api/shipment-requests/cart/submit/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getQuestSession, getActiveCompany, requirePermission } from '@/lib/permissions'; +import { submitCart } from '@/services/ship-requests'; + +/** + * POST: Submit the shipment request cart + */ +export async function POST(request: NextRequest) { + const session = await getQuestSession(); + const activeCompany = await getActiveCompany(); + + if (!session || !activeCompany) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + await requirePermission('create_shipment_request'); + } catch { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + try { + const body = (await request.json()) as { id: string }; + + if (!body.id) { + return NextResponse.json({ error: 'Cart ID is required' }, { status: 400 }); + } + + const submitted = await submitCart(body.id, activeCompany.id, session.user.id); + return NextResponse.json(submitted); + } catch (error) { + console.error('Error submitting shipment request:', error); + const message = error instanceof Error ? error.message : 'Internal server error'; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/shipment-requests/route.ts b/src/app/api/shipment-requests/route.ts new file mode 100644 index 0000000..e5a3538 --- /dev/null +++ b/src/app/api/shipment-requests/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from 'next/server'; +import { getQuestSession, getActiveCompany, requirePermission } from '@/lib/permissions'; +import { getRequests } from '@/services/ship-requests'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + const session = await getQuestSession(); + const activeCompany = await getActiveCompany(); + + if (!session || !activeCompany) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + await requirePermission('create_shipment_request'); + } catch { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + try { + const requests = await getRequests(activeCompany.id); + return NextResponse.json(requests); + } catch (error) { + console.error('Error fetching shipment requests:', error); + const message = error instanceof Error ? error.message : 'Internal server error'; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/components/requests/allocation-request-cart.tsx b/src/components/requests/allocation-request-cart.tsx new file mode 100644 index 0000000..313eaae --- /dev/null +++ b/src/components/requests/allocation-request-cart.tsx @@ -0,0 +1,332 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import type { + AllocRequestHeader, + AddAllocCartItemPayload, + UpdateCartHeaderPayload, +} from '@/types/requests'; +import { RequestStepper, type StepConfig } from './request-stepper'; +import { ShipToSelector } from './ship-to-selector'; +import { CartItemsTable } from './cart-items-table'; +import { CartHeaderForm } from './cart-header-form'; +import { CartReview } from './cart-review'; +import { CoilBrowserDialog } from './coil-browser-dialog'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Button } from '@/components/ui/button'; +import { Loader2, ArrowLeft, ArrowRight, Briefcase } from 'lucide-react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useToast } from '@/hooks/use-toast'; + +const STEPS: StepConfig[] = [ + { label: 'Job & Address', description: 'Job# + ship-to' }, + { label: 'Items', description: 'Add coils' }, + { label: 'Details', description: 'Order info' }, + { label: 'Review', description: 'Submit' }, +]; + +export function AllocationRequestCart() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { toast } = useToast(); + const [cart, setCart] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [currentStep, setCurrentStep] = useState(0); + const [browserOpen, setBrowserOpen] = useState(false); + const [jobNumber, setJobNumber] = useState(searchParams.get('job') || ''); + const [jobConfirmed, setJobConfirmed] = useState(false); + + // Fetch or create cart for a specific job + const fetchCart = useCallback(async (job: string) => { + setLoading(true); + setError(null); + try { + const res = await fetch( + `/api/allocation-requests/cart?jobNumber=${encodeURIComponent(job)}` + ); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + setCart(data); + setJobConfirmed(true); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load cart'); + } finally { + setLoading(false); + } + }, []); + + // If job number came from URL, fetch cart immediately + useEffect(() => { + const urlJob = searchParams.get('job'); + if (urlJob) { + fetchCart(urlJob); + } + }, [searchParams, fetchCart]); + + const handleConfirmJob = () => { + if (!jobNumber.trim()) return; + fetchCart(jobNumber.trim()); + }; + + // Update cart header + const updateHeader = async (data: UpdateCartHeaderPayload) => { + if (!cart) return; + const res = await fetch('/api/allocation-requests/cart', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: cart.id, ...data }), + }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || 'Failed to update cart'); + } + const updated = await res.json(); + setCart(updated); + }; + + // Add item + const addItem = async (item: AddAllocCartItemPayload) => { + if (!cart) return; + const res = await fetch('/api/allocation-requests/cart/items', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ request_id: cart.id, ...item }), + }); + if (!res.ok) { + const err = await res.json(); + toast({ + title: 'Error', + description: err.error || 'Failed to add item', + variant: 'destructive', + }); + return; + } + toast({ title: 'Item added to cart' }); + await fetchCart(cart.job_number); + }; + + // Remove item + const removeItem = async (detailId: string) => { + if (!cart) return; + const res = await fetch( + `/api/allocation-requests/cart/items?detailId=${detailId}&requestId=${cart.id}`, + { method: 'DELETE' } + ); + if (!res.ok) { + const err = await res.json(); + toast({ + title: 'Error', + description: err.error || 'Failed to remove item', + variant: 'destructive', + }); + return; + } + await fetchCart(cart.job_number); + }; + + // Update item + const updateItem = async ( + detailId: string, + data: { quantity?: number; notes?: string | null } + ) => { + if (!cart) return; + const res = await fetch( + `/api/allocation-requests/cart/items/${detailId}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ request_id: cart.id, ...data }), + } + ); + if (!res.ok) { + const err = await res.json(); + toast({ + title: 'Error', + description: err.error || 'Failed to update item', + variant: 'destructive', + }); + return; + } + await fetchCart(cart.job_number); + }; + + // Submit + const submitCart = async () => { + if (!cart) return; + const res = await fetch('/api/allocation-requests/cart/submit', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: cart.id }), + }); + if (!res.ok) { + const err = await res.json(); + toast({ + title: 'Error', + description: err.error || 'Failed to submit request', + variant: 'destructive', + }); + return; + } + toast({ title: 'Allocation request submitted successfully!' }); + router.push('/allocation-requests'); + }; + + // Handle ship-to selection + const handleSelectAddress = (address: string) => { + updateHeader({ ship_to_address: address }); + }; + + // Job number entry step (before cart is loaded) + if (!jobConfirmed) { + return ( +
+ + + + + + Job Number + + + +
+ +
+ setJobNumber(e.target.value)} + placeholder="e.g. J12345" + onKeyDown={(e) => e.key === 'Enter' && handleConfirmJob()} + /> + +
+
+ {error && ( +

{error}

+ )} +
+
+
+ ); + } + + if (loading) { + return ( + + + + + + ); + } + + if (error || !cart) { + return ( + + + Failed to load cart: {error || 'Unknown error'} + + + ); + } + + return ( +
+ + + {/* Job number indicator */} +
+ + Job: {cart.job_number} +
+ + {/* Step 0: Ship-To Address */} + {currentStep === 0 && ( + setCurrentStep(1)} + /> + )} + + {/* Step 1: Items */} + {currentStep === 1 && ( + <> + setBrowserOpen(true)} + /> +
+ + +
+ setBrowserOpen(false)} + jobNumber={cart.job_number} + onAddItem={addItem} + /> + + )} + + {/* Step 2: Details */} + {currentStep === 2 && ( + setCurrentStep(1)} + onNext={() => setCurrentStep(3)} + /> + )} + + {/* Step 3: Review */} + {currentStep === 3 && ( + setCurrentStep(2)} + /> + )} +
+ ); +} diff --git a/src/components/requests/allocation-request-list.tsx b/src/components/requests/allocation-request-list.tsx new file mode 100644 index 0000000..69265e2 --- /dev/null +++ b/src/components/requests/allocation-request-list.tsx @@ -0,0 +1,207 @@ +'use client'; + +import { useState } from 'react'; +import type { AllocRequestListItem } from '@/types/requests'; +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 { + SortableTableHead, + useSortableTable, +} from '@/components/ui/sortable-table-head'; +import { RequestStatusBadge } from './request-status-badge'; +import { Search, Plus, Eye, Pencil } from 'lucide-react'; +import { formatDate } from '@/lib/utils'; +import Link from 'next/link'; + +type Props = { + data: AllocRequestListItem[]; +}; + +export function AllocationRequestList({ data }: Props) { + const [searchTerm, setSearchTerm] = useState(''); + + const filteredData = data.filter((row) => { + const s = searchTerm.toLowerCase(); + return ( + row.job_number.toLowerCase().includes(s) || + row.ship_to_address.toLowerCase().includes(s) || + (row.order_number || '').toLowerCase().includes(s) || + (row.po_number || '').toLowerCase().includes(s) + ); + }); + + const { sortKey, sortDirection, handleSort, sortedData } = + useSortableTable(filteredData); + + return ( + + +
+
+ Allocation Requests + + {sortedData.length} request{sortedData.length !== 1 ? 's' : ''} + +
+ + + +
+
+ +
+ + setSearchTerm(e.target.value)} + className="pl-8" + /> +
+ +
+ + + + + Date + + + Job # + + + Ship-To + + + Order # + + + Items + + + + + + + {sortedData.length === 0 ? ( + + + No allocation requests found + + + ) : ( + sortedData.map((row, i) => { + const isDraft = !row.is_submitted && !row.is_cancelled; + return ( + + + {formatDate(new Date(row.created_at))} + + + {row.job_number} + + + {row.ship_to_address || '-'} + + {row.order_number || '-'} + + {row.detail_count} + + + + + + {isDraft ? ( + + + + ) : ( + + + + )} + + + ); + }) + )} + +
+ Status + + Action +
+
+ +
+ Showing {sortedData.length} of {data.length} requests +
+
+
+ ); +} diff --git a/src/components/requests/cart-header-form.tsx b/src/components/requests/cart-header-form.tsx new file mode 100644 index 0000000..0c51d37 --- /dev/null +++ b/src/components/requests/cart-header-form.tsx @@ -0,0 +1,221 @@ +'use client'; + +import { useState } from 'react'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Button } from '@/components/ui/button'; +import { ArrowLeft, ArrowRight, FileText, X } from 'lucide-react'; +import type { UpdateCartHeaderPayload } from '@/types/requests'; + +type Props = { + initialData: { + order_number: string | null; + po_number: string | null; + release_number: string | null; + pickup_date: string | null; + instructions: string | null; + email_recipients: string[]; + }; + onSave: (data: UpdateCartHeaderPayload) => Promise; + onBack: () => void; + onNext: () => void; + readOnly?: boolean; +}; + +export function CartHeaderForm({ + initialData, + onSave, + onBack, + onNext, + readOnly = false, +}: Props) { + const [orderNumber, setOrderNumber] = useState(initialData.order_number || ''); + const [poNumber, setPoNumber] = useState(initialData.po_number || ''); + const [releaseNumber, setReleaseNumber] = useState(initialData.release_number || ''); + const [pickupDate, setPickupDate] = useState( + initialData.pickup_date ? initialData.pickup_date.split('T')[0] : '' + ); + const [instructions, setInstructions] = useState(initialData.instructions || ''); + const [emailRecipients, setEmailRecipients] = useState( + initialData.email_recipients || [] + ); + const [emailInput, setEmailInput] = useState(''); + const [saving, setSaving] = useState(false); + + const handleAddEmail = () => { + const email = emailInput.trim(); + if (email && email.includes('@') && !emailRecipients.includes(email)) { + setEmailRecipients((prev) => [...prev, email]); + setEmailInput(''); + } + }; + + const handleRemoveEmail = (email: string) => { + setEmailRecipients((prev) => prev.filter((e) => e !== email)); + }; + + const handleEmailKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault(); + handleAddEmail(); + } + }; + + const handleNext = async () => { + if (readOnly) { + onNext(); + return; + } + + setSaving(true); + try { + await onSave({ + order_number: orderNumber || null, + po_number: poNumber || null, + release_number: releaseNumber || null, + pickup_date: pickupDate || null, + instructions: instructions || null, + email_recipients: emailRecipients, + }); + onNext(); + } finally { + setSaving(false); + } + }; + + return ( + + + + + Request Details + + + Add optional order details and notification preferences + + + +
+
+ + setOrderNumber(e.target.value)} + placeholder="Optional" + disabled={readOnly} + /> +
+
+ + setPoNumber(e.target.value)} + placeholder="Optional" + disabled={readOnly} + /> +
+
+ + setReleaseNumber(e.target.value)} + placeholder="Optional" + disabled={readOnly} + /> +
+
+ + setPickupDate(e.target.value)} + disabled={readOnly} + /> +
+
+ +
+ +