quest-vorteq/src/components/requests/allocation-request-cart.tsx
Lorentz Hinrichsen e60e07c9a5
Some checks failed
Build and Deploy / build (push) Successful in 4m39s
Build and Deploy / deploy (push) Failing after 1s
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>
2026-02-17 10:37:38 -05:00

332 lines
9.9 KiB
TypeScript

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