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 <noreply@anthropic.com>
555 lines
15 KiB
TypeScript
555 lines
15 KiB
TypeScript
/**
|
|
* Ship Request Service
|
|
*
|
|
* Handles CRUD operations for shipment request carts persisted in PostgreSQL.
|
|
* Carts are per-user, per-company. Only one active (non-submitted, non-cancelled) cart
|
|
* is allowed at a time.
|
|
*/
|
|
|
|
import { db } from '@/lib/db';
|
|
import type {
|
|
ShipRequestHeader,
|
|
ShipRequestListItem,
|
|
ShipRequestDetailItem,
|
|
AddShipCartItemPayload,
|
|
UpdateCartHeaderPayload,
|
|
UpdateCartItemPayload,
|
|
} from '@/types/requests';
|
|
|
|
// =============================================================================
|
|
// Serialization helpers
|
|
// =============================================================================
|
|
|
|
function serializeDetail(
|
|
d: {
|
|
id: string;
|
|
part_num: string;
|
|
lot_num: string | null;
|
|
plant: string;
|
|
warehouse: string | null;
|
|
quantity: number;
|
|
notes: string | null;
|
|
created_at: Date;
|
|
}
|
|
): ShipRequestDetailItem {
|
|
return {
|
|
id: d.id,
|
|
part_num: d.part_num,
|
|
lot_num: d.lot_num,
|
|
plant: d.plant,
|
|
warehouse: d.warehouse,
|
|
quantity: d.quantity,
|
|
notes: d.notes,
|
|
created_at: d.created_at.toISOString(),
|
|
};
|
|
}
|
|
|
|
function serializeHeader(
|
|
r: {
|
|
id: string;
|
|
auth_user_id: string;
|
|
quest_company_id: string;
|
|
ship_to_address: string;
|
|
order_number: string | null;
|
|
po_number: string | null;
|
|
release_number: string | null;
|
|
pickup_date: Date | null;
|
|
instructions: string | null;
|
|
email_recipients: string[];
|
|
is_submitted: boolean;
|
|
submitted_at: Date | null;
|
|
is_cancelled: boolean;
|
|
cancelled_at: Date | null;
|
|
cancelled_by: string | null;
|
|
last_cart_activity: Date;
|
|
created_at: Date;
|
|
updated_at: Date;
|
|
details: {
|
|
id: string;
|
|
part_num: string;
|
|
lot_num: string | null;
|
|
plant: string;
|
|
warehouse: string | null;
|
|
quantity: number;
|
|
notes: string | null;
|
|
created_at: Date;
|
|
}[];
|
|
}
|
|
): ShipRequestHeader {
|
|
return {
|
|
id: r.id,
|
|
auth_user_id: r.auth_user_id,
|
|
quest_company_id: r.quest_company_id,
|
|
ship_to_address: r.ship_to_address,
|
|
order_number: r.order_number,
|
|
po_number: r.po_number,
|
|
release_number: r.release_number,
|
|
pickup_date: r.pickup_date?.toISOString() ?? null,
|
|
instructions: r.instructions,
|
|
email_recipients: r.email_recipients,
|
|
is_submitted: r.is_submitted,
|
|
submitted_at: r.submitted_at?.toISOString() ?? null,
|
|
is_cancelled: r.is_cancelled,
|
|
cancelled_at: r.cancelled_at?.toISOString() ?? null,
|
|
cancelled_by: r.cancelled_by,
|
|
last_cart_activity: r.last_cart_activity.toISOString(),
|
|
created_at: r.created_at.toISOString(),
|
|
updated_at: r.updated_at.toISOString(),
|
|
details: r.details.map(serializeDetail),
|
|
};
|
|
}
|
|
|
|
// =============================================================================
|
|
// Cart Operations
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Get or create an active cart (unsubmitted, uncancelled) for this user+company
|
|
*/
|
|
export async function getOrCreateCart(
|
|
userId: string,
|
|
companyId: string
|
|
): Promise<ShipRequestHeader> {
|
|
// Find existing active cart
|
|
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' },
|
|
});
|
|
|
|
if (existing) {
|
|
return serializeHeader(existing);
|
|
}
|
|
|
|
// Create new cart
|
|
const newCart = await db.ship_request.create({
|
|
data: {
|
|
auth_user_id: userId,
|
|
quest_company_id: companyId,
|
|
ship_to_address: '',
|
|
},
|
|
include: { details: true },
|
|
});
|
|
|
|
return serializeHeader(newCart);
|
|
}
|
|
|
|
/**
|
|
* Get a specific request by ID, scoped to a company
|
|
*/
|
|
export async function getRequestById(
|
|
id: string,
|
|
companyId: string
|
|
): Promise<ShipRequestHeader | null> {
|
|
const request = await db.ship_request.findFirst({
|
|
where: { id, quest_company_id: companyId },
|
|
include: { details: { orderBy: { created_at: 'asc' } } },
|
|
});
|
|
|
|
return request ? serializeHeader(request) : null;
|
|
}
|
|
|
|
/**
|
|
* Get all requests for a company (list view)
|
|
*/
|
|
export async function getRequests(
|
|
companyId: string
|
|
): Promise<ShipRequestListItem[]> {
|
|
const requests = await db.ship_request.findMany({
|
|
where: { quest_company_id: companyId },
|
|
include: { _count: { select: { details: true } } },
|
|
orderBy: { created_at: 'desc' },
|
|
});
|
|
|
|
return requests.map((r) => ({
|
|
id: r.id,
|
|
ship_to_address: r.ship_to_address,
|
|
order_number: r.order_number,
|
|
po_number: r.po_number,
|
|
is_submitted: r.is_submitted,
|
|
submitted_at: r.submitted_at?.toISOString() ?? null,
|
|
is_cancelled: r.is_cancelled,
|
|
cancelled_at: r.cancelled_at?.toISOString() ?? null,
|
|
created_at: r.created_at.toISOString(),
|
|
detail_count: r._count.details,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Update cart header fields (ship-to, order#, PO#, etc.)
|
|
*/
|
|
export async function updateCartHeader(
|
|
id: string,
|
|
companyId: string,
|
|
data: UpdateCartHeaderPayload
|
|
): Promise<ShipRequestHeader> {
|
|
// Verify cart belongs to company and is still active
|
|
const cart = await db.ship_request.findFirst({
|
|
where: { id, quest_company_id: companyId, is_submitted: false, is_cancelled: false },
|
|
});
|
|
|
|
if (!cart) {
|
|
throw new Error('Cart not found or already submitted/cancelled');
|
|
}
|
|
|
|
const updated = await db.ship_request.update({
|
|
where: { id },
|
|
data: {
|
|
...(data.ship_to_address !== undefined && { ship_to_address: data.ship_to_address }),
|
|
...(data.order_number !== undefined && { order_number: data.order_number }),
|
|
...(data.po_number !== undefined && { po_number: data.po_number }),
|
|
...(data.release_number !== undefined && { release_number: data.release_number }),
|
|
...(data.pickup_date !== undefined && {
|
|
pickup_date: data.pickup_date ? new Date(data.pickup_date) : null,
|
|
}),
|
|
...(data.instructions !== undefined && { instructions: data.instructions }),
|
|
...(data.email_recipients !== undefined && { email_recipients: data.email_recipients }),
|
|
last_cart_activity: new Date(),
|
|
},
|
|
include: { details: { orderBy: { created_at: 'asc' } } },
|
|
});
|
|
|
|
return serializeHeader(updated);
|
|
}
|
|
|
|
// =============================================================================
|
|
// Cart Item Operations
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Add an item to the cart. Checks for duplicates (same part+lot+plant+warehouse).
|
|
*/
|
|
export async function addCartItem(
|
|
requestId: string,
|
|
companyId: string,
|
|
item: AddShipCartItemPayload
|
|
): Promise<ShipRequestDetailItem> {
|
|
// Verify cart is active
|
|
const cart = await db.ship_request.findFirst({
|
|
where: { id: requestId, quest_company_id: companyId, is_submitted: false, is_cancelled: false },
|
|
});
|
|
|
|
if (!cart) {
|
|
throw new Error('Cart not found or already submitted/cancelled');
|
|
}
|
|
|
|
// Check for duplicates
|
|
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) {
|
|
throw new Error('This item already exists in the cart. Update the quantity instead.');
|
|
}
|
|
|
|
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,
|
|
},
|
|
});
|
|
|
|
// Touch cart activity
|
|
await db.ship_request.update({
|
|
where: { id: requestId },
|
|
data: { last_cart_activity: new Date() },
|
|
});
|
|
|
|
return serializeDetail(detail);
|
|
}
|
|
|
|
/**
|
|
* Remove an item from the cart
|
|
*/
|
|
export async function removeCartItem(
|
|
detailId: string,
|
|
requestId: string,
|
|
companyId: string
|
|
): Promise<void> {
|
|
// Verify cart is active and detail belongs to it
|
|
const cart = await db.ship_request.findFirst({
|
|
where: { id: requestId, quest_company_id: companyId, is_submitted: false, is_cancelled: false },
|
|
});
|
|
|
|
if (!cart) {
|
|
throw new Error('Cart not found or already submitted/cancelled');
|
|
}
|
|
|
|
const detail = await db.ship_request_detail.findFirst({
|
|
where: { id: detailId, ship_request_id: requestId },
|
|
});
|
|
|
|
if (!detail) {
|
|
throw new Error('Item not found in cart');
|
|
}
|
|
|
|
await db.ship_request_detail.delete({ where: { id: detailId } });
|
|
|
|
// Touch cart activity
|
|
await db.ship_request.update({
|
|
where: { id: requestId },
|
|
data: { last_cart_activity: new Date() },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Update a cart item (quantity, notes)
|
|
*/
|
|
export async function updateCartItem(
|
|
detailId: string,
|
|
requestId: string,
|
|
companyId: string,
|
|
data: UpdateCartItemPayload
|
|
): Promise<ShipRequestDetailItem> {
|
|
// Verify cart is active
|
|
const cart = await db.ship_request.findFirst({
|
|
where: { id: requestId, quest_company_id: companyId, is_submitted: false, is_cancelled: false },
|
|
});
|
|
|
|
if (!cart) {
|
|
throw new Error('Cart not found or already submitted/cancelled');
|
|
}
|
|
|
|
const detail = await db.ship_request_detail.findFirst({
|
|
where: { id: detailId, ship_request_id: requestId },
|
|
});
|
|
|
|
if (!detail) {
|
|
throw new Error('Item not found in cart');
|
|
}
|
|
|
|
const updated = await db.ship_request_detail.update({
|
|
where: { id: detailId },
|
|
data: {
|
|
...(data.quantity !== undefined && { quantity: data.quantity }),
|
|
...(data.notes !== undefined && { notes: data.notes }),
|
|
},
|
|
});
|
|
|
|
// Touch cart activity
|
|
await db.ship_request.update({
|
|
where: { id: requestId },
|
|
data: { last_cart_activity: new Date() },
|
|
});
|
|
|
|
return serializeDetail(updated);
|
|
}
|
|
|
|
// =============================================================================
|
|
// Submit & Cancel
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Submit a cart (mark as submitted, lock for editing)
|
|
*/
|
|
export async function submitCart(
|
|
id: string,
|
|
companyId: string,
|
|
userId: string
|
|
): Promise<ShipRequestHeader> {
|
|
const cart = await db.ship_request.findFirst({
|
|
where: { id, quest_company_id: companyId, is_submitted: false, is_cancelled: false },
|
|
include: { details: true },
|
|
});
|
|
|
|
if (!cart) {
|
|
throw new Error('Cart not found or already submitted/cancelled');
|
|
}
|
|
|
|
if (cart.details.length === 0) {
|
|
throw new Error('Cannot submit an empty cart');
|
|
}
|
|
|
|
if (!cart.ship_to_address) {
|
|
throw new Error('Ship-to address is required');
|
|
}
|
|
|
|
const submitted = await db.ship_request.update({
|
|
where: { id },
|
|
data: {
|
|
is_submitted: true,
|
|
submitted_at: new Date(),
|
|
},
|
|
include: { details: { orderBy: { created_at: 'asc' } } },
|
|
});
|
|
|
|
// Email notification stub (J-007 not built yet)
|
|
console.log('[EMAIL STUB] Shipment request submitted:', {
|
|
requestId: id,
|
|
userId,
|
|
companyId,
|
|
recipients: submitted.email_recipients,
|
|
itemCount: submitted.details.length,
|
|
});
|
|
|
|
return serializeHeader(submitted);
|
|
}
|
|
|
|
/**
|
|
* Cancel a submitted request
|
|
*/
|
|
export async function cancelRequest(
|
|
id: string,
|
|
companyId: string,
|
|
userId: string
|
|
): Promise<ShipRequestHeader> {
|
|
const request = await db.ship_request.findFirst({
|
|
where: { id, quest_company_id: companyId, is_cancelled: false },
|
|
});
|
|
|
|
if (!request) {
|
|
throw new Error('Request not found');
|
|
}
|
|
|
|
const cancelled = await db.ship_request.update({
|
|
where: { id },
|
|
data: {
|
|
is_cancelled: true,
|
|
cancelled_at: new Date(),
|
|
cancelled_by: userId,
|
|
},
|
|
include: { details: { orderBy: { created_at: 'asc' } } },
|
|
});
|
|
|
|
// Email notification stub
|
|
console.log('[EMAIL STUB] Shipment request cancelled:', {
|
|
requestId: id,
|
|
userId,
|
|
companyId,
|
|
recipients: cancelled.email_recipients,
|
|
});
|
|
|
|
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<ShipRequestHeader | null> {
|
|
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<BatchAddResult> {
|
|
// 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 };
|
|
}
|