-
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
+
+
+
+
+
+
+ |
+ Part #
+ |
+
+ # Items
+ |
+
+ Total Lbs
+ |
+
+
+
+ {totalsByPart.map((group, i) => (
+
+
+ {group.part_num}
+
+
+ {group.count}
+
+
+ {Math.round(group.totalQty).toLocaleString('en-US')}
+
+
+ ))}
+
+
+
+
+ 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 && (
+
+ )}