feat: add shipment request (C-013) and allocation request (C-014) cart workflows
Multi-step cart workflows for both shipment and allocation requests: - Ship-to address selection from Epicor portal_CustomerShipToAddresses - Inventory browser dialog (shipment) / coil browser dialog (allocation) - Cart persistence in DB with duplicate detection - Details form (order#, PO#, pickup date, instructions, email recipients) - Review & submit with confirmation dialog - Cancel with permission gate and confirmation - Shared components: stepper, ship-to selector, cart table, header form, review - 16 API routes, 4 services, 13 components, 6 pages - Phase 2 complete: 16/16 tasks done Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b32553668d
commit
e60e07c9a5
41 changed files with 5106 additions and 20 deletions
332
src/components/requests/allocation-request-cart.tsx
Normal file
332
src/components/requests/allocation-request-cart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue