feat: add shipment request (C-013) and allocation request (C-014) cart workflows
Some checks failed
Build and Deploy / build (push) Successful in 4m39s
Build and Deploy / deploy (push) Failing after 1s

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 <noreply@anthropic.com>
This commit is contained in:
Lorentz Hinrichsen 2026-02-17 10:37:38 -05:00
parent b32553668d
commit e60e07c9a5
41 changed files with 5106 additions and 20 deletions

View file

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

View file

@ -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<AllocRequestHeader | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div>
<h1 className="mb-6 text-3xl font-bold">Allocation Request</h1>
<Card>
<CardContent className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</CardContent>
</Card>
</div>
);
}
if (error || !request) {
return (
<div>
<h1 className="mb-6 text-3xl font-bold">Allocation Request</h1>
<Card>
<CardContent className="p-6 text-center text-destructive">
{error || 'Request not found'}
</CardContent>
</Card>
</div>
);
}
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-3xl font-bold">Allocation Request</h1>
<div className="flex items-center gap-3">
<RequestStatusBadge
isSubmitted={request.is_submitted}
isCancelled={request.is_cancelled}
/>
{request.is_submitted && !request.is_cancelled && (
<RequestCancelDialog
requestType="allocation"
onConfirm={handleCancel}
/>
)}
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Request Details</CardTitle>
<CardDescription>
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))}`}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<div>
<span className="text-xs font-semibold text-muted-foreground uppercase">
Job Number
</span>
<p className="text-sm font-medium">{request.job_number}</p>
</div>
<div>
<span className="text-xs font-semibold text-muted-foreground uppercase">
Ship-To Address
</span>
<p className="text-sm">{request.ship_to_address || '-'}</p>
</div>
<div>
<span className="text-xs font-semibold text-muted-foreground uppercase">
Order #
</span>
<p className="text-sm">{request.order_number || '-'}</p>
</div>
<div>
<span className="text-xs font-semibold text-muted-foreground uppercase">
PO #
</span>
<p className="text-sm">{request.po_number || '-'}</p>
</div>
<div>
<span className="text-xs font-semibold text-muted-foreground uppercase">
Release #
</span>
<p className="text-sm">{request.release_number || '-'}</p>
</div>
<div>
<span className="text-xs font-semibold text-muted-foreground uppercase">
Pickup Date
</span>
<p className="text-sm">
{request.pickup_date
? formatDate(new Date(request.pickup_date))
: '-'}
</p>
</div>
</div>
{request.instructions && (
<div>
<span className="text-xs font-semibold text-muted-foreground uppercase">
Instructions
</span>
<p className="whitespace-pre-wrap text-sm">
{request.instructions}
</p>
</div>
)}
{request.email_recipients.length > 0 && (
<div>
<span className="text-xs font-semibold text-muted-foreground uppercase">
Email Recipients
</span>
<div className="mt-1 flex flex-wrap gap-1">
{request.email_recipients.map((email) => (
<span
key={email}
className="rounded-full bg-teal-100 px-2.5 py-0.5 text-xs font-medium text-teal-800"
>
{email}
</span>
))}
</div>
</div>
)}
{/* Items */}
<div className="pt-2">
<h3 className="mb-2 text-sm font-semibold text-muted-foreground uppercase">
Items ({request.details.length})
</h3>
<div className="overflow-hidden rounded-md border">
<Table>
<thead>
<tr className="bg-teal-700 text-white">
<th className="px-3 py-2 text-left text-xs font-semibold uppercase">
Part #
</th>
<th className="px-3 py-2 text-left text-xs font-semibold uppercase">
Lot #
</th>
<th className="px-3 py-2 text-left text-xs font-semibold uppercase">
Coil #
</th>
<th className="px-3 py-2 text-right text-xs font-semibold uppercase">
Qty
</th>
<th className="px-3 py-2 text-left text-xs font-semibold uppercase">
Notes
</th>
</tr>
</thead>
<TableBody>
{request.details.map((item, i) => (
<TableRow
key={item.id}
className={i % 2 === 0 ? 'bg-muted/30' : ''}
>
<TableCell className="font-medium">
{item.part_num}
</TableCell>
<TableCell>{item.lot_num || '-'}</TableCell>
<TableCell>{item.coil_number || '-'}</TableCell>
<TableCell className="text-right">
{item.quantity}
</TableCell>
<TableCell>{item.notes || '-'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
<div className="pt-4">
<Link href="/allocation-requests">
<Button variant="outline">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to List
</Button>
</Link>
</div>
</CardContent>
</Card>
</div>
);
}

View file

@ -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 (
<Card>
<CardContent className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</CardContent>
</Card>
);
}
export default function NewAllocationRequestPage() {
return (
<div>
<h1 className="mb-6 text-3xl font-bold">New Allocation Request</h1>
<Suspense fallback={<CartFallback />}>
<AllocationRequestCart />
</Suspense>
</div>
);
}

View file

@ -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 (
<Card>
<CardContent className="p-6">
<div className="space-y-4">
<div className="h-8 w-48 animate-pulse rounded bg-muted" />
<div className="h-10 w-full animate-pulse rounded bg-muted" />
<div className="space-y-2">
{[...Array(5)].map((_, i) => (
<div
key={i}
className="h-12 w-full animate-pulse rounded bg-muted"
/>
))}
</div>
</div>
</CardContent>
</Card>
);
}
export default function AllocationRequestsPage() {
const [requests, setRequests] = useState<AllocRequestListItem[] | null>(null);
const [error, setError] = useState<string | null>(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 (
<div>
<h1 className="mb-6 text-3xl font-bold">Allocation Requests</h1>
{error ? (
<Card>
<CardContent className="p-6 text-center text-destructive">
Failed to load allocation requests: {error}
</CardContent>
</Card>
) : requests === null ? (
<AllocationRequestsSkeleton />
) : (
<AllocationRequestList data={requests} />
)}
</div>
);
}

View file

@ -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<ShipRequestHeader | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div>
<h1 className="mb-6 text-3xl font-bold">Shipment Request</h1>
<Card>
<CardContent className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</CardContent>
</Card>
</div>
);
}
if (error || !request) {
return (
<div>
<h1 className="mb-6 text-3xl font-bold">Shipment Request</h1>
<Card>
<CardContent className="p-6 text-center text-destructive">
{error || 'Request not found'}
</CardContent>
</Card>
</div>
);
}
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-3xl font-bold">Shipment Request</h1>
<div className="flex items-center gap-3">
<RequestStatusBadge
isSubmitted={request.is_submitted}
isCancelled={request.is_cancelled}
/>
{request.is_submitted && !request.is_cancelled && (
<RequestCancelDialog
requestType="shipment"
onConfirm={handleCancel}
/>
)}
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Request Details</CardTitle>
<CardDescription>
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))}`}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<div>
<span className="text-xs font-semibold text-muted-foreground uppercase">
Ship-To Address
</span>
<p className="text-sm">{request.ship_to_address || '-'}</p>
</div>
<div>
<span className="text-xs font-semibold text-muted-foreground uppercase">
Order #
</span>
<p className="text-sm">{request.order_number || '-'}</p>
</div>
<div>
<span className="text-xs font-semibold text-muted-foreground uppercase">
PO #
</span>
<p className="text-sm">{request.po_number || '-'}</p>
</div>
<div>
<span className="text-xs font-semibold text-muted-foreground uppercase">
Release #
</span>
<p className="text-sm">{request.release_number || '-'}</p>
</div>
<div>
<span className="text-xs font-semibold text-muted-foreground uppercase">
Pickup Date
</span>
<p className="text-sm">
{request.pickup_date
? formatDate(new Date(request.pickup_date))
: '-'}
</p>
</div>
</div>
{request.instructions && (
<div>
<span className="text-xs font-semibold text-muted-foreground uppercase">
Instructions
</span>
<p className="whitespace-pre-wrap text-sm">
{request.instructions}
</p>
</div>
)}
{request.email_recipients.length > 0 && (
<div>
<span className="text-xs font-semibold text-muted-foreground uppercase">
Email Recipients
</span>
<div className="mt-1 flex flex-wrap gap-1">
{request.email_recipients.map((email) => (
<span
key={email}
className="rounded-full bg-teal-100 px-2.5 py-0.5 text-xs font-medium text-teal-800"
>
{email}
</span>
))}
</div>
</div>
)}
{/* Items */}
<div className="pt-2">
<h3 className="mb-2 text-sm font-semibold text-muted-foreground uppercase">
Items ({request.details.length})
</h3>
<div className="overflow-hidden rounded-md border">
<Table>
<thead>
<tr className="bg-teal-700 text-white">
<th className="px-3 py-2 text-left text-xs font-semibold uppercase">
Part #
</th>
<th className="px-3 py-2 text-left text-xs font-semibold uppercase">
Lot #
</th>
<th className="px-3 py-2 text-left text-xs font-semibold uppercase">
Plant
</th>
<th className="px-3 py-2 text-left text-xs font-semibold uppercase">
Warehouse
</th>
<th className="px-3 py-2 text-right text-xs font-semibold uppercase">
Qty
</th>
<th className="px-3 py-2 text-left text-xs font-semibold uppercase">
Notes
</th>
</tr>
</thead>
<TableBody>
{request.details.map((item, i) => (
<TableRow
key={item.id}
className={i % 2 === 0 ? 'bg-muted/30' : ''}
>
<TableCell className="font-medium">
{item.part_num}
</TableCell>
<TableCell>{item.lot_num || '-'}</TableCell>
<TableCell>{item.plant}</TableCell>
<TableCell>{item.warehouse || '-'}</TableCell>
<TableCell className="text-right">
{item.quantity}
</TableCell>
<TableCell>{item.notes || '-'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
<div className="pt-4">
<Link href="/shipment-requests">
<Button variant="outline">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to List
</Button>
</Link>
</div>
</CardContent>
</Card>
</div>
);
}

View file

@ -0,0 +1,12 @@
'use client';
import { ShipmentRequestCart } from '@/components/requests/shipment-request-cart';
export default function NewShipmentRequestPage() {
return (
<div>
<h1 className="mb-6 text-3xl font-bold">New Shipment Request</h1>
<ShipmentRequestCart />
</div>
);
}

View file

@ -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 (
<Card>
<CardContent className="p-6">
<div className="space-y-4">
<div className="h-8 w-48 animate-pulse rounded bg-muted" />
<div className="h-10 w-full animate-pulse rounded bg-muted" />
<div className="space-y-2">
{[...Array(5)].map((_, i) => (
<div
key={i}
className="h-12 w-full animate-pulse rounded bg-muted"
/>
))}
</div>
</div>
</CardContent>
</Card>
);
}
export default function ShipmentRequestsPage() {
const [requests, setRequests] = useState<ShipRequestListItem[] | null>(null);
const [error, setError] = useState<string | null>(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 (
<div>
<h1 className="mb-6 text-3xl font-bold">Shipment Requests</h1>
{error ? (
<Card>
<CardContent className="p-6 text-center text-destructive">
Failed to load shipment requests: {error}
</CardContent>
</Card>
) : requests === null ? (
<ShipmentRequestsSkeleton />
) : (
<ShipmentRequestList data={requests} />
)}
</div>
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<AllocRequestHeader | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="space-y-4">
<RequestStepper steps={STEPS} currentStep={0} />
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Briefcase className="h-5 w-5" />
Job Number
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="jobNumber">
Enter the Epicor job number for this allocation request
</Label>
<div className="mt-2 flex gap-2">
<Input
id="jobNumber"
value={jobNumber}
onChange={(e) => setJobNumber(e.target.value)}
placeholder="e.g. J12345"
onKeyDown={(e) => e.key === 'Enter' && handleConfirmJob()}
/>
<Button
onClick={handleConfirmJob}
disabled={!jobNumber.trim() || loading}
className="bg-teal-700 hover:bg-teal-800"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Continue'
)}
</Button>
</div>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</CardContent>
</Card>
</div>
);
}
if (loading) {
return (
<Card>
<CardContent className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</CardContent>
</Card>
);
}
if (error || !cart) {
return (
<Card>
<CardContent className="p-6 text-center text-destructive">
Failed to load cart: {error || 'Unknown error'}
</CardContent>
</Card>
);
}
return (
<div className="space-y-4">
<RequestStepper
steps={STEPS}
currentStep={currentStep}
onStepClick={setCurrentStep}
/>
{/* Job number indicator */}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Briefcase className="h-4 w-4" />
Job: <span className="font-semibold text-foreground">{cart.job_number}</span>
</div>
{/* Step 0: Ship-To Address */}
{currentStep === 0 && (
<ShipToSelector
selectedAddress={cart.ship_to_address}
onSelect={handleSelectAddress}
onNext={() => setCurrentStep(1)}
/>
)}
{/* Step 1: Items */}
{currentStep === 1 && (
<>
<CartItemsTable
items={cart.details}
variant="alloc"
onRemoveItem={removeItem}
onUpdateItem={updateItem}
onOpenBrowser={() => setBrowserOpen(true)}
/>
<div className="flex justify-between">
<Button variant="outline" onClick={() => setCurrentStep(0)}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button
onClick={() => setCurrentStep(2)}
disabled={cart.details.length === 0}
>
Next
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
<CoilBrowserDialog
open={browserOpen}
onClose={() => setBrowserOpen(false)}
jobNumber={cart.job_number}
onAddItem={addItem}
/>
</>
)}
{/* Step 2: Details */}
{currentStep === 2 && (
<CartHeaderForm
initialData={{
order_number: cart.order_number,
po_number: cart.po_number,
release_number: cart.release_number,
pickup_date: cart.pickup_date,
instructions: cart.instructions,
email_recipients: cart.email_recipients,
}}
onSave={updateHeader}
onBack={() => setCurrentStep(1)}
onNext={() => setCurrentStep(3)}
/>
)}
{/* Step 3: Review */}
{currentStep === 3 && (
<CartReview
cart={cart}
variant="alloc"
onSubmit={submitCart}
onBack={() => setCurrentStep(2)}
/>
)}
</div>
);
}

