333 lines
9.9 KiB
TypeScript
333 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>
|
||
|
|
);
|
||
|
|
}
|