From 500811f3e3df460c42df789a323d7625cf7dd03d Mon Sep 17 00:00:00 2001 From: Lorentz Hinrichsen Date: Tue, 17 Feb 2026 17:01:53 -0500 Subject: [PATCH] feat: redesign shipment request cart as inventory-integrated flow Replace the wizard-style 4-step shipment cart with an inventory-page- integrated flow matching the legacy portal UX. Users now add items directly from inventory detail rows via "+" buttons, with a persistent cart bar showing status. The "Complete Release" page is a flat form instead of a multi-step wizard. Key changes: - Add ShipmentCartProvider context for cross-page cart state - Add CartBar component (start/cancel release, view status) - Integrate cart column into inventory detail table with per-row add buttons, plant mismatch indicators, and "Add All" support - Rewrite /shipment-requests/new as flat Complete Release page - Add check_only param to cart API (avoid auto-creating on mount) - Add server-side plant constraint validation on cart items - Add batch add API endpoint for "Add All" functionality - Delete deprecated wizard cart and inventory browser dialog Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 2 +- package.json | 2 +- .../inventory/[category]/detail/page.tsx | 6 + src/app/(portal)/layout.tsx | 9 +- .../(portal)/shipment-requests/new/page.tsx | 332 +++++++++++++++++- .../cart/items/batch/route.ts | 50 +++ .../api/shipment-requests/cart/items/route.ts | 33 +- src/app/api/shipment-requests/cart/route.ts | 17 +- .../inventory/inventory-detail-table.tsx | 302 +++++++++++++--- .../layout/portal-client-providers.tsx | 24 ++ src/components/requests/cart-bar.tsx | 162 +++++++++ .../requests/inventory-browser-dialog.tsx | 219 ------------ .../requests/shipment-request-cart.tsx | 259 -------------- src/components/ui/alert-dialog.tsx | 141 ++++++++ src/hooks/use-shipment-cart.tsx | 286 +++++++++++++++ src/services/ship-requests.ts | 116 ++++++ 16 files changed, 1423 insertions(+), 537 deletions(-) create mode 100644 src/app/api/shipment-requests/cart/items/batch/route.ts create mode 100644 src/components/layout/portal-client-providers.tsx create mode 100644 src/components/requests/cart-bar.tsx delete mode 100644 src/components/requests/inventory-browser-dialog.tsx delete mode 100644 src/components/requests/shipment-request-cart.tsx create mode 100644 src/components/ui/alert-dialog.tsx create mode 100644 src/hooks/use-shipment-cart.tsx diff --git a/package-lock.json b/package-lock.json index a97b0d1..33484c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@hookform/resolvers": "^3.9.1", "@prisma/client": "^6.1.0", "@radix-ui/react-accordion": "^1.2.2", - "@radix-ui/react-alert-dialog": "^1.1.4", + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-checkbox": "^1.1.3", "@radix-ui/react-dialog": "^1.1.15", diff --git a/package.json b/package.json index 026fb22..436eab1 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "@hookform/resolvers": "^3.9.1", "@prisma/client": "^6.1.0", "@radix-ui/react-accordion": "^1.2.2", - "@radix-ui/react-alert-dialog": "^1.1.4", + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-checkbox": "^1.1.3", "@radix-ui/react-dialog": "^1.1.15", diff --git a/src/app/(portal)/inventory/[category]/detail/page.tsx b/src/app/(portal)/inventory/[category]/detail/page.tsx index fd7e8fa..cd03587 100644 --- a/src/app/(portal)/inventory/[category]/detail/page.tsx +++ b/src/app/(portal)/inventory/[category]/detail/page.tsx @@ -9,6 +9,7 @@ import { getQuestSession, getActiveCompany, isSubUser, + hasPermission, } from '@/lib/permissions'; import { InventoryDetailTable } from '@/components/inventory/inventory-detail-table'; import { Card, CardContent } from '@/components/ui/card'; @@ -74,6 +75,9 @@ async function InventoryDetailData({ const dbName = `[${process.env.PORTAL_DB_NAME || 'VorteqPortal'}]`; const sub = userIsSubUser ? 1 : 0; + // Check if user can create shipment requests (for cart integration) + const canShip = await hasPermission('create_shipment_request'); + const details = await getInventoryDetails( category, activeCompany.epicor_cust_id, @@ -95,6 +99,8 @@ async function InventoryDetailData({ partNum={part} plant={plant} warehouse={warehouse} + cartEnabled={canShip} + category={category} /> ); } diff --git a/src/app/(portal)/layout.tsx b/src/app/(portal)/layout.tsx index 5397767..1ed143b 100644 --- a/src/app/(portal)/layout.tsx +++ b/src/app/(portal)/layout.tsx @@ -2,9 +2,11 @@ import { getQuestSession, getActiveCompany, getUserCompanies, + hasPermission, } from '@/lib/permissions'; import { PortalSidebar } from '@/components/layout/portal-sidebar'; import { PortalHeader } from '@/components/layout/portal-header'; +import { PortalClientProviders } from '@/components/layout/portal-client-providers'; import { Breadcrumb } from '@/components/layout/breadcrumb'; import { redirect } from 'next/navigation'; import { getUnreadAlertCount } from '@/services/notifications'; @@ -33,6 +35,9 @@ export default async function PortalLayout({ // Get available companies for the switcher const companies = await getUserCompanies(); + // Check if user can create shipment requests (for cart provider) + const hasShipmentPermission = await hasPermission('create_shipment_request'); + return (
@@ -51,7 +56,9 @@ export default async function PortalLayout({ />
- {children} + + {children} +
diff --git a/src/app/(portal)/shipment-requests/new/page.tsx b/src/app/(portal)/shipment-requests/new/page.tsx index 927125e..57308ee 100644 --- a/src/app/(portal)/shipment-requests/new/page.tsx +++ b/src/app/(portal)/shipment-requests/new/page.tsx @@ -1,12 +1,334 @@ 'use client'; -import { ShipmentRequestCart } from '@/components/requests/shipment-request-cart'; +import { useMemo, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useShipmentCart } from '@/hooks/use-shipment-cart'; +import { CartBar } from '@/components/requests/cart-bar'; +import { CartItemsTable } from '@/components/requests/cart-items-table'; +import { ShipToSelector } from '@/components/requests/ship-to-selector'; +import { CartHeaderForm } from '@/components/requests/cart-header-form'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { + Table, + TableBody, + TableCell, + TableRow, +} from '@/components/ui/table'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; +import { ArrowLeft, Loader2, Send } from 'lucide-react'; +import { useToast } from '@/hooks/use-toast'; +import Link from 'next/link'; +import type { UpdateCartHeaderPayload } from '@/types/requests'; + +/** + * "Complete Release" page — flat single-page form for reviewing cart items, + * setting ship-to address and order details, and submitting. + */ +export default function CompleteReleasePage() { + const router = useRouter(); + const { toast } = useToast(); + const { + cart, + cartLoading, + hasCart, + totalLbs, + refreshCart, + removeItem, + } = useShipmentCart(); + const [submitting, setSubmitting] = useState(false); + + // Group items by part number for totals summary + const totalsByPart = useMemo(() => { + if (!cart) return []; + const groups = new Map(); + for (const item of cart.details) { + const existing = groups.get(item.part_num); + if (existing) { + existing.totalQty += item.quantity; + existing.count += 1; + } else { + groups.set(item.part_num, { + part_num: item.part_num, + totalQty: item.quantity, + count: 1, + }); + } + } + return Array.from(groups.values()); + }, [cart]); + + const grandTotal = useMemo(() => Math.round(totalLbs), [totalLbs]); + + // Update cart header + 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'); + } + await refreshCart(); + }; + + // Update cart 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 refreshCart(); + }; + + // Submit cart + const handleSubmit = async () => { + if (!cart) return; + setSubmitting(true); + try { + 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'); + } finally { + setSubmitting(false); + } + }; + + if (cartLoading) { + return ( +
+

Complete Shipment Release

+ + + + + +
+ ); + } + + // No active cart + if (!hasCart || !cart) { + return ( +
+

Complete Shipment Release

+ + +

+ You must add some items to your shipment release in order to + complete it. +

+ + + +
+
+
+ ); + } + + const itemCount = cart.details.length; + const canSubmit = + itemCount > 0 && !!cart.ship_to_address && !submitting; -export default function NewShipmentRequestPage() { return ( -
-

New Shipment Request

- +
+

Complete Shipment Release

+ + {/* Cart bar (hide "View/Complete Release" button since we're on this page) */} + + + {/* Empty cart warning */} + {itemCount === 0 && ( + + +

+ You must add some items to your shipment release in order to + complete it. +

+ + + +
+
+ )} + + {/* Items table */} + {itemCount > 0 && ( + <> + router.push('/inventory')} + /> + + {/* Totals by Part */} + + + Totals by Part + + +
+ + + + + + + + + + {totalsByPart.map((group, i) => ( + + + {group.part_num} + + + {group.count} + + + {Math.round(group.totalQty).toLocaleString('en-US')} + + + ))} + +
+ Part # + + # Items + + Total Lbs +
+
+
+ Total Shipment: {grandTotal.toLocaleString('en-US')} lbs +
+
+
+ + )} + + {/* Ship-To Address */} + updateHeader({ ship_to_address: address })} + onNext={() => { + /* no-op — flat page, no stepper */ + }} + /> + + {/* Order Details Form */} + router.push('/inventory')} + onNext={() => { + /* no-op — flat page */ + }} + readOnly={false} + /> + + {/* Action buttons */} +
+ + + + + + + + + + + Submit Shipment Release? + + This will submit your shipment release request with{' '} + {itemCount} item{itemCount !== 1 ? 's' : ''} totaling{' '} + {grandTotal.toLocaleString('en-US')} lbs. This action cannot + be undone. + + + + Cancel + + Submit + + + + +
); } diff --git a/src/app/api/shipment-requests/cart/items/batch/route.ts b/src/app/api/shipment-requests/cart/items/batch/route.ts new file mode 100644 index 0000000..16d1b38 --- /dev/null +++ b/src/app/api/shipment-requests/cart/items/batch/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getQuestSession, getActiveCompany, requirePermission } from '@/lib/permissions'; +import { addBatchCartItems } from '@/services/ship-requests'; +import type { AddShipCartItemPayload } from '@/types/requests'; + +/** + * POST: Add multiple items to the shipment request cart in one request. + * Used by the "Add All" button on the inventory detail page. + */ +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 { + request_id: string; + items: AddShipCartItemPayload[]; + }; + + if (!body.request_id) { + return NextResponse.json({ error: 'Request ID is required' }, { status: 400 }); + } + + if (!Array.isArray(body.items) || body.items.length === 0) { + return NextResponse.json({ error: 'Items array is required' }, { status: 400 }); + } + + const result = await addBatchCartItems(body.request_id, activeCompany.id, body.items); + return NextResponse.json({ + success: true, + added: result.added, + skipped: result.skipped, + errors: result.errors, + }); + } catch (error) { + console.error('Error batch adding cart items:', error); + const message = error instanceof Error ? error.message : 'Internal server error'; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/shipment-requests/cart/items/route.ts b/src/app/api/shipment-requests/cart/items/route.ts index 229bf48..ff4c16c 100644 --- a/src/app/api/shipment-requests/cart/items/route.ts +++ b/src/app/api/shipment-requests/cart/items/route.ts @@ -1,10 +1,11 @@ import { NextRequest, NextResponse } from 'next/server'; import { getQuestSession, getActiveCompany, requirePermission } from '@/lib/permissions'; -import { addCartItem, removeCartItem } from '@/services/ship-requests'; +import { addCartItem, removeCartItem, getRequestById } from '@/services/ship-requests'; import type { AddShipCartItemPayload } from '@/types/requests'; /** - * POST: Add an item to the shipment request cart + * POST: Add an item to the shipment request cart. + * Enforces single-plant constraint: all items must be from the same plant. */ export async function POST(request: NextRequest) { const session = await getQuestSession(); @@ -35,13 +36,35 @@ export async function POST(request: NextRequest) { ); } + // Plant constraint: check existing items' plant + const cart = await getRequestById(request_id, activeCompany.id); + if (cart && cart.details.length > 0) { + const firstDetail = cart.details[0]!; + const lockedPlant = firstDetail.plant; + if (item.plant !== lockedPlant) { + return NextResponse.json( + { + success: false, + action: 'bad_plant', + reason: `Cannot add items from a different plant. Your shipment release is locked to: ${lockedPlant}`, + }, + { status: 409 } + ); + } + } + const detail = await addCartItem(request_id, activeCompany.id, item); - return NextResponse.json(detail, { status: 201 }); + return NextResponse.json({ success: true, 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 }); + if (message.includes('already exists')) { + return NextResponse.json( + { success: false, action: 'in_cart', reason: message }, + { status: 409 } + ); + } + return NextResponse.json({ error: message }, { status: 500 }); } } diff --git a/src/app/api/shipment-requests/cart/route.ts b/src/app/api/shipment-requests/cart/route.ts index 2386ef3..b9a541d 100644 --- a/src/app/api/shipment-requests/cart/route.ts +++ b/src/app/api/shipment-requests/cart/route.ts @@ -1,14 +1,17 @@ import { NextRequest, NextResponse } from 'next/server'; import { getQuestSession, getActiveCompany, requirePermission } from '@/lib/permissions'; -import { getOrCreateCart, updateCartHeader } from '@/services/ship-requests'; +import { getOrCreateCart, getActiveCart, 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 + * + * Query params: + * - check_only=true: Only check for existing active cart, return 204 if none exists */ -export async function GET() { +export async function GET(request: NextRequest) { const session = await getQuestSession(); const activeCompany = await getActiveCompany(); @@ -23,6 +26,16 @@ export async function GET() { } try { + const checkOnly = request.nextUrl.searchParams.get('check_only') === 'true'; + + if (checkOnly) { + const cart = await getActiveCart(session.user.id, activeCompany.id); + if (!cart) { + return new NextResponse(null, { status: 204 }); + } + return NextResponse.json(cart); + } + const cart = await getOrCreateCart(session.user.id, activeCompany.id); return NextResponse.json(cart); } catch (error) { diff --git a/src/components/inventory/inventory-detail-table.tsx b/src/components/inventory/inventory-detail-table.tsx index 64029c4..379c0b2 100644 --- a/src/components/inventory/inventory-detail-table.tsx +++ b/src/components/inventory/inventory-detail-table.tsx @@ -17,11 +17,20 @@ import { TableCell, TableRow, } from '@/components/ui/table'; -import { Download, Search } from 'lucide-react'; +import { Download, Search, Plus, Ban, Loader2 } from 'lucide-react'; import { SortableTableHead, useSortableTable, } from '@/components/ui/sortable-table-head'; +import { + useShipmentCartOptional, + cartItemKey, +} from '@/hooks/use-shipment-cart'; +import { CartBar } from '@/components/requests/cart-bar'; +import { useToast } from '@/hooks/use-toast'; + +// Categories where items cannot be added to a shipment release +const BLOCKED_CATEGORIES = ['unprocessed-rr', 'processed-rr']; type Props = { data: InventoryDetailRow[]; @@ -29,6 +38,10 @@ type Props = { plant?: string; warehouse?: string; embedded?: boolean; + /** Whether to show cart integration (add-to-cart buttons, cart bar) */ + cartEnabled?: boolean; + /** Current inventory category (used to block R&R) */ + category?: string; }; function formatNum(val: number | string | null | undefined): string { @@ -88,11 +101,21 @@ function flattenRow(row: InventoryDetailRow): FlatDetailRow { function DetailTableContent({ data, partNum, + plant, + cartEnabled = false, + category, }: { data: InventoryDetailRow[]; partNum?: string; + plant?: string; + cartEnabled?: boolean; + category?: string; }) { const [searchTerm, setSearchTerm] = useState(''); + const [addingIdx, setAddingIdx] = useState(null); + const [addingAll, setAddingAll] = useState(false); + const cartCtx = useShipmentCartOptional(); + const { toast } = useToast(); const flatData = data.map(flattenRow); @@ -113,6 +136,101 @@ function DetailTableContent({ const { sortKey, sortDirection, handleSort, sortedData } = useSortableTable(filteredData); + // Cart state + const showCartColumn = + cartEnabled && + cartCtx && + cartCtx.hasCart && + category && + !BLOCKED_CATEGORIES.includes(category); + + const cartPlant = cartCtx?.cartPlant; + const isPlantMismatch = !!( + showCartColumn && + cartPlant && + plant && + cartPlant !== plant + ); + + // Build payload for a row + const buildPayload = (raw: InventoryDetailRow) => ({ + part_num: String(raw.CustPartNum ?? raw.LotNum ?? ''), + lot_num: raw.LotNum ? String(raw.LotNum) : undefined, + plant: plant || String(raw.WIP_FG ?? ''), + warehouse: raw.Bin ? String(raw.Bin) : undefined, + quantity: Number(raw.OnHandQty ?? 0), + }); + + // Check if a row is in the cart + const isInCart = (raw: InventoryDetailRow) => { + if (!cartCtx) return false; + const key = cartItemKey( + String(raw.CustPartNum ?? raw.LotNum ?? ''), + raw.LotNum ? String(raw.LotNum) : null, + plant || String(raw.WIP_FG ?? ''), + raw.Bin ? String(raw.Bin) : null + ); + return cartCtx.cartItemMap.has(key); + }; + + const handleAddItem = async (raw: InventoryDetailRow, idx: number) => { + if (!cartCtx) return; + setAddingIdx(idx); + try { + const result = await cartCtx.addItem(buildPayload(raw)); + if (result.success) { + toast({ title: 'Item added to shipment' }); + } else if (result.action === 'bad_plant') { + toast({ + title: 'Plant mismatch', + description: result.reason, + variant: 'destructive', + }); + } else if (result.action === 'in_cart') { + toast({ + title: 'Already in cart', + description: 'This item is already in your shipment release.', + }); + } else { + toast({ + title: 'Error', + description: result.reason || 'Failed to add item', + variant: 'destructive', + }); + } + } finally { + setAddingIdx(null); + } + }; + + const handleAddAll = async () => { + if (!cartCtx) return; + setAddingAll(true); + try { + const items = data + .filter((raw) => !isInCart(raw)) + .map(buildPayload); + + if (items.length === 0) { + toast({ title: 'All items are already in your shipment' }); + return; + } + + await cartCtx.addAllItems(items); + toast({ + title: 'Items added', + description: `Added items to your shipment release.`, + }); + } finally { + setAddingAll(false); + } + }; + + // Count how many items can be added (not already in cart) + const availableToAdd = showCartColumn && !isPlantMismatch + ? data.filter((raw) => !isInCart(raw)).length + : 0; + const handleExportCSV = () => { const headers = [ 'Cust Part#', @@ -162,8 +280,25 @@ function DetailTableContent({ window.URL.revokeObjectURL(url); }; + const totalColumns = showCartColumn ? 15 : 14; + return ( <> + {/* Plant mismatch warning */} + {showCartColumn && isPlantMismatch && ( +
+ You cannot add items to your shipment release from this plant{' '} + because items from another plant have already been selected. +
+ )} + + {/* R&R blocked warning */} + {cartEnabled && cartCtx?.hasCart && category && BLOCKED_CATEGORIES.includes(category) && ( +
+ Rejects and Returns may not be added to a shipment release. +
+ )} +
@@ -174,6 +309,21 @@ function DetailTableContent({ className="pl-8" />
+ {showCartColumn && !isPlantMismatch && availableToAdd > 0 && ( + + )} +
+ ); + } + + // Active cart — show status bar + const itemCount = cart?.details.length ?? 0; + const formattedLbs = Math.round(totalLbs).toLocaleString('en-US'); + const shipTo = cart?.ship_to_address; + + return ( +
+
+ {/* Left: Cart info */} +
+
+ + Shipment Release + +
+
+ Date Started: + + {cart?.created_at + ? new Date(cart.created_at).toLocaleDateString() + : '-'} + +
+ {cartPlant && ( +
+ Plant: + {cartPlant} +
+ )} +
+ Items Selected: + {itemCount} +
+
+ Total Lbs: + {formattedLbs} +
+ {shipTo && ( +
+ Ship To: + {shipTo} +
+ )} +
+ + {/* Right: Action buttons */} +
+ {!hideCompleteButton && ( + + + + )} + +
+
+
+ ); +} diff --git a/src/components/requests/inventory-browser-dialog.tsx b/src/components/requests/inventory-browser-dialog.tsx deleted file mode 100644 index 47e407b..0000000 --- a/src/components/requests/inventory-browser-dialog.tsx +++ /dev/null @@ -1,219 +0,0 @@ -'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; -}; - -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('finished-goods'); - const [items, setItems] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [searchTerm, setSearchTerm] = useState(''); - const [addingIndex, setAddingIndex] = useState(null); - const [quantities, setQuantities] = useState>({}); - - 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 ( - !o && onClose()}> - - - - - Browse Inventory - - - -
- {CATEGORIES.map((cat) => ( - - ))} -
- -
- - setSearchTerm(e.target.value)} - className="pl-8" - /> -
- -
- {loading ? ( -
- -
- ) : error ? ( -

- Failed to load inventory: {error} -

- ) : filtered && filtered.length === 0 ? ( -

- No inventory items found. -

- ) : filtered ? ( -
- - - - - - - - - - - - - {filtered.slice(0, 100).map((item, i) => ( - - - {item.LotNum || '-'} - - {item.CustPartNum || '-'} - {item.Bin || '-'} - - {item.OnHandQty ?? 0} - - - - setQuantities((prev) => ({ - ...prev, - [i]: e.target.value, - })) - } - min={0} - step="any" - /> - - - - - - ))} - -
- Lot # - - Part # - - Bin - - On Hand - - Qty - - Add -
- {filtered.length > 100 && ( -

- Showing first 100 of {filtered.length} items. Use search to narrow results. -

- )} -
- ) : null} -
-
-
- ); -} diff --git a/src/components/requests/shipment-request-cart.tsx b/src/components/requests/shipment-request-cart.tsx deleted file mode 100644 index 10e69de..0000000 --- a/src/components/requests/shipment-request-cart.tsx +++ /dev/null @@ -1,259 +0,0 @@ -'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(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(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 ( - - - - - - ); - } - - if (error || !cart) { - return ( - - - Failed to load cart: {error || 'Unknown error'} - - - ); - } - - return ( -
- - - {/* Step 0: Ship-To Address */} - {currentStep === 0 && ( - setCurrentStep(1)} - /> - )} - - {/* Step 1: Items */} - {currentStep === 1 && ( - <> - setBrowserOpen(true)} - /> -
- - -
- setBrowserOpen(false)} - onAddItem={addItem} - /> - - )} - - {/* Step 2: Details */} - {currentStep === 2 && ( - setCurrentStep(1)} - onNext={() => setCurrentStep(3)} - /> - )} - - {/* Step 3: Review */} - {currentStep === 3 && ( - setCurrentStep(2)} - /> - )} -
- ); -} diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..eaf8836 --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -0,0 +1,141 @@ +"use client" + +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/src/hooks/use-shipment-cart.tsx b/src/hooks/use-shipment-cart.tsx new file mode 100644 index 0000000..4ffad7a --- /dev/null +++ b/src/hooks/use-shipment-cart.tsx @@ -0,0 +1,286 @@ +'use client'; + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react'; +import type { + ShipRequestHeader, + ShipRequestDetailItem, + AddShipCartItemPayload, +} from '@/types/requests'; + +// ============================================================================= +// Types +// ============================================================================= + +type AddItemResult = { + success: boolean; + action?: 'in_cart' | 'bad_plant'; + reason?: string; +}; + +type ShipmentCartContextValue = { + /** Current active cart (null if none) */ + cart: ShipRequestHeader | null; + /** Whether the initial cart check is loading */ + cartLoading: boolean; + /** Map of "partNum|lotNum|plant|warehouse" → detail item for O(1) lookups */ + cartItemMap: Map; + /** The locked plant (from first item), or null if cart is empty */ + cartPlant: string | null; + /** Whether there's an active (non-submitted, non-cancelled) cart */ + hasCart: boolean; + /** Total weight (sum of quantities) in cart */ + totalLbs: number; + /** Start a new cart (auto-creates via API) */ + startCart: () => Promise; + /** Cancel the active draft cart */ + cancelCart: () => Promise; + /** Add a single item to the cart */ + addItem: (item: AddShipCartItemPayload) => Promise; + /** Add all items in batch */ + addAllItems: (items: AddShipCartItemPayload[]) => Promise; + /** Remove a single item from the cart */ + removeItem: (detailId: string) => Promise; + /** Re-fetch cart data from server */ + refreshCart: () => Promise; +}; + +// ============================================================================= +// Context +// ============================================================================= + +const ShipmentCartContext = createContext(null); + +// ============================================================================= +// Helper: build lookup key for cart items +// ============================================================================= + +function cartItemKey( + partNum: string, + lotNum: string | null | undefined, + plant: string, + warehouse: string | null | undefined +): string { + return `${partNum}|${lotNum ?? ''}|${plant}|${warehouse ?? ''}`; +} + +// ============================================================================= +// Provider +// ============================================================================= + +export function ShipmentCartProvider({ children }: { children: ReactNode }) { + const [cart, setCart] = useState(null); + const [cartLoading, setCartLoading] = useState(true); + + // On mount: lightweight check for existing active cart + const checkCart = useCallback(async () => { + try { + const res = await fetch('/api/shipment-requests/cart?check_only=true'); + if (res.status === 204) { + setCart(null); + } else if (res.ok) { + const data = await res.json(); + setCart(data); + } else if (res.status === 403) { + // User doesn't have permission - that's fine, no cart + setCart(null); + } + } catch { + // Network error - fail silently, cart features just won't show + setCart(null); + } finally { + setCartLoading(false); + } + }, []); + + useEffect(() => { + checkCart(); + }, [checkCart]); + + // Refresh cart (full re-fetch) + const refreshCart = useCallback(async () => { + try { + const res = await fetch('/api/shipment-requests/cart?check_only=true'); + if (res.status === 204) { + setCart(null); + } else if (res.ok) { + const data = await res.json(); + setCart(data); + } + } catch { + // Silently fail + } + }, []); + + // Start a new cart (auto-creates) + const startCart = useCallback(async () => { + const res = await fetch('/api/shipment-requests/cart'); + if (!res.ok) throw new Error('Failed to start cart'); + const data = await res.json(); + setCart(data); + }, []); + + // Cancel active draft cart + const cancelCart = useCallback(async () => { + if (!cart) return; + const res = await fetch(`/api/shipment-requests/${cart.id}/cancel`, { + method: 'POST', + }); + if (!res.ok) throw new Error('Failed to cancel cart'); + setCart(null); + }, [cart]); + + // Add single item + const addItem = useCallback( + async (item: AddShipCartItemPayload): Promise => { + if (!cart) return { success: false, reason: 'No active cart' }; + + const res = await fetch('/api/shipment-requests/cart/items', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ request_id: cart.id, ...item }), + }); + + const data = await res.json(); + + if (!res.ok) { + return { + success: false, + action: data.action, + reason: data.reason || data.error || 'Failed to add item', + }; + } + + // Refresh cart to get updated details + await refreshCart(); + return { success: true }; + }, + [cart, refreshCart] + ); + + // Add all items in batch + const addAllItems = useCallback( + async (items: AddShipCartItemPayload[]) => { + if (!cart) return; + + await fetch('/api/shipment-requests/cart/items/batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ request_id: cart.id, items }), + }); + + // Refresh cart regardless of result + await refreshCart(); + }, + [cart, refreshCart] + ); + + // Remove item + const removeItem = useCallback( + async (detailId: string) => { + if (!cart) return; + + await fetch( + `/api/shipment-requests/cart/items?detailId=${detailId}&requestId=${cart.id}`, + { method: 'DELETE' } + ); + + await refreshCart(); + }, + [cart, refreshCart] + ); + + // Derived values + const cartItemMap = useMemo(() => { + const map = new Map(); + if (cart) { + for (const d of cart.details) { + const key = cartItemKey(d.part_num, d.lot_num, d.plant, d.warehouse); + map.set(key, d); + } + } + return map; + }, [cart]); + + const cartPlant = useMemo(() => { + if (!cart || cart.details.length === 0) return null; + return cart.details[0]!.plant; + }, [cart]); + + const hasCart = cart !== null; + + const totalLbs = useMemo(() => { + if (!cart) return 0; + return cart.details.reduce((sum, d) => sum + d.quantity, 0); + }, [cart]); + + const value: ShipmentCartContextValue = useMemo( + () => ({ + cart, + cartLoading, + cartItemMap, + cartPlant, + hasCart, + totalLbs, + startCart, + cancelCart, + addItem, + addAllItems, + removeItem, + refreshCart, + }), + [ + cart, + cartLoading, + cartItemMap, + cartPlant, + hasCart, + totalLbs, + startCart, + cancelCart, + addItem, + addAllItems, + removeItem, + refreshCart, + ] + ); + + return ( + + {children} + + ); +} + +// ============================================================================= +// Hook +// ============================================================================= + +export function useShipmentCart() { + const ctx = useContext(ShipmentCartContext); + if (!ctx) { + throw new Error('useShipmentCart must be used within ShipmentCartProvider'); + } + return ctx; +} + +/** + * Safe version of useShipmentCart that returns null if not in provider. + * Use this for components that may or may not be inside the cart provider. + */ +export function useShipmentCartOptional() { + return useContext(ShipmentCartContext); +} + +/** + * Build a cart item lookup key from inventory row data. + * Exported so components can use the same key format. + */ +export { cartItemKey }; diff --git a/src/services/ship-requests.ts b/src/services/ship-requests.ts index 51973c4..08ab754 100644 --- a/src/services/ship-requests.ts +++ b/src/services/ship-requests.ts @@ -437,3 +437,119 @@ export async function cancelRequest( return serializeHeader(cancelled); } + +// ============================================================================= +// Lightweight Cart Check (no auto-create) +// ============================================================================= + +/** + * Check if an active cart exists for this user+company WITHOUT creating one. + * Returns null if no active cart exists. + */ +export async function getActiveCart( + userId: string, + companyId: string +): Promise { + 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' }, + }); + + return existing ? serializeHeader(existing) : null; +} + +// ============================================================================= +// Batch Cart Item Operations +// ============================================================================= + +export type BatchAddResult = { + added: ShipRequestDetailItem[]; + skipped: { item: AddShipCartItemPayload; reason: string }[]; + errors: { item: AddShipCartItemPayload; error: string }[]; +}; + +/** + * Add multiple items to the cart in one operation. + * Skips duplicates and plant mismatches rather than throwing. + */ +export async function addBatchCartItems( + requestId: string, + companyId: string, + items: AddShipCartItemPayload[] +): Promise { + // Verify cart is active + const cart = await db.ship_request.findFirst({ + where: { id: requestId, quest_company_id: companyId, is_submitted: false, is_cancelled: false }, + include: { details: { orderBy: { created_at: 'asc' } } }, + }); + + if (!cart) { + throw new Error('Cart not found or already submitted/cancelled'); + } + + // Determine locked plant from existing items + const lockedPlant = cart.details.length > 0 ? cart.details[0]!.plant : null; + + const added: ShipRequestDetailItem[] = []; + const skipped: { item: AddShipCartItemPayload; reason: string }[] = []; + const errors: { item: AddShipCartItemPayload; error: string }[] = []; + + for (const item of items) { + try { + // Plant constraint check + if (lockedPlant && item.plant !== lockedPlant) { + skipped.push({ item, reason: `Plant mismatch: cart is locked to ${lockedPlant}` }); + continue; + } + + // Duplicate check + 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) { + skipped.push({ item, reason: 'Already in cart' }); + continue; + } + + // If this is the first item, it sets the plant + 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, + }, + }); + + added.push(serializeDetail(detail)); + } catch (err) { + errors.push({ item, error: err instanceof Error ? err.message : 'Unknown error' }); + } + } + + // Touch cart activity + if (added.length > 0) { + await db.ship_request.update({ + where: { id: requestId }, + data: { last_cart_activity: new Date() }, + }); + } + + return { added, skipped, errors }; +}