diff --git a/docker-compose.yml b/docker-compose.yml index 99fa89e..dc0ea92 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,6 +36,7 @@ services: NODE_ENV: production volumes: - ./public/uploads/supplier-products:/app/public/uploads/supplier-products + - ./public/uploads/gmail-reply-logos:/app/public/uploads/gmail-reply-logos depends_on: db: condition: service_healthy diff --git a/prisma/migrations/20260412180000_cached_order_hold_fields/migration.sql b/prisma/migrations/20260412180000_cached_order_hold_fields/migration.sql new file mode 100644 index 0000000..aea511a --- /dev/null +++ b/prisma/migrations/20260412180000_cached_order_hold_fields/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "CachedOrder" ADD COLUMN IF NOT EXISTS "holdReasonCode" TEXT, +ADD COLUMN IF NOT EXISTS "holdNote" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index af3f2d4..198e7bb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -189,6 +189,9 @@ model CachedOrder { currency String? financialStatus String? fulfillmentStatus String? + /** When fulfillment is ON_HOLD: machine reason (see SUPPLIER_HOLD_REASON_CODES). */ + holdReasonCode String? + holdNote String? @db.Text trackingNumber String? trackingCompany String? trackingUrl String? diff --git a/src/app/api/shopify/orders/[orderId]/route.ts b/src/app/api/shopify/orders/[orderId]/route.ts index 087c4fb..001bd7e 100644 --- a/src/app/api/shopify/orders/[orderId]/route.ts +++ b/src/app/api/shopify/orders/[orderId]/route.ts @@ -3,6 +3,10 @@ import { auth } from "@/auth" import { prisma } from "@/lib/prisma" import { handleApiError } from "@/lib/errors" import { normalizeFulfillmentStatus, toVendorDisplayStatus } from "@/lib/orders/fulfillmentStatus" +import { + SUPPLIER_HOLD_REASON_LABELS, + isSupplierHoldReasonCode, +} from "@/lib/orders/supplierHoldReasons" export async function GET( request: Request, @@ -74,6 +78,8 @@ export async function GET( email: true, customer: true, fulfillmentStatus: true, + holdReasonCode: true, + holdNote: true, trackingNumber: true, trackingCompany: true, trackingUrl: true, @@ -118,6 +124,12 @@ export async function GET( order.last_supplier_update_at = cached.lastSupplierUpdateAt?.toISOString() || null order.last_shopify_sync_at = cached.lastShopifySyncAt?.toISOString() || null order.last_shopify_sync_error = cached.lastShopifySyncError || null + order.hold_reason_code = cached.holdReasonCode || null + order.hold_note = cached.holdNote || null + order.hold_reason_label = + cached.holdReasonCode && isSupplierHoldReasonCode(cached.holdReasonCode) + ? SUPPLIER_HOLD_REASON_LABELS[cached.holdReasonCode] + : null } return NextResponse.json({ order }) diff --git a/src/app/api/supplier/orders/[orderId]/route.ts b/src/app/api/supplier/orders/[orderId]/route.ts index 5ceb334..f3725b2 100644 --- a/src/app/api/supplier/orders/[orderId]/route.ts +++ b/src/app/api/supplier/orders/[orderId]/route.ts @@ -2,7 +2,12 @@ import { NextResponse } from "next/server" import { auth } from "@/auth" import { prisma } from "@/lib/prisma" import { normalizeFulfillmentStatus, toVendorDisplayStatus } from "@/lib/orders/fulfillmentStatus" +import { + SUPPLIER_HOLD_REASON_LABELS, + isSupplierHoldReasonCode, +} from "@/lib/orders/supplierHoldReasons" import { supplierOrderPatchSchema, validationErrorResponse } from "@/lib/api-schemas" +import { runSupplierOrderShopifySync } from "@/lib/shopify/supplierOrderShopifySync" async function getSupplierContext(userId: string) { const profile = await prisma.supplierProfile.findUnique({ @@ -54,242 +59,6 @@ function isSupplierAllowedForOrder( }) } -async function syncOrderToShopify( - storeId: string, - shopifyOrderId: string, - fulfillmentStatus: string, - trackingNumber: string | null, - trackingCompany: string | null, - trackingUrl: string | null, -): Promise<{ ok: boolean; error?: string }> { - const canonical = normalizeFulfillmentStatus(fulfillmentStatus) - if (canonical !== "SHIPPED" && canonical !== "FULFILLED") { - // No fulfillment push needed for non-shipped statuses. - return { ok: true } - } - if (!trackingNumber || !trackingCompany) { - return { ok: false, error: "Tracking platform and tracking number are required for shipped/fulfilled sync." } - } - - const store = await prisma.store.findUnique({ - where: { id: storeId }, - select: { shopifyDomain: true, shopifyAccessToken: true }, - }) - if (!store?.shopifyDomain || !store?.shopifyAccessToken) { - return { ok: false, error: "Shopify store not connected." } - } - const cleanDomain = store.shopifyDomain.trim().replace(/^https?:\/\//, "").replace(/\/$/, "") - - const fetchScopes = async (): Promise => { - try { - const res = await fetch(`https://${cleanDomain}/admin/oauth/access_scopes.json`, { - method: "GET", - headers: { - "X-Shopify-Access-Token": store.shopifyAccessToken, - "Content-Type": "application/json", - }, - }) - if (!res.ok) return [] - const payload = (await res.json()) as { access_scopes?: Array<{ handle?: string }> } - return (payload.access_scopes || []).map((s) => s.handle || "").filter(Boolean) - } catch { - return [] - } - } - - const updateTrackingOnExistingFulfillment = async (): Promise<{ ok: boolean; error?: string }> => { - const listRes = await fetch( - `https://${cleanDomain}/admin/api/2024-01/orders/${shopifyOrderId}/fulfillments.json`, - { - method: "GET", - headers: { - "X-Shopify-Access-Token": store.shopifyAccessToken, - "Content-Type": "application/json", - }, - }, - ) - if (!listRes.ok) { - const details = await listRes.text().catch(() => "Unknown Shopify response") - return { ok: false, error: `Read existing fulfillments failed: ${details}` } - } - - const listPayload = (await listRes.json()) as { - fulfillments?: Array<{ id: number; created_at?: string }> - } - const fulfillments = listPayload.fulfillments || [] - if (fulfillments.length === 0) { - return { ok: false, error: "No existing fulfillment found to update tracking." } - } - - const sorted = [...fulfillments].sort((a, b) => { - const aTs = a.created_at ? new Date(a.created_at).getTime() : 0 - const bTs = b.created_at ? new Date(b.created_at).getTime() : 0 - return bTs - aTs - }) - const latest = sorted[0] - - const updateRes = await fetch( - `https://${cleanDomain}/admin/api/2024-01/fulfillments/${latest.id}/update_tracking.json`, - { - method: "POST", - headers: { - "X-Shopify-Access-Token": store.shopifyAccessToken, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - fulfillment: { - notify_customer: false, - tracking_info: { - number: trackingNumber, - company: trackingCompany, - url: trackingUrl || undefined, - }, - }, - }), - }, - ) - if (!updateRes.ok) { - const details = await updateRes.text().catch(() => "Unknown Shopify response") - return { ok: false, error: `Update tracking on existing fulfillment failed: ${details}` } - } - return { ok: true } - } - - const createLegacyFulfillmentFallback = async (): Promise<{ ok: boolean; error?: string }> => { - const orderRes = await fetch( - `https://${cleanDomain}/admin/api/2024-01/orders/${shopifyOrderId}.json?status=any`, - { - method: "GET", - headers: { - "X-Shopify-Access-Token": store.shopifyAccessToken, - "Content-Type": "application/json", - }, - }, - ) - if (!orderRes.ok) { - const details = await orderRes.text().catch(() => "Unknown Shopify response") - return { ok: false, error: `Fallback fetch order failed: ${details}` } - } - - const orderPayload = (await orderRes.json()) as { - order?: { - line_items?: Array<{ id: number; fulfillable_quantity?: number; quantity?: number }> - } - } - const lineItems = (orderPayload.order?.line_items || []) - .filter((li) => (li.fulfillable_quantity ?? li.quantity ?? 0) > 0) - .map((li) => ({ - id: li.id, - quantity: li.fulfillable_quantity ?? li.quantity ?? 1, - })) - - const legacyPayload = { - fulfillment: { - notify_customer: false, - tracking_number: trackingNumber, - tracking_company: trackingCompany, - tracking_urls: trackingUrl ? [trackingUrl] : undefined, - line_items: lineItems.length > 0 ? lineItems : undefined, - }, - } - - const legacyRes = await fetch( - `https://${cleanDomain}/admin/api/2024-01/orders/${shopifyOrderId}/fulfillments.json`, - { - method: "POST", - headers: { - "X-Shopify-Access-Token": store.shopifyAccessToken, - "Content-Type": "application/json", - }, - body: JSON.stringify(legacyPayload), - }, - ) - if (!legacyRes.ok) { - const details = await legacyRes.text().catch(() => "Unknown Shopify response") - return { ok: false, error: `Legacy fulfillment failed: ${details}` } - } - return { ok: true } - } - - const foRes = await fetch( - `https://${cleanDomain}/admin/api/2024-01/orders/${shopifyOrderId}/fulfillment_orders.json`, - { - method: "GET", - headers: { - "X-Shopify-Access-Token": store.shopifyAccessToken, - "Content-Type": "application/json", - }, - }, - ) - if (!foRes.ok) { - const details = await foRes.text().catch(() => "Unknown Shopify response") - const fallback = await createLegacyFulfillmentFallback() - if (fallback.ok) return fallback - const scopes = await fetchScopes() - return { - ok: false, - error: `Failed to read fulfillment orders: ${details}. Fallback also failed: ${fallback.error}. Token scopes: [${scopes.join(", ")}]. Re-install/reconnect app after scope changes.`, - } - } - - const foJson = (await foRes.json()) as { - fulfillment_orders?: Array<{ id: number; status?: string; request_status?: string }> - } - const allFulfillmentOrders = foJson.fulfillment_orders || [] - const fulfillmentOrders = allFulfillmentOrders.filter((fo) => { - const status = String(fo.status || "").toLowerCase() - // Only try fulfillment orders that are still actionable. - return status && status !== "closed" && status !== "cancelled" && status !== "incomplete" - }) - if (fulfillmentOrders.length === 0) { - const closedCount = allFulfillmentOrders.filter((fo) => String(fo.status || "").toLowerCase() === "closed").length - if (closedCount > 0) { - // Closed fulfillment orders are usually already fulfilled: update tracking on existing fulfillment. - const updated = await updateTrackingOnExistingFulfillment() - if (updated.ok) return updated - return { ok: false, error: `All fulfillment orders are closed. ${updated.error}` } - } - return { ok: false, error: "No actionable fulfillment orders available for this order." } - } - - const payload = { - fulfillment: { - notify_customer: false, - tracking_info: { - number: trackingNumber, - company: trackingCompany, - url: trackingUrl || undefined, - }, - line_items_by_fulfillment_order: fulfillmentOrders.map((f) => ({ - fulfillment_order_id: f.id, - })), - }, - } - - const syncRes = await fetch(`https://${cleanDomain}/admin/api/2024-01/fulfillments.json`, { - method: "POST", - headers: { - "X-Shopify-Access-Token": store.shopifyAccessToken, - "Content-Type": "application/json", - }, - body: JSON.stringify(payload), - }) - if (!syncRes.ok) { - const details = await syncRes.text().catch(() => "Unknown Shopify response") - const updated = await updateTrackingOnExistingFulfillment() - if (updated.ok) return updated - const fallback = await createLegacyFulfillmentFallback() - if (fallback.ok) return fallback - const scopes = await fetchScopes() - return { - ok: false, - error: `Shopify fulfillment sync failed: ${details}. Existing fulfillment tracking update failed: ${updated.error}. Fallback also failed: ${fallback.error}. Token scopes: [${scopes.join(", ")}]. Re-install/reconnect app after scope changes.`, - } - } - - return { ok: true } -} - export async function GET( _req: Request, { params }: { params: Promise<{ orderId: string }> }, @@ -372,9 +141,15 @@ export async function GET( financialStatus: order.financialStatus, fulfillmentStatus: normalizeFulfillmentStatus(order.fulfillmentStatus), fulfillmentStatusDisplay: toVendorDisplayStatus(order.fulfillmentStatus), - trackingNumber: (order as unknown as { trackingNumber?: string | null }).trackingNumber, - trackingCompany: (order as unknown as { trackingCompany?: string | null }).trackingCompany, - trackingUrl: (order as unknown as { trackingUrl?: string | null }).trackingUrl, + trackingNumber: order.trackingNumber, + trackingCompany: order.trackingCompany, + trackingUrl: order.trackingUrl, + holdReasonCode: order.holdReasonCode, + holdNote: order.holdNote, + holdReasonLabel: + order.holdReasonCode && isSupplierHoldReasonCode(order.holdReasonCode) + ? SUPPLIER_HOLD_REASON_LABELS[order.holdReasonCode] + : null, createdAt: order.shopifyCreatedAt || order.createdAt, lineItems, customer, @@ -404,7 +179,14 @@ export async function PATCH( if (!parsed.success) { return validationErrorResponse(parsed.error) } - const { fulfillmentStatus: fs, trackingNumber, trackingCompany, trackingUrl } = parsed.data + const { + fulfillmentStatus: fs, + trackingNumber: trIn, + trackingCompany: tcIn, + trackingUrl: tuIn, + holdReasonCode: hrIn, + holdNote: hnIn, + } = parsed.data const fulfillmentStatus = normalizeFulfillmentStatus( fs === undefined || fs === null ? undefined : String(fs), @@ -417,17 +199,41 @@ export async function PATCH( const order = await prisma.cachedOrder.findFirst({ where: { shopifyOrderId: orderId }, - select: { id: true, storeId: true, lineItems: true }, + select: { + id: true, + storeId: true, + lineItems: true, + fulfillmentStatus: true, + }, }) if (!order) return NextResponse.json({ message: "Order not found" }, { status: 404 }) if (!isSupplierAllowedForOrder(ctx.syncsByStore, order.storeId, order.lineItems)) { return NextResponse.json({ message: "Order not accessible" }, { status: 403 }) } + const previousCanonical = normalizeFulfillmentStatus(order.fulfillmentStatus) + + let trackingNumber = trIn + let trackingCompany = tcIn + let trackingUrl = tuIn + let holdReasonCode: string | null = hrIn ?? null + let holdNote: string | null = hnIn ?? null + + if (fulfillmentStatus === "ON_HOLD") { + trackingNumber = null + trackingCompany = null + trackingUrl = null + } else { + holdReasonCode = null + holdNote = null + } + const updated = await prisma.cachedOrder.update({ where: { id: order.id }, data: { fulfillmentStatus, + holdReasonCode, + holdNote, trackingNumber, trackingCompany, trackingUrl, @@ -435,21 +241,29 @@ export async function PATCH( lastSupplierUpdateAt: new Date(), lastSupplierUpdatedBy: session.user.id, lastShopifySyncError: null, - } as unknown as Record, + }, select: { shopifyOrderId: true, fulfillmentStatus: true, + holdReasonCode: true, + holdNote: true, }, }) - const shopifySync = await syncOrderToShopify( - order.storeId, - orderId, - fulfillmentStatus, + const shopifySync = await runSupplierOrderShopifySync({ + storeId: order.storeId, + shopifyOrderId: orderId, + previousCanonical, + newCanonical: fulfillmentStatus, trackingNumber, trackingCompany, trackingUrl, - ) + holdCode: + fulfillmentStatus === "ON_HOLD" && holdReasonCode && isSupplierHoldReasonCode(holdReasonCode) + ? holdReasonCode + : null, + holdNote, + }) if (shopifySync.ok) { await prisma.cachedOrder.update({ @@ -458,7 +272,7 @@ export async function PATCH( pendingVendorSync: false, lastShopifySyncAt: new Date(), lastShopifySyncError: null, - } as unknown as Record, + }, }) } else { await prisma.cachedOrder.update({ @@ -466,7 +280,7 @@ export async function PATCH( data: { pendingVendorSync: true, lastShopifySyncError: shopifySync.error || "Unknown sync error", - } as unknown as Record, + }, }) } @@ -476,6 +290,10 @@ export async function PATCH( trackingNumber, trackingCompany, trackingUrl, + holdReasonLabel: + holdReasonCode && isSupplierHoldReasonCode(holdReasonCode) + ? SUPPLIER_HOLD_REASON_LABELS[holdReasonCode] + : null, pendingVendorSync: !shopifySync.ok, lastShopifySyncError: shopifySync.ok ? null : shopifySync.error || "Unknown sync error", fulfillmentStatusDisplay: toVendorDisplayStatus(updated.fulfillmentStatus), diff --git a/src/app/api/supplier/orders/route.ts b/src/app/api/supplier/orders/route.ts index 85d5cf8..d22f5c7 100644 --- a/src/app/api/supplier/orders/route.ts +++ b/src/app/api/supplier/orders/route.ts @@ -2,6 +2,10 @@ import { NextResponse } from "next/server" import { auth } from "@/auth" import { prisma } from "@/lib/prisma" import { normalizeFulfillmentStatus, toVendorDisplayStatus } from "@/lib/orders/fulfillmentStatus" +import { + SUPPLIER_HOLD_REASON_LABELS, + isSupplierHoldReasonCode, +} from "@/lib/orders/supplierHoldReasons" /** * GET /api/supplier/orders @@ -158,10 +162,16 @@ export async function GET() { fulfillmentStatus: normalizeFulfillmentStatus(order.fulfillmentStatus), status: normalizeFulfillmentStatus(order.fulfillmentStatus), fulfillmentStatusDisplay: toVendorDisplayStatus(order.fulfillmentStatus), - trackingNumber: (order as unknown as { trackingNumber?: string | null }).trackingNumber, - trackingCompany: (order as unknown as { trackingCompany?: string | null }).trackingCompany, - trackingUrl: (order as unknown as { trackingUrl?: string | null }).trackingUrl, - pendingVendorSync: (order as unknown as { pendingVendorSync?: boolean }).pendingVendorSync ?? false, + trackingNumber: order.trackingNumber, + trackingCompany: order.trackingCompany, + trackingUrl: order.trackingUrl, + pendingVendorSync: order.pendingVendorSync, + holdReasonCode: order.holdReasonCode, + holdNote: order.holdNote, + holdReasonLabel: + order.holdReasonCode && isSupplierHoldReasonCode(order.holdReasonCode) + ? SUPPLIER_HOLD_REASON_LABELS[order.holdReasonCode] + : null, vendorName: order.store.businessName || order.store.shopifyStoreName, createdAt: order.shopifyCreatedAt || order.createdAt, store: order.store, diff --git a/src/app/dashboard/orders/[orderId]/page.tsx b/src/app/dashboard/orders/[orderId]/page.tsx index 71e63ef..286198c 100644 --- a/src/app/dashboard/orders/[orderId]/page.tsx +++ b/src/app/dashboard/orders/[orderId]/page.tsx @@ -63,6 +63,9 @@ interface OrderDetails { tracking_url?: string | null pending_vendor_sync?: boolean last_shopify_sync_error?: string | null + hold_reason_code?: string | null + hold_note?: string | null + hold_reason_label?: string | null } export default function OrderDetailsPage() { @@ -224,7 +227,9 @@ export default function OrderDetailsPage() { className={`px-3 py-1.5 rounded-full text-xs font-black uppercase tracking-wider inline-flex items-center gap-1.5 whitespace-nowrap ${ order.fulfillment_status === "fulfilled" ? "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30" - : "bg-blue-500/20 text-blue-400 border border-blue-500/30" + : order.fulfillment_status === "on_hold" + ? "bg-amber-500/20 text-amber-300 border border-amber-500/35" + : "bg-blue-500/20 text-blue-400 border border-blue-500/30" }`} > {order.fulfillment_status === "fulfilled" ? ( @@ -232,7 +237,9 @@ export default function OrderDetailsPage() { ) : ( )} - {order.fulfillment_status || "Unfulfilled"} + {order.fulfillment_status === "on_hold" + ? "On hold" + : order.fulfillment_status || "Unfulfilled"} @@ -266,6 +273,22 @@ export default function OrderDetailsPage() { {order.last_shopify_sync_error && (

{order.last_shopify_sync_error}

)} + {(order.fulfillment_status === "on_hold" || order.hold_reason_label) && ( +
+

+ Supplier hold +

+ {order.hold_reason_label ? ( +

{order.hold_reason_label}

+ ) : null} + {order.hold_note?.trim() ? ( +

{order.hold_note}

+ ) : null} + {!order.hold_reason_label && !order.hold_note?.trim() ? ( +

Reason details not available.

+ ) : null} +
+ )}
diff --git a/src/app/dashboard/settings/account/page.tsx b/src/app/dashboard/settings/account/page.tsx index a3f8292..566213a 100644 --- a/src/app/dashboard/settings/account/page.tsx +++ b/src/app/dashboard/settings/account/page.tsx @@ -354,7 +354,16 @@ export default function AccountSettingsPage() { Reinstalled your app in Shopify or added API permissions? Shopify issues a new Admin API access token. Paste it here—your store domain stays the same; we verify the token with - Shopify before saving. + Shopify before saving. Supplier fulfillment sync needs fulfillment scopes; + write_orders + is required for order tags and the hold metafield. Native{" "} + On hold in Shopify admin also + needs{" "} + + write_merchant_managed_fulfillment_orders + {" "} + (or write_third_party_fulfillment_orders{" "} + if applicable).

{tokenSuccess ? (
diff --git a/src/app/supplier/orders/[orderId]/page.tsx b/src/app/supplier/orders/[orderId]/page.tsx index 9f0c76e..e86048b 100644 --- a/src/app/supplier/orders/[orderId]/page.tsx +++ b/src/app/supplier/orders/[orderId]/page.tsx @@ -2,8 +2,12 @@ import { useEffect, useMemo, useState } from "react" import { useParams, useRouter } from "next/navigation" -import { ArrowLeft, Loader2, AlertCircle, Package, Truck, Save, CheckCircle2, User, Mail, Phone, MapPin } from "lucide-react" +import { ArrowLeft, Loader2, AlertCircle, Package, Truck, Save, CheckCircle2, User, Mail, Phone, MapPin, X } from "lucide-react" import toast from "react-hot-toast" +import { + SUPPLIER_HOLD_REASON_OPTIONS, + type SupplierHoldReasonCode, +} from "@/lib/orders/supplierHoldReasons" const SHOPIFY_TRACKING_COMPANIES = [ "4PX", @@ -50,6 +54,9 @@ type SupplierOrder = { shippingAddress?: { first_name?: string; last_name?: string; address1?: string; address2?: string; city?: string; province?: string; country?: string; zip?: string; phone?: string } | null billingAddress?: { first_name?: string; last_name?: string; address1?: string; address2?: string; city?: string; province?: string; country?: string; zip?: string; phone?: string } | null store: { id: string; businessName: string; shopifyStoreName: string } + holdReasonCode?: string | null + holdNote?: string | null + holdReasonLabel?: string | null } export default function SupplierOrderDetailsPage() { @@ -66,6 +73,12 @@ export default function SupplierOrderDetailsPage() { const [trackingNumber, setTrackingNumber] = useState("") const [trackingCompany, setTrackingCompany] = useState("") const [trackingUrl, setTrackingUrl] = useState("") + const [holdReasonCode, setHoldReasonCode] = useState("") + const [holdNote, setHoldNote] = useState("") + const [holdModalOpen, setHoldModalOpen] = useState(false) + const [statusBeforeHoldModal, setStatusBeforeHoldModal] = useState(null) + const [modalReason, setModalReason] = useState("") + const [modalNote, setModalNote] = useState("") useEffect(() => { if (!orderId) return @@ -82,6 +95,17 @@ export default function SupplierOrderDetailsPage() { setTrackingNumber(o.trackingNumber || "") setTrackingCompany(o.trackingCompany || "") setTrackingUrl(o.trackingUrl || "") + const hc = o.holdReasonCode + setHoldReasonCode( + hc === "inventory_out_of_stock" || + hc === "address_incorrect" || + hc === "high_risk_fraud" || + hc === "awaiting_payment" || + hc === "other" + ? hc + : "", + ) + setHoldNote(o.holdNote || "") } catch { setError("Failed to load order details.") } finally { @@ -93,16 +117,73 @@ export default function SupplierOrderDetailsPage() { const changed = useMemo(() => { if (!order) return false + const origHold = + order.holdReasonCode === "inventory_out_of_stock" || + order.holdReasonCode === "address_incorrect" || + order.holdReasonCode === "high_risk_fraud" || + order.holdReasonCode === "awaiting_payment" || + order.holdReasonCode === "other" + ? order.holdReasonCode + : "" return ( fulfillmentStatus !== (order.fulfillmentStatus || "UNFULFILLED") || trackingNumber !== (order.trackingNumber || "") || trackingCompany !== (order.trackingCompany || "") || - trackingUrl !== (order.trackingUrl || "") + trackingUrl !== (order.trackingUrl || "") || + holdReasonCode !== origHold || + holdNote !== (order.holdNote || "") ) - }, [order, fulfillmentStatus, trackingNumber, trackingCompany, trackingUrl]) + }, [order, fulfillmentStatus, trackingNumber, trackingCompany, trackingUrl, holdReasonCode, holdNote]) + + const trackingLocked = fulfillmentStatus === "ON_HOLD" + const holdIncomplete = fulfillmentStatus === "ON_HOLD" && !holdReasonCode + const canSave = changed && !holdIncomplete && !holdModalOpen + + const openHoldModal = (revertTo: string) => { + setStatusBeforeHoldModal(revertTo) + setModalReason(holdReasonCode || "") + setModalNote(holdReasonCode === "other" ? holdNote : "") + setHoldModalOpen(true) + } + + const confirmHoldModal = () => { + if (!modalReason) { + toast.error("Select a hold reason") + return + } + if (modalReason === "other" && !modalNote.trim()) { + toast.error("Enter a note for Other") + return + } + setHoldReasonCode(modalReason) + setHoldNote(modalReason === "other" ? modalNote.trim() : "") + setFulfillmentStatus("ON_HOLD") + setHoldModalOpen(false) + setStatusBeforeHoldModal(null) + } + + const cancelHoldModal = () => { + setHoldModalOpen(false) + if (statusBeforeHoldModal !== null) { + setFulfillmentStatus(statusBeforeHoldModal) + setStatusBeforeHoldModal(null) + } + } + + const onStatusChange = (value: string) => { + if (value === "ON_HOLD") { + const prev = fulfillmentStatus + setFulfillmentStatus("ON_HOLD") + openHoldModal(prev) + return + } + setFulfillmentStatus(value) + setHoldReasonCode("") + setHoldNote("") + } const save = async () => { - if (!order) return + if (!order || holdIncomplete) return setSaving(true) try { const res = await fetch(`/api/supplier/orders/${order.id}`, { @@ -110,22 +191,42 @@ export default function SupplierOrderDetailsPage() { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fulfillmentStatus, - trackingNumber, - trackingCompany, - trackingUrl, + trackingNumber: trackingLocked ? null : trackingNumber || null, + trackingCompany: trackingLocked ? null : trackingCompany || null, + trackingUrl: trackingLocked ? null : trackingUrl || null, + holdReasonCode: fulfillmentStatus === "ON_HOLD" ? holdReasonCode : null, + holdNote: + fulfillmentStatus === "ON_HOLD" && holdReasonCode === "other" + ? holdNote.trim() + : fulfillmentStatus === "ON_HOLD" + ? null + : null, }), }) - if (!res.ok) throw new Error("Failed to save") + const data = (await res.json().catch(() => ({}))) as { + message?: string + order?: SupplierOrder & { fulfillmentStatusDisplay?: string } + } + if (!res.ok) throw new Error(data.message || "Failed to save") toast.success("Order updated successfully") + const next = data.order setOrder({ ...order, - fulfillmentStatus, - trackingNumber, - trackingCompany, - trackingUrl, + fulfillmentStatus: next?.fulfillmentStatus ?? fulfillmentStatus, + trackingNumber: trackingLocked ? null : trackingNumber, + trackingCompany: trackingLocked ? null : trackingCompany, + trackingUrl: trackingLocked ? null : trackingUrl, + holdReasonCode: fulfillmentStatus === "ON_HOLD" ? holdReasonCode : null, + holdNote: + fulfillmentStatus === "ON_HOLD" && holdReasonCode === "other" + ? holdNote.trim() + : fulfillmentStatus === "ON_HOLD" + ? null + : null, + holdReasonLabel: next?.holdReasonLabel ?? order.holdReasonLabel, }) - } catch { - toast.error("Failed to update order") + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to update order") } finally { setSaving(false) } @@ -163,7 +264,7 @@ export default function SupplierOrderDetailsPage() {
+ {fulfillmentStatus === "ON_HOLD" && holdReasonCode ? ( +
+

Hold reason

+

+ {SUPPLIER_HOLD_REASON_OPTIONS.find((o) => o.value === holdReasonCode)?.label || + holdReasonCode} +

+ {holdReasonCode === "other" && holdNote.trim() ? ( +

{holdNote}

+ ) : null} + +
+ ) : null} +
setModalReason(opt.value)} + /> + {opt.label} + + ))} +
+ {modalReason === "other" ? ( +
+ +