View file

@ -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 (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Allocation Requests</CardTitle>
<CardDescription>
{sortedData.length} request{sortedData.length !== 1 ? 's' : ''}
</CardDescription>
</div>
<Link href="/allocation-requests/new">
<Button className="bg-teal-700 hover:bg-teal-800">
<Plus className="mr-2 h-4 w-4" />
New Request
</Button>
</Link>
</div>
</CardHeader>
<CardContent>
<div className="mb-4 relative">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search by job#, address, order#, PO#..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
<div className="overflow-hidden rounded-md border">
<Table>
<thead>
<tr className="bg-teal-700 text-white">
<SortableTableHead
sortKey="created_at"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Date
</SortableTableHead>
<SortableTableHead
sortKey="job_number"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Job #
</SortableTableHead>
<SortableTableHead
sortKey="ship_to_address"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Ship-To
</SortableTableHead>
<SortableTableHead
sortKey="order_number"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Order #
</SortableTableHead>
<SortableTableHead
sortKey="detail_count"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
className="text-right"
>
Items
</SortableTableHead>
<th className="px-4 py-2 text-left text-xs font-semibold uppercase">
Status
</th>
<th className="px-4 py-2 text-center text-xs font-semibold uppercase">
Action
</th>
</tr>
</thead>
<TableBody>
{sortedData.length === 0 ? (
<TableRow>
<TableCell
colSpan={7}
className="text-center text-muted-foreground"
>
No allocation requests found
</TableCell>
</TableRow>
) : (
sortedData.map((row, i) => {
const isDraft = !row.is_submitted && !row.is_cancelled;
return (
<TableRow
key={row.id}
className={i % 2 === 0 ? 'bg-muted/30' : ''}
>
<TableCell>
{formatDate(new Date(row.created_at))}
</TableCell>
<TableCell className="font-medium">
{row.job_number}
</TableCell>
<TableCell className="max-w-[200px] truncate">
{row.ship_to_address || '-'}
</TableCell>
<TableCell>{row.order_number || '-'}</TableCell>
<TableCell className="text-right">
{row.detail_count}
</TableCell>
<TableCell>
<RequestStatusBadge
isSubmitted={row.is_submitted}
isCancelled={row.is_cancelled}
/>
</TableCell>
<TableCell className="text-center">
{isDraft ? (
<Link
href={`/allocation-requests/new?job=${encodeURIComponent(row.job_number)}`}
>
<Button
size="sm"
variant="ghost"
className="text-teal-700"
>
<Pencil className="h-4 w-4" />
</Button>
</Link>
) : (
<Link href={`/allocation-requests/${row.id}`}>
<Button
size="sm"
variant="ghost"
className="text-teal-700"
>
<Eye className="h-4 w-4" />
</Button>
</Link>
)}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
<div className="mt-4 text-sm text-muted-foreground">
Showing {sortedData.length} of {data.length} requests
</div>
</CardContent>
</Card>
);
}

View file

@ -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<void>;
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<string[]>(
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 (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
Request Details
</CardTitle>
<CardDescription>
Add optional order details and notification preferences
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<Label htmlFor="orderNumber">Order Number</Label>
<Input
id="orderNumber"
value={orderNumber}
onChange={(e) => setOrderNumber(e.target.value)}
placeholder="Optional"
disabled={readOnly}
/>
</div>
<div>
<Label htmlFor="poNumber">PO Number</Label>
<Input
id="poNumber"
value={poNumber}
onChange={(e) => setPoNumber(e.target.value)}
placeholder="Optional"
disabled={readOnly}
/>
</div>
<div>
<Label htmlFor="releaseNumber">Release Number</Label>
<Input
id="releaseNumber"
value={releaseNumber}
onChange={(e) => setReleaseNumber(e.target.value)}
placeholder="Optional"
disabled={readOnly}
/>
</div>
<div>
<Label htmlFor="pickupDate">Pickup Date</Label>
<Input
id="pickupDate"
type="date"
value={pickupDate}
onChange={(e) => setPickupDate(e.target.value)}
disabled={readOnly}
/>
</div>
</div>
<div>
<Label htmlFor="instructions">Special Instructions</Label>
<Textarea
id="instructions"
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
placeholder="Any special handling or delivery instructions..."
rows={3}
disabled={readOnly}
/>
</div>
<div>
<Label htmlFor="emailRecipients">Email Notification Recipients</Label>
<div className="flex gap-2">
<Input
id="emailRecipients"
type="email"
value={emailInput}
onChange={(e) => setEmailInput(e.target.value)}
onKeyDown={handleEmailKeyDown}
onBlur={handleAddEmail}
placeholder="Enter email and press Enter"
disabled={readOnly}
/>
{!readOnly && (
<Button
type="button"
variant="outline"
onClick={handleAddEmail}
disabled={!emailInput.trim()}
>
Add
</Button>
)}
</div>
{emailRecipients.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{emailRecipients.map((email) => (
<span
key={email}
className="inline-flex items-center gap-1 rounded-full bg-teal-100 px-2.5 py-0.5 text-xs font-medium text-teal-800"
>
{email}
{!readOnly && (
<button
type="button"
onClick={() => handleRemoveEmail(email)}
className="ml-0.5 hover:text-teal-900"
>
<X className="h-3 w-3" />
</button>
)}
</span>
))}
</div>
)}
</div>
<div className="flex justify-between pt-4">
<Button variant="outline" onClick={onBack}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button onClick={handleNext} disabled={saving}>
{saving ? 'Saving...' : 'Next'}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,246 @@
'use client';
import type {
ShipRequestDetailItem,
AllocRequestDetailItem,
} from '@/types/requests';
import {
Table,
TableBody,
TableCell,
TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Plus, Trash2, ShoppingCart } from 'lucide-react';
import { useState } from 'react';
type ShipItemRow = ShipRequestDetailItem & { _type: 'ship' };
type AllocItemRow = AllocRequestDetailItem & { _type: 'alloc' };
type Props = {
items: (ShipRequestDetailItem | AllocRequestDetailItem)[];
variant: 'ship' | 'alloc';
onRemoveItem: (detailId: string) => Promise<void>;
onUpdateItem: (detailId: string, data: { quantity?: number; notes?: string | null }) => Promise<void>;
onOpenBrowser: () => void;
readOnly?: boolean;
};
export function CartItemsTable({
items,
variant,
onRemoveItem,
onUpdateItem,
onOpenBrowser,
readOnly = false,
}: Props) {
const [removingId, setRemovingId] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [editQty, setEditQty] = useState<string>('');
const [editNotes, setEditNotes] = useState<string>('');
const handleRemove = async (id: string) => {
setRemovingId(id);
try {
await onRemoveItem(id);
} finally {
setRemovingId(null);
}
};
const startEdit = (item: ShipRequestDetailItem | AllocRequestDetailItem) => {
setEditingId(item.id);
setEditQty(String(item.quantity));
setEditNotes(item.notes || '');
};
const saveEdit = async (id: string) => {
await onUpdateItem(id, {
quantity: parseFloat(editQty) || 0,
notes: editNotes || null,
});
setEditingId(null);
};
const cancelEdit = () => {
setEditingId(null);
};
const isShipItem = (
item: ShipRequestDetailItem | AllocRequestDetailItem
): item is ShipRequestDetailItem => {
return 'plant' in item;
};
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<ShoppingCart className="h-5 w-5" />
Cart Items ({items.length})
</CardTitle>
{!readOnly && (
<Button onClick={onOpenBrowser} size="sm">
<Plus className="mr-2 h-4 w-4" />
Add Items
</Button>
)}
</div>
</CardHeader>
<CardContent>
{items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<ShoppingCart className="mb-2 h-8 w-8 opacity-40" />
<p>No items in cart</p>
{!readOnly && (
<Button
variant="outline"
className="mt-3"
onClick={onOpenBrowser}
>
<Plus className="mr-2 h-4 w-4" />
Browse Inventory
</Button>
)}
</div>
) : (
<div className="overflow-hidden rounded-md border">
<Table>
<thead>
<tr className="bg-teal-700 text-white">
<th className="px-4 py-2 text-left text-xs font-semibold uppercase">
Part #
</th>
<th className="px-4 py-2 text-left text-xs font-semibold uppercase">
Lot #
</th>
{variant === 'ship' ? (
<>
<th className="px-4 py-2 text-left text-xs font-semibold uppercase">
Plant
</th>
<th className="px-4 py-2 text-left text-xs font-semibold uppercase">
Warehouse
</th>
</>
) : (
<th className="px-4 py-2 text-left text-xs font-semibold uppercase">
Coil #
</th>
)}
<th className="px-4 py-2 text-right text-xs font-semibold uppercase">
Qty
</th>
<th className="px-4 py-2 text-left text-xs font-semibold uppercase">
Notes
</th>
{!readOnly && (
<th className="px-4 py-2 text-center text-xs font-semibold uppercase">
Actions
</th>
)}
</tr>
</thead>
<TableBody>
{items.map((item, i) => {
const isEditing = editingId === item.id;
return (
<TableRow
key={item.id}
className={i % 2 === 0 ? 'bg-muted/30' : ''}
>
<TableCell className="font-medium">
{item.part_num}
</TableCell>
<TableCell>{item.lot_num || '-'}</TableCell>
{variant === 'ship' && isShipItem(item) ? (
<>
<TableCell>{item.plant}</TableCell>
<TableCell>{item.warehouse || '-'}</TableCell>
</>
) : !isShipItem(item) ? (
<TableCell>{item.coil_number || '-'}</TableCell>
) : null}
<TableCell className="text-right">
{isEditing ? (
<Input
type="number"
value={editQty}
onChange={(e) => setEditQty(e.target.value)}
className="h-8 w-24 text-right"
min={0}
step="any"
/>
) : (
<span
className={!readOnly ? 'cursor-pointer hover:underline' : ''}
onClick={() => !readOnly && startEdit(item)}
>
{item.quantity}
</span>
)}
</TableCell>
<TableCell>
{isEditing ? (
<Input
value={editNotes}
onChange={(e) => setEditNotes(e.target.value)}
className="h-8"
placeholder="Notes..."
/>
) : (
<span
className={!readOnly ? 'cursor-pointer hover:underline' : ''}
onClick={() => !readOnly && startEdit(item)}
>
{item.notes || '-'}
</span>
)}
</TableCell>
{!readOnly && (
<TableCell className="text-center">
{isEditing ? (
<div className="flex justify-center gap-1">
<Button
size="sm"
variant="outline"
onClick={() => saveEdit(item.id)}
>
Save
</Button>
<Button
size="sm"
variant="ghost"
onClick={cancelEdit}
>
Cancel
</Button>
</div>
) : (
<Button
size="sm"
variant="ghost"
onClick={() => handleRemove(item.id)}
disabled={removingId === item.id}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</TableCell>
)}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,278 @@
'use client';
import { useState } from 'react';
import type {
ShipRequestHeader,
AllocRequestHeader,
ShipRequestDetailItem,
AllocRequestDetailItem,
} from '@/types/requests';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ArrowLeft, CheckCircle, Send } from 'lucide-react';
import { formatDate } from '@/lib/utils';
type Props = {
cart: ShipRequestHeader | AllocRequestHeader;
variant: 'ship' | 'alloc';
onSubmit: () => Promise<void>;
onBack: () => void;
};
function isAllocRequest(
cart: ShipRequestHeader | AllocRequestHeader
): cart is AllocRequestHeader {
return 'job_number' in cart;
}
function isShipDetail(
item: ShipRequestDetailItem | AllocRequestDetailItem
): item is ShipRequestDetailItem {
return 'plant' in item;
}
export function CartReview({ cart, variant, onSubmit, onBack }: Props) {
const [confirmOpen, setConfirmOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async () => {
setSubmitting(true);
try {
await onSubmit();
setConfirmOpen(false);
} finally {
setSubmitting(false);
}
};
return (
<>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CheckCircle className="h-5 w-5" />
Review &amp; Submit
</CardTitle>
<CardDescription>
Review your {variant === 'ship' ? 'shipment' : 'allocation'} request before submitting
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Ship-To Address */}
<div>
<h3 className="text-sm font-semibold text-muted-foreground uppercase mb-1">
Ship-To Address
</h3>
<p className="text-sm">{cart.ship_to_address || 'Not selected'}</p>
</div>
{/* Job Number (allocation only) */}
{isAllocRequest(cart) && (
<div>
<h3 className="text-sm font-semibold text-muted-foreground uppercase mb-1">
Job Number
</h3>
<p className="text-sm">{cart.job_number}</p>
</div>
)}
{/* Details */}
<div className="grid gap-3 sm:grid-cols-2">
{cart.order_number && (
<div>
<span className="text-xs font-semibold text-muted-foreground uppercase">
Order #
</span>
<p className="text-sm">{cart.order_number}</p>
</div>
)}
{cart.po_number && (
<div>
<span className="text-xs font-semibold text-muted-foreground uppercase">
PO #
</span>
<p className="text-sm">{cart.po_number}</p>
</div>
)}
{cart.release_number && (
<div>
<span className="text-xs font-semibold text-muted-foreground uppercase">
Release #
</span>
<p className="text-sm">{cart.release_number}</p>
</div>
)}
{cart.pickup_date && (
<div>
<span className="text-xs font-semibold text-muted-foreground uppercase">
Pickup Date
</span>
<p className="text-sm">
{formatDate(new Date(cart.pickup_date))}
</p>
</div>
)}
</div>
{cart.instructions && (
<div>
<h3 className="text-sm font-semibold text-muted-foreground uppercase mb-1">
Instructions
</h3>
<p className="whitespace-pre-wrap text-sm">{cart.instructions}</p>
</div>
)}
{cart.email_recipients.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-muted-foreground uppercase mb-1">
Notification Recipients
</h3>
<div className="flex flex-wrap gap-1">
{cart.email_recipients.map((email) => (
<span
key={email}
className="rounded-full bg-teal-100 px-2.5 py-0.5 text-xs font-medium text-teal-800"
>
{email}
</span>
))}
</div>
</div>
)}
{/* Items Table */}
<div>
<h3 className="text-sm font-semibold text-muted-foreground uppercase mb-2">
Items ({cart.details.length})
</h3>
<div className="overflow-hidden rounded-md border">
<Table>
<thead>
<tr className="bg-teal-700 text-white">
<th className="px-3 py-2 text-left text-xs font-semibold uppercase">
Part #
</th>
<th className="px-3 py-2 text-left text-xs font-semibold uppercase">
Lot #
</th>
{variant === 'ship' ? (
<>
<th className="px-3 py-2 text-left text-xs font-semibold uppercase">
Plant
</th>
<th className="px-3 py-2 text-left text-xs font-semibold uppercase">
Warehouse
</th>
</>
) : (
<th className="px-3 py-2 text-left text-xs font-semibold uppercase">
Coil #
</th>
)}
<th className="px-3 py-2 text-right text-xs font-semibold uppercase">
Qty
</th>
<th className="px-3 py-2 text-left text-xs font-semibold uppercase">
Notes
</th>
</tr>
</thead>
<TableBody>
{cart.details.map((item, i) => (
<TableRow
key={item.id}
className={i % 2 === 0 ? 'bg-muted/30' : ''}
>
<TableCell className="font-medium">
{item.part_num}
</TableCell>
<TableCell>{item.lot_num || '-'}</TableCell>
{variant === 'ship' && isShipDetail(item) ? (
<>
<TableCell>{item.plant}</TableCell>
<TableCell>{item.warehouse || '-'}</TableCell>
</>
) : !isShipDetail(item) ? (
<TableCell>{item.coil_number || '-'}</TableCell>
) : null}
<TableCell className="text-right">
{item.quantity}
</TableCell>
<TableCell>{item.notes || '-'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
<div className="flex justify-between pt-4">
<Button variant="outline" onClick={onBack}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button
onClick={() => setConfirmOpen(true)}
className="bg-teal-700 hover:bg-teal-800"
disabled={cart.details.length === 0 || !cart.ship_to_address}
>
<Send className="mr-2 h-4 w-4" />
Submit Request
</Button>
</div>
</CardContent>
</Card>
{/* Confirmation Dialog */}
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Submit {variant === 'ship' ? 'shipment' : 'allocation'} request?</DialogTitle>
<DialogDescription>
This will submit your request with {cart.details.length} item
{cart.details.length !== 1 ? 's' : ''}. You will not be able to edit the
request after submission.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setConfirmOpen(false)}
disabled={submitting}
>
Go Back
</Button>
<Button
onClick={handleSubmit}
disabled={submitting}
className="bg-teal-700 hover:bg-teal-800"
>
{submitting ? 'Submitting...' : 'Confirm Submit'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View file

@ -0,0 +1,197 @@
'use client';
import { useEffect, useState } from 'react';
import type { AvailableCoilItem, AddAllocCartItemPayload } from '@/types/requests';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Table,
TableBody,
TableCell,
TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Search, Plus, Loader2, Package } from 'lucide-react';
type Props = {
open: boolean;
onClose: () => void;
jobNumber: string;
onAddItem: (item: AddAllocCartItemPayload) => Promise<void>;
};
export function CoilBrowserDialog({ open, onClose, jobNumber, onAddItem }: Props) {
const [coils, setCoils] = useState<AvailableCoilItem[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [addingIndex, setAddingIndex] = useState<number | null>(null);
const [quantities, setQuantities] = useState<Record<number, string>>({});
useEffect(() => {
if (!open || !jobNumber) return;
setLoading(true);
setError(null);
setCoils(null);
fetch(`/api/available-coils?jobNumber=${encodeURIComponent(jobNumber)}`)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((data) => setCoils(data))
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [open, jobNumber]);
const filtered = coils?.filter((coil) => {
if (!searchTerm) return true;
const s = searchTerm.toLowerCase();
return (
coil.part_num.toLowerCase().includes(s) ||
(coil.lot_num || '').toLowerCase().includes(s) ||
(coil.coil_number || '').toLowerCase().includes(s)
);
});
const handleAdd = async (coil: AvailableCoilItem, index: number) => {
setAddingIndex(index);
try {
const qty = parseFloat(quantities[index] || '0') || coil.on_hand_qty;
await onAddItem({
part_num: coil.part_num,
lot_num: coil.lot_num || undefined,
coil_number: coil.coil_number || undefined,
quantity: qty,
});
} finally {
setAddingIndex(null);
}
};
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Package className="h-5 w-5" />
Available Coils for Job {jobNumber}
</DialogTitle>
</DialogHeader>
<div className="relative mb-3">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search by part#, lot#, coil#..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : error ? (
<p className="py-4 text-center text-sm text-destructive">
Failed to load coils: {error}
</p>
) : coils && coils.length === 0 ? (
<div className="py-8 text-center">
<p className="text-sm text-muted-foreground">
No coils available for this job.
</p>
<p className="mt-1 text-xs text-muted-foreground">
The coil allocation view may not be configured yet.
</p>
</div>
) : filtered ? (
<div className="rounded-md border">
<Table>
<thead>
<tr className="bg-teal-700 text-white">
<th className="px-3 py-2 text-left text-xs font-semibold uppercase">
Part #
</th>
<th className="px-3 py-2 text-left text-xs font-semibold uppercase">
Lot #
</th>
<th className="px-3 py-2 text-left text-xs font-semibold uppercase">
Coil #
</th>
<th className="px-3 py-2 text-right text-xs font-semibold uppercase">
On Hand
</th>
<th className="px-3 py-2 text-right text-xs font-semibold uppercase">
Qty
</th>
<th className="px-3 py-2 text-center text-xs font-semibold uppercase">
Add
</th>
</tr>
</thead>
<TableBody>
{filtered.slice(0, 100).map((coil, i) => (
<TableRow
key={`${coil.part_num}-${coil.lot_num}-${coil.coil_number}-${i}`}
className={i % 2 === 0 ? 'bg-muted/30' : ''}
>
<TableCell className="font-medium">
{coil.part_num}
</TableCell>
<TableCell>{coil.lot_num || '-'}</TableCell>
<TableCell>{coil.coil_number || '-'}</TableCell>
<TableCell className="text-right">
{coil.on_hand_qty}
</TableCell>
<TableCell className="text-right">
<Input
type="number"
className="h-7 w-20 text-right"
placeholder={String(coil.on_hand_qty)}
value={quantities[i] ?? ''}
onChange={(e) =>
setQuantities((prev) => ({
...prev,
[i]: e.target.value,
}))
}
min={0}
step="any"
/>
</TableCell>
<TableCell className="text-center">
<Button
size="sm"
variant="ghost"
onClick={() => handleAdd(coil, i)}
disabled={addingIndex === i}
className="text-teal-700 hover:text-teal-900"
>
<Plus className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{filtered.length > 100 && (
<p className="p-2 text-center text-xs text-muted-foreground">
Showing first 100 of {filtered.length} items.
</p>
)}
</div>
) : null}
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,219 @@
'use client';
import { useEffect, useState } from 'react';
import type { InventoryDetailRow } from '@/services/inventory';
import type { AddShipCartItemPayload } from '@/types/requests';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Table,
TableBody,
TableCell,
TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Search, Plus, Loader2, Package } from 'lucide-react';
type Props = {
open: boolean;
onClose: () => void;
onAddItem: (item: AddShipCartItemPayload) => Promise<void>;
};
type InventoryCategory = 'wip' | 'finished-goods' | 'processed-other';
const CATEGORIES: { value: InventoryCategory; label: string }[] = [
{ value: 'finished-goods', label: 'Finished Goods' },
{ value: 'wip', label: 'Work in Progress' },
{ value: 'processed-other', label: 'Processed Other' },
];
export function InventoryBrowserDialog({ open, onClose, onAddItem }: Props) {
const [category, setCategory] = useState<InventoryCategory>('finished-goods');
const [items, setItems] = useState<InventoryDetailRow[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [addingIndex, setAddingIndex] = useState<number | null>(null);
const [quantities, setQuantities] = useState<Record<number, string>>({});
useEffect(() => {
if (!open) return;
setLoading(true);
setError(null);
setItems(null);
fetch(`/api/inventory/details?category=${category}`)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((json) => {
setItems(json.data || []);
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [open, category]);
const filtered = items?.filter((item) => {
if (!searchTerm) return true;
const s = searchTerm.toLowerCase();
return (
String(item.LotNum || '').toLowerCase().includes(s) ||
String(item.CustPartNum || '').toLowerCase().includes(s) ||
String(item.Bin || '').toLowerCase().includes(s) ||
String(item.SkidNum || '').toLowerCase().includes(s)
);
});
const handleAdd = async (item: InventoryDetailRow, index: number) => {
setAddingIndex(index);
try {
const qty = parseFloat(quantities[index] || '0') || (item.OnHandQty ?? 0);
await onAddItem({
part_num: String(item.CustPartNum || item.LotNum || ''),
lot_num: item.LotNum ? String(item.LotNum) : undefined,
plant: String(item.WIP_FG || ''),
warehouse: item.Bin ? String(item.Bin) : undefined,
quantity: qty,
});
} finally {
setAddingIndex(null);
}
};
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Package className="h-5 w-5" />
Browse Inventory
</DialogTitle>
</DialogHeader>
<div className="flex gap-2 mb-3">
{CATEGORIES.map((cat) => (
<Button
key={cat.value}
variant={category === cat.value ? 'default' : 'outline'}
size="sm"
onClick={() => setCategory(cat.value)}
className={category === cat.value ? 'bg-teal-700 hover:bg-teal-800' : ''}
>
{cat.label}
</Button>
))}
</div>
<div className="relative mb-3">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search by lot#, part#, bin..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : error ? (
<p className="py-4 text-center text-sm text-destructive">
Failed to load inventory: {error}
</p>
) : filtered && filtered.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">
No inventory items found.
</p>
) : filtered ? (
<div className="rounded-md border">
<Table>
<thead>
<tr className="bg-teal-700 text-white">
<th className="px-3 py-2 text-left text-xs font-semibold uppercase">
Lot #
</th>
<th className="px-3 py-2 text-left text-xs font-semibold uppercase">
Part #
</th>
<th className="px-3 py-2 text-left text-xs font-semibold uppercase">
Bin
</th>
<th className="px-3 py-2 text-right text-xs font-semibold uppercase">
On Hand
</th>
<th className="px-3 py-2 text-right text-xs font-semibold uppercase">
Qty
</th>
<th className="px-3 py-2 text-center text-xs font-semibold uppercase">
Add
</th>
</tr>
</thead>
<TableBody>
{filtered.slice(0, 100).map((item, i) => (
<TableRow
key={`${item.LotNum}-${item.Bin}-${i}`}
className={i % 2 === 0 ? 'bg-muted/30' : ''}
>
<TableCell className="font-medium">
{item.LotNum || '-'}
</TableCell>
<TableCell>{item.CustPartNum || '-'}</TableCell>
<TableCell>{item.Bin || '-'}</TableCell>
<TableCell className="text-right">
{item.OnHandQty ?? 0}
</TableCell>
<TableCell className="text-right">
<Input
type="number"
className="h-7 w-20 text-right"
placeholder={String(item.OnHandQty ?? 0)}
value={quantities[i] ?? ''}
onChange={(e) =>
setQuantities((prev) => ({
...prev,
[i]: e.target.value,
}))
}
min={0}
step="any"
/>
</TableCell>
<TableCell className="text-center">
<Button
size="sm"
variant="ghost"
onClick={() => handleAdd(item, i)}
disabled={addingIndex === i}
className="text-teal-700 hover:text-teal-900"
>
<Plus className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{filtered.length > 100 && (
<p className="p-2 text-center text-xs text-muted-foreground">
Showing first 100 of {filtered.length} items. Use search to narrow results.
</p>
)}
</div>
) : null}
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,70 @@
'use client';
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { XCircle } from 'lucide-react';
type Props = {
requestType: 'shipment' | 'allocation';
onConfirm: () => Promise<void>;
};
export function RequestCancelDialog({ requestType, onConfirm }: Props) {
const [open, setOpen] = useState(false);
const [cancelling, setCancelling] = useState(false);
const handleConfirm = async () => {
setCancelling(true);
try {
await onConfirm();
setOpen(false);
} finally {
setCancelling(false);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="destructive" size="sm">
<XCircle className="mr-2 h-4 w-4" />
Cancel Request
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Cancel {requestType} request?</DialogTitle>
<DialogDescription>
This action cannot be undone. The {requestType} request will be
marked as cancelled and a notification email will be sent.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={cancelling}
>
Keep Request
</Button>
<Button
variant="destructive"
onClick={handleConfirm}
disabled={cancelling}
>
{cancelling ? 'Cancelling...' : 'Yes, Cancel'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,24 @@
'use client';
import { Badge } from '@/components/ui/badge';
type Props = {
isSubmitted: boolean;
isCancelled: boolean;
};
export function RequestStatusBadge({ isSubmitted, isCancelled }: Props) {
if (isCancelled) {
return <Badge variant="destructive">Cancelled</Badge>;
}
if (isSubmitted) {
return (
<Badge className="bg-teal-700 text-white hover:bg-teal-800">
Submitted
</Badge>
);
}
return <Badge variant="secondary">Draft</Badge>;
}

View file

@ -0,0 +1,81 @@
'use client';
import { cn } from '@/lib/utils';
import { Check } from 'lucide-react';
export type StepConfig = {
label: string;
description?: string;
};
type Props = {
steps: StepConfig[];
currentStep: number;
onStepClick?: (step: number) => void;
};
export function RequestStepper({ steps, currentStep, onStepClick }: Props) {
return (
<nav aria-label="Progress" className="mb-6">
<ol className="flex items-center">
{steps.map((step, index) => {
const isCompleted = index < currentStep;
const isCurrent = index === currentStep;
const isClickable = onStepClick && index < currentStep;
return (
<li
key={step.label}
className={cn('relative flex-1', index !== steps.length - 1 && 'pr-4')}
>
<div className="flex items-center">
<button
type="button"
className={cn(
'relative flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-sm font-semibold transition-colors',
isCompleted &&
'bg-teal-700 text-white hover:bg-teal-800',
isCurrent &&
'border-2 border-teal-700 bg-white text-teal-700',
!isCompleted &&
!isCurrent &&
'border-2 border-gray-300 bg-white text-gray-400'
)}
onClick={() => isClickable && onStepClick(index)}
disabled={!isClickable}
>
{isCompleted ? (
<Check className="h-4 w-4" />
) : (
<span>{index + 1}</span>
)}
</button>
{/* Connector line */}
{index !== steps.length - 1 && (
<div
className={cn(
'ml-2 h-0.5 flex-1',
isCompleted ? 'bg-teal-700' : 'bg-gray-300'
)}
/>
)}
</div>
<div className="mt-1.5">
<span
className={cn(
'text-xs font-medium',
isCurrent ? 'text-teal-700' : isCompleted ? 'text-gray-700' : 'text-gray-400'
)}
>
{step.label}
</span>
</div>
</li>
);
})}
</ol>
</nav>
);
}

View file

@ -0,0 +1,148 @@
'use client';
import { useEffect, useState } from 'react';
import type { ShipToAddress } from '@/types/requests';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { MapPin, Search, ArrowRight, Loader2 } from 'lucide-react';
type Props = {
selectedAddress: string;
onSelect: (address: string) => void;
onNext: () => void;
};
export function ShipToSelector({ selectedAddress, onSelect, onNext }: Props) {
const [addresses, setAddresses] = useState<ShipToAddress[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
fetch('/api/ship-to-addresses')
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((data) => setAddresses(data))
.catch((err) => setError(err.message));
}, []);
const filtered = addresses?.filter((addr) => {
const s = searchTerm.toLowerCase();
return (
addr.name.toLowerCase().includes(s) ||
addr.address1.toLowerCase().includes(s) ||
addr.city.toLowerCase().includes(s) ||
addr.ship_to_num.toLowerCase().includes(s)
);
});
const formatAddress = (addr: ShipToAddress) => {
const parts = [addr.address1];
if (addr.address2) parts.push(addr.address2);
parts.push(`${addr.city}, ${addr.state} ${addr.zip}`);
return parts.join(', ');
};
const formatSelectedDisplay = (addr: ShipToAddress) => {
return `${addr.name} - ${formatAddress(addr)}`;
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MapPin className="h-5 w-5" />
Ship-To Address
</CardTitle>
<CardDescription>
Select the delivery address for this request
</CardDescription>
</CardHeader>
<CardContent>
{error ? (
<p className="text-sm text-destructive">
Failed to load addresses: {error}
</p>
) : addresses === null ? (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading addresses...
</div>
) : addresses.length === 0 ? (
<p className="text-sm text-muted-foreground">
No ship-to addresses found for this company.
</p>
) : (
<>
<div className="relative mb-4">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search addresses..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
<RadioGroup
value={selectedAddress}
onValueChange={(val) => onSelect(val)}
className="max-h-80 space-y-2 overflow-y-auto"
>
{filtered?.map((addr) => {
const value = formatSelectedDisplay(addr);
return (
<div
key={addr.ship_to_num}
className="flex items-start space-x-3 rounded-md border p-3 hover:bg-muted/50"
>
<RadioGroupItem
value={value}
id={`addr-${addr.ship_to_num}`}
/>
<Label
htmlFor={`addr-${addr.ship_to_num}`}
className="flex-1 cursor-pointer"
>
<div className="font-medium">{addr.name}</div>
<div className="text-sm text-muted-foreground">
{addr.address1}
{addr.address2 && <>, {addr.address2}</>}
</div>
<div className="text-sm text-muted-foreground">
{addr.city}, {addr.state} {addr.zip}
</div>
</Label>
</div>
);
})}
</RadioGroup>
{filtered?.length === 0 && (
<p className="mt-2 text-center text-sm text-muted-foreground">
No addresses match your search.
</p>
)}
</>
)}
<div className="mt-6 flex justify-end">
<Button onClick={onNext} disabled={!selectedAddress}>
Next
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,259 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import type {
ShipRequestHeader,
AddShipCartItemPayload,
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 { InventoryBrowserDialog } from './inventory-browser-dialog';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Loader2, ArrowLeft, ArrowRight } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useToast } from '@/hooks/use-toast';
const STEPS: StepConfig[] = [
{ label: 'Address', description: 'Select ship-to' },
{ label: 'Items', description: 'Add items' },
{ label: 'Details', description: 'Order info' },
{ label: 'Review', description: 'Submit' },
];
export function ShipmentRequestCart() {
const router = useRouter();
const { toast } = useToast();
const [cart, setCart] = useState<ShipRequestHeader | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentStep, setCurrentStep] = useState(0);
const [browserOpen, setBrowserOpen] = useState(false);
// Fetch or create cart
const fetchCart = useCallback(async () => {
try {
const res = await fetch('/api/shipment-requests/cart');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setCart(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load cart');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchCart();
}, [fetchCart]);
// Update cart header (ship-to, details, etc.)
const updateHeader = async (data: UpdateCartHeaderPayload) => {
if (!cart) return;
const res = await fetch('/api/shipment-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 to cart
const addItem = async (item: AddShipCartItemPayload) => {
if (!cart) return;
const res = await fetch('/api/shipment-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();
};
// Remove item from cart
const removeItem = async (detailId: string) => {
if (!cart) return;
const res = await fetch(
`/api/shipment-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();
};
// Update item
const updateItem = async (
detailId: string,
data: { quantity?: number; notes?: string | null }
) => {
if (!cart) return;
const res = await fetch(
`/api/shipment-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();
};
// Submit cart
const submitCart = async () => {
if (!cart) return;
const res = await fetch('/api/shipment-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: 'Shipment request submitted successfully!' });
router.push('/shipment-requests');
};
// Handle ship-to selection
const handleSelectAddress = (address: string) => {
updateHeader({ ship_to_address: address });
};
if (loading) {
return (
<Card>
<CardContent className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</CardContent>
</Card>
);
}
if (error || !cart) {
return (
<Card>
<CardContent className="p-6 text-center text-destructive">
Failed to load cart: {error || 'Unknown error'}
</CardContent>
</Card>
);
}
return (
<div className="space-y-4">
<RequestStepper
steps={STEPS}
currentStep={currentStep}
onStepClick={setCurrentStep}
/>
{/* Step 0: Ship-To Address */}
{currentStep === 0 && (
<ShipToSelector
selectedAddress={cart.ship_to_address}
onSelect={handleSelectAddress}
onNext={() => setCurrentStep(1)}
/>
)}
{/* Step 1: Items */}
{currentStep === 1 && (
<>
<CartItemsTable
items={cart.details}
variant="ship"
onRemoveItem={removeItem}
onUpdateItem={updateItem}
onOpenBrowser={() => setBrowserOpen(true)}
/>
<div className="flex justify-between">
<Button variant="outline" onClick={() => setCurrentStep(0)}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button
onClick={() => setCurrentStep(2)}
disabled={cart.details.length === 0}
>
Next
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
<InventoryBrowserDialog
open={browserOpen}
onClose={() => setBrowserOpen(false)}
onAddItem={addItem}
/>
</>
)}
{/* Step 2: Details */}
{currentStep === 2 && (
<CartHeaderForm
initialData={{
order_number: cart.order_number,
po_number: cart.po_number,
release_number: cart.release_number,
pickup_date: cart.pickup_date,
instructions: cart.instructions,
email_recipients: cart.email_recipients,
}}
onSave={updateHeader}
onBack={() => setCurrentStep(1)}
onNext={() => setCurrentStep(3)}
/>
)}
{/* Step 3: Review */}
{currentStep === 3 && (
<CartReview
cart={cart}
variant="ship"
onSubmit={submitCart}
onBack={() => setCurrentStep(2)}
/>
)}
</div>
);
}

View file

@ -0,0 +1,202 @@
'use client';
import { useState } from 'react';
import type { ShipRequestListItem } 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: ShipRequestListItem[];
};
export function ShipmentRequestList({ data }: Props) {
const [searchTerm, setSearchTerm] = useState('');
const filteredData = data.filter((row) => {
const s = searchTerm.toLowerCase();
return (
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 (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Shipment Requests</CardTitle>
<CardDescription>
{sortedData.length} request{sortedData.length !== 1 ? 's' : ''}
</CardDescription>
</div>
<Link href="/shipment-requests/new">
<Button className="bg-teal-700 hover:bg-teal-800">
<Plus className="mr-2 h-4 w-4" />
New Request
</Button>
</Link>
</div>
</CardHeader>
<CardContent>
<div className="mb-4 relative">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search by address, order#, PO#..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
<div className="overflow-hidden rounded-md border">
<Table>
<thead>
<tr className="bg-teal-700 text-white">
<SortableTableHead
sortKey="created_at"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Date
</SortableTableHead>
<SortableTableHead
sortKey="ship_to_address"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Ship-To
</SortableTableHead>
<SortableTableHead
sortKey="order_number"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
Order #
</SortableTableHead>
<SortableTableHead
sortKey="po_number"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
>
PO #
</SortableTableHead>
<SortableTableHead
sortKey="detail_count"
currentSortKey={sortKey}
currentDirection={sortDirection}
onSort={handleSort}
className="text-right"
>
Items
</SortableTableHead>
<th className="px-4 py-2 text-left text-xs font-semibold uppercase">
Status
</th>
<th className="px-4 py-2 text-center text-xs font-semibold uppercase">
Action
</th>
</tr>
</thead>
<TableBody>
{sortedData.length === 0 ? (
<TableRow>
<TableCell
colSpan={7}
className="text-center text-muted-foreground"
>
No shipment requests found
</TableCell>
</TableRow>
) : (
sortedData.map((row, i) => {
const isDraft = !row.is_submitted && !row.is_cancelled;
return (
<TableRow
key={row.id}
className={i % 2 === 0 ? 'bg-muted/30' : ''}
>
<TableCell>
{formatDate(new Date(row.created_at))}
</TableCell>
<TableCell className="max-w-[200px] truncate">
{row.ship_to_address || '-'}
</TableCell>
<TableCell>{row.order_number || '-'}</TableCell>
<TableCell>{row.po_number || '-'}</TableCell>
<TableCell className="text-right">
{row.detail_count}
</TableCell>
<TableCell>
<RequestStatusBadge
isSubmitted={row.is_submitted}
isCancelled={row.is_cancelled}
/>
</TableCell>
<TableCell className="text-center">
{isDraft ? (
<Link href="/shipment-requests/new">
<Button
size="sm"
variant="ghost"
className="text-teal-700"
>
<Pencil className="h-4 w-4" />
</Button>
</Link>
) : (
<Link href={`/shipment-requests/${row.id}`}>
<Button
size="sm"
variant="ghost"
className="text-teal-700"
>
<Eye className="h-4 w-4" />
</Button>
</Link>
)}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
<div className="mt-4 text-sm text-muted-foreground">
Showing {sortedData.length} of {data.length} requests
</div>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,439 @@
/**
* Allocation Request Service
*
* Handles CRUD operations for allocation request carts persisted in PostgreSQL.
* Similar to ship-requests but requires a job_number and uses coil_number
* instead of plant/warehouse on detail items.
*/
import { db } from '@/lib/db';
import type {
AllocRequestHeader,
AllocRequestListItem,
AllocRequestDetailItem,
AddAllocCartItemPayload,
UpdateCartHeaderPayload,
UpdateCartItemPayload,
} from '@/types/requests';
// =============================================================================
// Serialization helpers
// =============================================================================
function serializeDetail(
d: {
id: string;
part_num: string;
lot_num: string | null;
coil_number: string | null;
quantity: number;
notes: string | null;
created_at: Date;
}
): AllocRequestDetailItem {
return {
id: d.id,
part_num: d.part_num,
lot_num: d.lot_num,
coil_number: d.coil_number,
quantity: d.quantity,
notes: d.notes,
created_at: d.created_at.toISOString(),
};
}
function serializeHeader(
r: {
id: string;
auth_user_id: string;
quest_company_id: string;
job_number: string;
ship_to_address: string;
order_number: string | null;
po_number: string | null;
release_number: string | null;
pickup_date: Date | null;
instructions: string | null;
email_recipients: string[];
is_submitted: boolean;
submitted_at: Date | null;
is_cancelled: boolean;
cancelled_at: Date | null;
cancelled_by: string | null;
last_cart_activity: Date;
created_at: Date;
updated_at: Date;
details: {
id: string;
part_num: string;
lot_num: string | null;
coil_number: string | null;
quantity: number;
notes: string | null;
created_at: Date;
}[];
}
): AllocRequestHeader {
return {
id: r.id,
auth_user_id: r.auth_user_id,
quest_company_id: r.quest_company_id,
job_number: r.job_number,
ship_to_address: r.ship_to_address,
order_number: r.order_number,
po_number: r.po_number,
release_number: r.release_number,
pickup_date: r.pickup_date?.toISOString() ?? null,
instructions: r.instructions,
email_recipients: r.email_recipients,
is_submitted: r.is_submitted,
submitted_at: r.submitted_at?.toISOString() ?? null,
is_cancelled: r.is_cancelled,
cancelled_at: r.cancelled_at?.toISOString() ?? null,
cancelled_by: r.cancelled_by,
last_cart_activity: r.last_cart_activity.toISOString(),
created_at: r.created_at.toISOString(),
updated_at: r.updated_at.toISOString(),
details: r.details.map(serializeDetail),
};
}
// =============================================================================
// Cart Operations
// =============================================================================
/**
* Get or create an active cart for this user+company+job
*/
export async function getOrCreateCart(
userId: string,
companyId: string,
jobNumber: string
): Promise<AllocRequestHeader> {
// Find existing active cart for this job
const existing = await db.alloc_request.findFirst({
where: {
auth_user_id: userId,
quest_company_id: companyId,
job_number: jobNumber,
is_submitted: false,
is_cancelled: false,
},
include: { details: { orderBy: { created_at: 'asc' } } },
orderBy: { created_at: 'desc' },
});
if (existing) {
return serializeHeader(existing);
}
// Create new cart
const newCart = await db.alloc_request.create({
data: {
auth_user_id: userId,
quest_company_id: companyId,
job_number: jobNumber,
ship_to_address: '',
},
include: { details: true },
});
return serializeHeader(newCart);
}
/**
* Get a specific request by ID, scoped to a company
*/
export async function getRequestById(
id: string,
companyId: string
): Promise<AllocRequestHeader | null> {
const request = await db.alloc_request.findFirst({
where: { id, quest_company_id: companyId },
include: { details: { orderBy: { created_at: 'asc' } } },
});
return request ? serializeHeader(request) : null;
}
/**
* Get all requests for a company (list view)
*/
export async function getRequests(
companyId: string
): Promise<AllocRequestListItem[]> {
const requests = await db.alloc_request.findMany({
where: { quest_company_id: companyId },
include: { _count: { select: { details: true } } },
orderBy: { created_at: 'desc' },
});
return requests.map((r) => ({
id: r.id,
job_number: r.job_number,
ship_to_address: r.ship_to_address,
order_number: r.order_number,
po_number: r.po_number,
is_submitted: r.is_submitted,
submitted_at: r.submitted_at?.toISOString() ?? null,
is_cancelled: r.is_cancelled,
cancelled_at: r.cancelled_at?.toISOString() ?? null,
created_at: r.created_at.toISOString(),
detail_count: r._count.details,
}));
}
/**
* Update cart header fields
*/
export async function updateCartHeader(
id: string,
companyId: string,
data: UpdateCartHeaderPayload
): Promise<AllocRequestHeader> {
const cart = await db.alloc_request.findFirst({
where: { id, quest_company_id: companyId, is_submitted: false, is_cancelled: false },
});
if (!cart) {
throw new Error('Cart not found or already submitted/cancelled');
}
const updated = await db.alloc_request.update({
where: { id },
data: {
...(data.ship_to_address !== undefined && { ship_to_address: data.ship_to_address }),
...(data.order_number !== undefined && { order_number: data.order_number }),
...(data.po_number !== undefined && { po_number: data.po_number }),
...(data.release_number !== undefined && { release_number: data.release_number }),
...(data.pickup_date !== undefined && {
pickup_date: data.pickup_date ? new Date(data.pickup_date) : null,
}),
...(data.instructions !== undefined && { instructions: data.instructions }),
...(data.email_recipients !== undefined && { email_recipients: data.email_recipients }),
last_cart_activity: new Date(),
},
include: { details: { orderBy: { created_at: 'asc' } } },
});
return serializeHeader(updated);
}
// =============================================================================
// Cart Item Operations
// =============================================================================
/**
* Add an item to the cart. Checks for duplicates (part+lot+coil).
*/
export async function addCartItem(
requestId: string,
companyId: string,
item: AddAllocCartItemPayload
): Promise<AllocRequestDetailItem> {
const cart = await db.alloc_request.findFirst({
where: { id: requestId, quest_company_id: companyId, is_submitted: false, is_cancelled: false },
});
if (!cart) {
throw new Error('Cart not found or already submitted/cancelled');
}
// Check for duplicates (part_num + lot_num + coil_number)
const duplicate = await db.alloc_request_detail.findFirst({
where: {
alloc_request_id: requestId,
part_num: item.part_num,
lot_num: item.lot_num || null,
coil_number: item.coil_number || null,
},
});
if (duplicate) {
throw new Error('This item already exists in the cart. Update the quantity instead.');
}
const detail = await db.alloc_request_detail.create({
data: {
alloc_request_id: requestId,
part_num: item.part_num,
lot_num: item.lot_num || null,
coil_number: item.coil_number || null,
quantity: item.quantity,
notes: item.notes || null,
},
});
// Touch cart activity
await db.alloc_request.update({
where: { id: requestId },
data: { last_cart_activity: new Date() },
});
return serializeDetail(detail);
}
/**
* Remove an item from the cart
*/
export async function removeCartItem(
detailId: string,
requestId: string,
companyId: string
): Promise<void> {
const cart = await db.alloc_request.findFirst({
where: { id: requestId, quest_company_id: companyId, is_submitted: false, is_cancelled: false },
});
if (!cart) {
throw new Error('Cart not found or already submitted/cancelled');
}
const detail = await db.alloc_request_detail.findFirst({
where: { id: detailId, alloc_request_id: requestId },
});
if (!detail) {
throw new Error('Item not found in cart');
}
await db.alloc_request_detail.delete({ where: { id: detailId } });
await db.alloc_request.update({
where: { id: requestId },
data: { last_cart_activity: new Date() },
});
}
/**
* Update a cart item (quantity, notes)
*/
export async function updateCartItem(
detailId: string,
requestId: string,
companyId: string,
data: UpdateCartItemPayload
): Promise<AllocRequestDetailItem> {
const cart = await db.alloc_request.findFirst({
where: { id: requestId, quest_company_id: companyId, is_submitted: false, is_cancelled: false },
});
if (!cart) {
throw new Error('Cart not found or already submitted/cancelled');
}
const detail = await db.alloc_request_detail.findFirst({
where: { id: detailId, alloc_request_id: requestId },
});
if (!detail) {
throw new Error('Item not found in cart');
}
const updated = await db.alloc_request_detail.update({
where: { id: detailId },
data: {
...(data.quantity !== undefined && { quantity: data.quantity }),
...(data.notes !== undefined && { notes: data.notes }),
},
});
await db.alloc_request.update({
where: { id: requestId },
data: { last_cart_activity: new Date() },
});
return serializeDetail(updated);
}
// =============================================================================
// Submit & Cancel
// =============================================================================
/**
* Submit a cart
*/
export async function submitCart(
id: string,
companyId: string,
userId: string
): Promise<AllocRequestHeader> {
const cart = await db.alloc_request.findFirst({
where: { id, quest_company_id: companyId, is_submitted: false, is_cancelled: false },
include: { details: true },
});
if (!cart) {
throw new Error('Cart not found or already submitted/cancelled');
}
if (cart.details.length === 0) {
throw new Error('Cannot submit an empty cart');
}
if (!cart.ship_to_address) {
throw new Error('Ship-to address is required');
}
if (!cart.job_number) {
throw new Error('Job number is required');
}
const submitted = await db.alloc_request.update({
where: { id },
data: {
is_submitted: true,
submitted_at: new Date(),
},
include: { details: { orderBy: { created_at: 'asc' } } },
});
// Email notification stub
console.log('[EMAIL STUB] Allocation request submitted:', {
requestId: id,
userId,
companyId,
jobNumber: submitted.job_number,
recipients: submitted.email_recipients,
itemCount: submitted.details.length,
});
return serializeHeader(submitted);
}
/**
* Cancel a submitted request
*/
export async function cancelRequest(
id: string,
companyId: string,
userId: string
): Promise<AllocRequestHeader> {
const request = await db.alloc_request.findFirst({
where: { id, quest_company_id: companyId, is_cancelled: false },
});
if (!request) {
throw new Error('Request not found');
}
const cancelled = await db.alloc_request.update({
where: { id },
data: {
is_cancelled: true,
cancelled_at: new Date(),
cancelled_by: userId,
},
include: { details: { orderBy: { created_at: 'asc' } } },
});
console.log('[EMAIL STUB] Allocation request cancelled:', {
requestId: id,
userId,
companyId,
jobNumber: cancelled.job_number,
recipients: cancelled.email_recipients,
});
return serializeHeader(cancelled);
}

View file

@ -0,0 +1,70 @@
/**
* Available Coils Service
*
* Queries Epicor for coils available for allocation to a specific job.
* The legacy system used CoilAllocation.sql with JobNum + DBNAME params.
* This queries PartLot/PartBin data filtered by job number.
*/
import { execQuery, getPortalDbName } from '@/lib/epicor';
import type { AvailableCoilItem } from '@/types/requests';
type EpicorCoilRow = {
PartNum: string;
LotNum: string | null;
CoilNum: string | null;
OnHandQty: number;
Plant: string | null;
Warehouse: string | null;
};
/**
* Get available coils for allocation to a specific job.
*
* This is a best-effort query the legacy CoilAllocation.sql may not be available
* as a view in the current Epicor database. If the query fails, we return an empty
* array gracefully.
*/
export async function getAvailableCoils(
custId: string,
dbName: string,
jobNumber: string
): Promise<AvailableCoilItem[]> {
try {
// Try the portal coil allocation view/query
// Legacy used: SELECT ... FROM CoilAllocation WHERE JobNum = @JobNum AND DBNAME = @DBNAME
const query = `
SELECT
PartNum,
LotNum,
CoilNum,
OnHandQty,
Plant,
WarehouseCode AS Warehouse
FROM portal_CoilAllocation
WHERE JobNum = @JobNum
ORDER BY PartNum, LotNum, CoilNum
`;
const rows = await execQuery<EpicorCoilRow[]>(query, {
JobNum: jobNumber,
});
return rows.map((row) => ({
part_num: row.PartNum?.trim() ?? '',
lot_num: row.LotNum?.trim() || null,
coil_number: row.CoilNum?.trim() || null,
on_hand_qty: Number(row.OnHandQty ?? 0),
plant: row.Plant?.trim() || null,
warehouse: row.Warehouse?.trim() || null,
}));
} catch (error) {
// The coil allocation view may not exist yet — fail gracefully
console.warn(
'[available-coils] Failed to query coil allocation data. ' +
'The portal_CoilAllocation view may need to be created in Epicor.',
error instanceof Error ? error.message : error
);
return [];
}
}

View file

@ -0,0 +1,439 @@
/**
* Ship Request Service
*
* Handles CRUD operations for shipment request carts persisted in PostgreSQL.
* Carts are per-user, per-company. Only one active (non-submitted, non-cancelled) cart
* is allowed at a time.
*/
import { db } from '@/lib/db';
import type {
ShipRequestHeader,
ShipRequestListItem,
ShipRequestDetailItem,
AddShipCartItemPayload,
UpdateCartHeaderPayload,
UpdateCartItemPayload,
} from '@/types/requests';
// =============================================================================
// Serialization helpers
// =============================================================================
function serializeDetail(
d: {
id: string;
part_num: string;
lot_num: string | null;
plant: string;
warehouse: string | null;
quantity: number;
notes: string | null;
created_at: Date;
}
): ShipRequestDetailItem {
return {
id: d.id,
part_num: d.part_num,
lot_num: d.lot_num,
plant: d.plant,
warehouse: d.warehouse,
quantity: d.quantity,
notes: d.notes,
created_at: d.created_at.toISOString(),
};
}
function serializeHeader(
r: {
id: string;
auth_user_id: string;
quest_company_id: string;
ship_to_address: string;
order_number: string | null;
po_number: string | null;
release_number: string | null;
pickup_date: Date | null;
instructions: string | null;
email_recipients: string[];
is_submitted: boolean;
submitted_at: Date | null;
is_cancelled: boolean;
cancelled_at: Date | null;
cancelled_by: string | null;
last_cart_activity: Date;
created_at: Date;
updated_at: Date;
details: {
id: string;
part_num: string;
lot_num: string | null;
plant: string;
warehouse: string | null;
quantity: number;
notes: string | null;
created_at: Date;
}[];
}
): ShipRequestHeader {
return {
id: r.id,
auth_user_id: r.auth_user_id,
quest_company_id: r.quest_company_id,
ship_to_address: r.ship_to_address,
order_number: r.order_number,
po_number: r.po_number,
release_number: r.release_number,
pickup_date: r.pickup_date?.toISOString() ?? null,
instructions: r.instructions,
email_recipients: r.email_recipients,
is_submitted: r.is_submitted,
submitted_at: r.submitted_at?.toISOString() ?? null,
is_cancelled: r.is_cancelled,
cancelled_at: r.cancelled_at?.toISOString() ?? null,
cancelled_by: r.cancelled_by,
last_cart_activity: r.last_cart_activity.toISOString(),
created_at: r.created_at.toISOString(),
updated_at: r.updated_at.toISOString(),
details: r.details.map(serializeDetail),
};
}
// =============================================================================
// Cart Operations
// =============================================================================
/**
* Get or create an active cart (unsubmitted, uncancelled) for this user+company
*/
export async function getOrCreateCart(
userId: string,
companyId: string
): Promise<ShipRequestHeader> {
// Find existing active cart
const existing = await db.ship_request.findFirst({
where: {
auth_user_id: userId,
quest_company_id: companyId,
is_submitted: false,
is_cancelled: false,
},
include: { details: { orderBy: { created_at: 'asc' } } },
orderBy: { created_at: 'desc' },
});
if (existing) {
return serializeHeader(existing);
}
// Create new cart
const newCart = await db.ship_request.create({
data: {
auth_user_id: userId,
quest_company_id: companyId,
ship_to_address: '',
},
include: { details: true },
});
return serializeHeader(newCart);
}
/**
* Get a specific request by ID, scoped to a company
*/
export async function getRequestById(
id: string,
companyId: string
): Promise<ShipRequestHeader | null> {
const request = await db.ship_request.findFirst({
where: { id, quest_company_id: companyId },
include: { details: { orderBy: { created_at: 'asc' } } },
});
return request ? serializeHeader(request) : null;
}
/**
* Get all requests for a company (list view)
*/
export async function getRequests(
companyId: string
): Promise<ShipRequestListItem[]> {
const requests = await db.ship_request.findMany({
where: { quest_company_id: companyId },
include: { _count: { select: { details: true } } },
orderBy: { created_at: 'desc' },
});
return requests.map((r) => ({
id: r.id,
ship_to_address: r.ship_to_address,
order_number: r.order_number,
po_number: r.po_number,
is_submitted: r.is_submitted,
submitted_at: r.submitted_at?.toISOString() ?? null,
is_cancelled: r.is_cancelled,
cancelled_at: r.cancelled_at?.toISOString() ?? null,
created_at: r.created_at.toISOString(),
detail_count: r._count.details,
}));
}
/**
* Update cart header fields (ship-to, order#, PO#, etc.)
*/
export async function updateCartHeader(
id: string,
companyId: string,
data: UpdateCartHeaderPayload
): Promise<ShipRequestHeader> {
// Verify cart belongs to company and is still active
const cart = await db.ship_request.findFirst({
where: { id, quest_company_id: companyId, is_submitted: false, is_cancelled: false },
});
if (!cart) {
throw new Error('Cart not found or already submitted/cancelled');
}
const updated = await db.ship_request.update({
where: { id },
data: {
...(data.ship_to_address !== undefined && { ship_to_address: data.ship_to_address }),
...(data.order_number !== undefined && { order_number: data.order_number }),
...(data.po_number !== undefined && { po_number: data.po_number }),
...(data.release_number !== undefined && { release_number: data.release_number }),
...(data.pickup_date !== undefined && {
pickup_date: data.pickup_date ? new Date(data.pickup_date) : null,
}),
...(data.instructions !== undefined && { instructions: data.instructions }),
...(data.email_recipients !== undefined && { email_recipients: data.email_recipients }),
last_cart_activity: new Date(),
},
include: { details: { orderBy: { created_at: 'asc' } } },
});
return serializeHeader(updated);
}
// =============================================================================
// Cart Item Operations
// =============================================================================
/**
* Add an item to the cart. Checks for duplicates (same part+lot+plant+warehouse).
*/
export async function addCartItem(
requestId: string,
companyId: string,
item: AddShipCartItemPayload
): Promise<ShipRequestDetailItem> {
// Verify cart is active
const cart = await db.ship_request.findFirst({
where: { id: requestId, quest_company_id: companyId, is_submitted: false, is_cancelled: false },
});
if (!cart) {
throw new Error('Cart not found or already submitted/cancelled');
}
// Check for duplicates
const duplicate = await db.ship_request_detail.findFirst({
where: {
ship_request_id: requestId,
part_num: item.part_num,
lot_num: item.lot_num || null,
plant: item.plant,
warehouse: item.warehouse || null,
},
});
if (duplicate) {
throw new Error('This item already exists in the cart. Update the quantity instead.');
}
const detail = await db.ship_request_detail.create({
data: {
ship_request_id: requestId,
part_num: item.part_num,
lot_num: item.lot_num || null,
plant: item.plant,
warehouse: item.warehouse || null,
quantity: item.quantity,
notes: item.notes || null,
},
});
// Touch cart activity
await db.ship_request.update({
where: { id: requestId },
data: { last_cart_activity: new Date() },
});
return serializeDetail(detail);
}
/**
* Remove an item from the cart
*/
export async function removeCartItem(
detailId: string,
requestId: string,
companyId: string
): Promise<void> {
// Verify cart is active and detail belongs to it
const cart = await db.ship_request.findFirst({
where: { id: requestId, quest_company_id: companyId, is_submitted: false, is_cancelled: false },
});
if (!cart) {
throw new Error('Cart not found or already submitted/cancelled');
}
const detail = await db.ship_request_detail.findFirst({
where: { id: detailId, ship_request_id: requestId },
});
if (!detail) {
throw new Error('Item not found in cart');
}
await db.ship_request_detail.delete({ where: { id: detailId } });
// Touch cart activity
await db.ship_request.update({
where: { id: requestId },
data: { last_cart_activity: new Date() },
});
}
/**
* Update a cart item (quantity, notes)
*/
export async function updateCartItem(
detailId: string,
requestId: string,
companyId: string,
data: UpdateCartItemPayload
): Promise<ShipRequestDetailItem> {
// Verify cart is active
const cart = await db.ship_request.findFirst({
where: { id: requestId, quest_company_id: companyId, is_submitted: false, is_cancelled: false },
});
if (!cart) {
throw new Error('Cart not found or already submitted/cancelled');
}
const detail = await db.ship_request_detail.findFirst({
where: { id: detailId, ship_request_id: requestId },
});
if (!detail) {
throw new Error('Item not found in cart');
}
const updated = await db.ship_request_detail.update({
where: { id: detailId },
data: {
...(data.quantity !== undefined && { quantity: data.quantity }),
...(data.notes !== undefined && { notes: data.notes }),
},
});
// Touch cart activity
await db.ship_request.update({
where: { id: requestId },
data: { last_cart_activity: new Date() },
});
return serializeDetail(updated);
}
// =============================================================================
// Submit & Cancel
// =============================================================================
/**
* Submit a cart (mark as submitted, lock for editing)
*/
export async function submitCart(
id: string,
companyId: string,
userId: string
): Promise<ShipRequestHeader> {
const cart = await db.ship_request.findFirst({
where: { id, quest_company_id: companyId, is_submitted: false, is_cancelled: false },
include: { details: true },
});
if (!cart) {
throw new Error('Cart not found or already submitted/cancelled');
}
if (cart.details.length === 0) {
throw new Error('Cannot submit an empty cart');
}
if (!cart.ship_to_address) {
throw new Error('Ship-to address is required');
}
const submitted = await db.ship_request.update({
where: { id },
data: {
is_submitted: true,
submitted_at: new Date(),
},
include: { details: { orderBy: { created_at: 'asc' } } },
});
// Email notification stub (J-007 not built yet)
console.log('[EMAIL STUB] Shipment request submitted:', {
requestId: id,
userId,
companyId,
recipients: submitted.email_recipients,
itemCount: submitted.details.length,
});
return serializeHeader(submitted);
}
/**
* Cancel a submitted request
*/
export async function cancelRequest(
id: string,
companyId: string,
userId: string
): Promise<ShipRequestHeader> {
const request = await db.ship_request.findFirst({
where: { id, quest_company_id: companyId, is_cancelled: false },
});
if (!request) {
throw new Error('Request not found');
}
const cancelled = await db.ship_request.update({
where: { id },
data: {
is_cancelled: true,
cancelled_at: new Date(),
cancelled_by: userId,
},
include: { details: { orderBy: { created_at: 'asc' } } },
});
// Email notification stub
console.log('[EMAIL STUB] Shipment request cancelled:', {
requestId: id,
userId,
companyId,
recipients: cancelled.email_recipients,
});
return serializeHeader(cancelled);
}

View file

@ -0,0 +1,60 @@
/**
* Ship-To Address Service
*
* Queries Epicor portal_CustomerShipToAddresses view for customer ship-to addresses.
* Sub-users are filtered to only see addresses containing 'NB HANDY'.
*/
import { execQuery } from '@/lib/epicor';
import type { ShipToAddress } from '@/types/requests';
type EpicorShipToRow = {
ShipToNum: string;
ShipToName: string;
ShipToAddress1: string;
ShipToAddress2: string | null;
ShipToCity: string;
ShipToState: string;
ShipToZip: string;
};
/**
* Get ship-to addresses for a customer from Epicor
*/
export async function getShipToAddresses(
custId: string,
dbName: string,
isSubUser: boolean
): Promise<ShipToAddress[]> {
let query = `
SELECT
ShipToNum,
ShipToName,
Address1 AS ShipToAddress1,
Address2 AS ShipToAddress2,
City AS ShipToCity,
State AS ShipToState,
Zip AS ShipToZip
FROM portal_CustomerShipToAddresses
WHERE CustID = @CustID
`;
// Sub-users can only see addresses containing 'NB HANDY'
if (isSubUser) {
query += ` AND UPPER(ShipToName) LIKE '%NB HANDY%'\n`;
}
query += ' ORDER BY ShipToName';
const rows = await execQuery<EpicorShipToRow[]>(query, { CustID: custId });
return rows.map((row) => ({
ship_to_num: row.ShipToNum?.trim() ?? '',
name: row.ShipToName?.trim() ?? '',
address1: row.ShipToAddress1?.trim() ?? '',
address2: row.ShipToAddress2?.trim() || null,
city: row.ShipToCity?.trim() ?? '',
state: row.ShipToState?.trim() ?? '',
zip: row.ShipToZip?.trim() ?? '',
}));
}

167
src/types/requests.ts Normal file
View file

@ -0,0 +1,167 @@
/**
* Types for Shipment Request and Allocation Request cart workflows
*/
// =============================================================================
// SHIP-TO ADDRESSES (from Epicor portal_CustomerShipToAddresses view)
// =============================================================================
export type ShipToAddress = {
ship_to_num: string;
name: string;
address1: string;
address2: string | null;
city: string;
state: string;
zip: string;
};
// =============================================================================
// SHIPMENT REQUESTS
// =============================================================================
export type ShipRequestDetailItem = {
id: string;
part_num: string;
lot_num: string | null;
plant: string;
warehouse: string | null;
quantity: number;
notes: string | null;
created_at: string;
};
export type ShipRequestHeader = {
id: string;
auth_user_id: string;
quest_company_id: string;
ship_to_address: string;
order_number: string | null;
po_number: string | null;
release_number: string | null;
pickup_date: string | null;
instructions: string | null;
email_recipients: string[];
is_submitted: boolean;
submitted_at: string | null;
is_cancelled: boolean;
cancelled_at: string | null;
cancelled_by: string | null;
last_cart_activity: string;
created_at: string;
updated_at: string;
details: ShipRequestDetailItem[];
};
export type ShipRequestListItem = {
id: string;
ship_to_address: string;
order_number: string | null;
po_number: string | null;
is_submitted: boolean;
submitted_at: string | null;
is_cancelled: boolean;
cancelled_at: string | null;
created_at: string;
detail_count: number;
};
// =============================================================================
// ALLOCATION REQUESTS
// =============================================================================
export type AllocRequestDetailItem = {
id: string;
part_num: string;
lot_num: string | null;
coil_number: string | null;
quantity: number;
notes: string | null;
created_at: string;
};
export type AllocRequestHeader = {
id: string;
auth_user_id: string;
quest_company_id: string;
job_number: string;
ship_to_address: string;
order_number: string | null;
po_number: string | null;
release_number: string | null;
pickup_date: string | null;
instructions: string | null;
email_recipients: string[];
is_submitted: boolean;
submitted_at: string | null;
is_cancelled: boolean;
cancelled_at: string | null;
cancelled_by: string | null;
last_cart_activity: string;
created_at: string;
updated_at: string;
details: AllocRequestDetailItem[];
};
export type AllocRequestListItem = {
id: string;
job_number: string;
ship_to_address: string;
order_number: string | null;
po_number: string | null;
is_submitted: boolean;
submitted_at: string | null;
is_cancelled: boolean;
cancelled_at: string | null;
created_at: string;
detail_count: number;
};
// =============================================================================
// AVAILABLE COILS (for allocation request browsing)
// =============================================================================
export type AvailableCoilItem = {
part_num: string;
lot_num: string | null;
coil_number: string | null;
on_hand_qty: number;
plant: string | null;
warehouse: string | null;
};
// =============================================================================
// CART MUTATION PAYLOADS
// =============================================================================
export type AddShipCartItemPayload = {
part_num: string;
lot_num?: string;
plant: string;
warehouse?: string;
quantity: number;
notes?: string;
};
export type AddAllocCartItemPayload = {
part_num: string;
lot_num?: string;
coil_number?: string;
quantity: number;
notes?: string;
};
export type UpdateCartHeaderPayload = {
ship_to_address?: string;
order_number?: string | null;
po_number?: string | null;
release_number?: string | null;
pickup_date?: string | null;
instructions?: string | null;
email_recipients?: string[];
};
export type UpdateCartItemPayload = {
quantity?: number;
notes?: string | null;
};