From 17567b9f0a8ab8cb15178356c72e8c047d9ac03a Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Wed, 7 Aug 2024 13:40:31 +0200 Subject: [PATCH] feat(dashboard): claims first implementation (#8468) * wip: setup UI * wip: rendering modal, adding claim items, create checks * fix: make form work after merge * fix: continuation of claim edit --------- Co-authored-by: fPolic --- .../dashboard/src/hooks/api/claims.tsx | 13 +- .../dashboard/src/i18n/translations/en.json | 6 + .../providers/router-provider/route-map.tsx | 5 + .../order-create-claim/claim-create.tsx | 91 +++ .../add-claim-items-table.tsx | 275 +++++++ .../components/add-claim-items-table/index.ts | 1 + .../use-claim-item-table-columns.tsx | 98 +++ .../use-claim-item-table-filters.tsx | 32 + .../use-claim-item-table-query.tsx | 61 ++ .../claim-create-form/claim-create-form.tsx | 715 ++++++++++++++++++ .../claim-create-form/claim-inbound-item.tsx | 247 ++++++ .../components/claim-create-form/index.ts | 1 + .../components/claim-create-form/schema.ts | 23 + .../routes/orders/order-create-claim/index.ts | 1 + .../order-summary-section.tsx | 9 +- 15 files changed, 1574 insertions(+), 4 deletions(-) create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-create-claim/claim-create.tsx create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/add-claim-items-table/add-claim-items-table.tsx create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/add-claim-items-table/index.ts create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/add-claim-items-table/use-claim-item-table-columns.tsx create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/add-claim-items-table/use-claim-item-table-filters.tsx create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/add-claim-items-table/use-claim-item-table-query.tsx create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/claim-create-form/claim-create-form.tsx create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/claim-create-form/claim-inbound-item.tsx create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/claim-create-form/index.ts create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/claim-create-form/schema.ts create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-create-claim/index.ts diff --git a/packages/admin-next/dashboard/src/hooks/api/claims.tsx b/packages/admin-next/dashboard/src/hooks/api/claims.tsx index 6cd03a4568..279979c9c5 100644 --- a/packages/admin-next/dashboard/src/hooks/api/claims.tsx +++ b/packages/admin-next/dashboard/src/hooks/api/claims.tsx @@ -56,7 +56,10 @@ export const useClaims = ( export const useCreateClaim = ( orderId: string, options?: UseMutationOptions< - HttpTypes.AdminClaimResponse, + { + claim: HttpTypes.AdminClaimResponse + order: HttpTypes.AdminOrderResponse + }, Error, HttpTypes.AdminCreateClaim > @@ -75,6 +78,11 @@ export const useCreateClaim = ( queryClient.invalidateQueries({ queryKey: ordersQueryKeys.preview(orderId), }) + + queryClient.invalidateQueries({ + queryKey: claimsQueryKeys.lists(), + }) + options?.onSuccess?.(data, variables, context) }, ...options, @@ -103,6 +111,7 @@ export const useCancelClaim = ( queryClient.invalidateQueries({ queryKey: claimsQueryKeys.details(), }) + queryClient.invalidateQueries({ queryKey: claimsQueryKeys.lists(), }) @@ -231,7 +240,7 @@ export const useAddClaimInboundItems = ( }) } -export const useUpdateClaimInboundItems = ( +export const useUpdateClaimInboundItem = ( id: string, orderId: string, options?: UseMutationOptions< diff --git a/packages/admin-next/dashboard/src/i18n/translations/en.json b/packages/admin-next/dashboard/src/i18n/translations/en.json index c5a71d05c0..210f7a8e3f 100644 --- a/packages/admin-next/dashboard/src/i18n/translations/en.json +++ b/packages/admin-next/dashboard/src/i18n/translations/en.json @@ -882,6 +882,12 @@ } } }, + "claims": { + "create": "Create Claim", + "outbound": "Outbound", + "refundAmount": "Estimated difference", + "activeChangeError": "There is an active order change on this order. Please finish or discard the previous change." + }, "reservations": { "allocatedLabel": "Allocated", "notAllocatedLabel": "Not allocated" diff --git a/packages/admin-next/dashboard/src/providers/router-provider/route-map.tsx b/packages/admin-next/dashboard/src/providers/router-provider/route-map.tsx index bc368605ad..5313daf3d6 100644 --- a/packages/admin-next/dashboard/src/providers/router-provider/route-map.tsx +++ b/packages/admin-next/dashboard/src/providers/router-provider/route-map.tsx @@ -243,6 +243,11 @@ export const RouteMap: RouteObject[] = [ lazy: () => import("../../routes/orders/order-create-return"), }, + { + path: "claims", + lazy: () => + import("../../routes/orders/order-create-claim"), + }, { path: "payments/:paymentId/refund", lazy: () => diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-claim/claim-create.tsx b/packages/admin-next/dashboard/src/routes/orders/order-create-claim/claim-create.tsx new file mode 100644 index 0000000000..e3f80544d5 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-claim/claim-create.tsx @@ -0,0 +1,91 @@ +import { useEffect, useMemo, useState } from "react" +import { useTranslation } from "react-i18next" +import { useNavigate, useParams } from "react-router-dom" + +import { toast } from "@medusajs/ui" + +import { RouteFocusModal } from "../../../components/modals" +import { ClaimCreateForm } from "./components/claim-create-form" + +import { useOrder, useOrderPreview } from "../../../hooks/api/orders" +import { useClaims, useCreateClaim } from "../../../hooks/api/claims" +import { DEFAULT_FIELDS } from "../order-detail/constants" + +let IS_REQUEST_RUNNING = false + +export const ClaimCreate = () => { + const { id } = useParams() + const navigate = useNavigate() + const { t } = useTranslation() + + const { order } = useOrder(id!, { + fields: DEFAULT_FIELDS, + }) + + const { order: preview } = useOrderPreview(id!) + + const [activeClaimId, setActiveClaimId] = useState() + + const { mutateAsync: createClaim } = useCreateClaim(order.id) + + // TODO: GET /claims/:id is not implemented + // const { claim } = useClaim(activeClaimId, undefined, { + // enabled: !!activeClaimId, + // }) + + // TEMP HACK: until the endpoint above is implemented + const { claims } = useClaims(undefined, { + enabled: !!activeClaimId, + limit: 999, + }) + + const claim = useMemo(() => { + if (claims) { + return claims.find((c) => c.id === activeClaimId) + } + }, [claims, activeClaimId]) + + useEffect(() => { + async function run() { + if (IS_REQUEST_RUNNING || !preview) { + return + } + + if (preview.order_change) { + if (preview.order_change.change_type === "claim") { + setActiveClaimId(preview.order_change.claim_id) + } else { + navigate(`/orders/${preview.id}`, { replace: true }) + toast.error(t("orders.claims.activeChangeError")) + } + + return + } + + IS_REQUEST_RUNNING = true + + try { + const { claim } = await createClaim({ + order_id: preview.id, + type: "replace", + }) + setActiveClaimId(claim.id) + } catch (e) { + navigate(`/orders/${preview.id}`, { replace: true }) + toast.error(e.message) + } finally { + IS_REQUEST_RUNNING = false + } + } + + run() + }, [preview]) + + return ( + + {claim && preview && order && ( + + )} + + ) +} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/add-claim-items-table/add-claim-items-table.tsx b/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/add-claim-items-table/add-claim-items-table.tsx new file mode 100644 index 0000000000..b3b93c03c7 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/add-claim-items-table/add-claim-items-table.tsx @@ -0,0 +1,275 @@ +import { OnChangeFn, RowSelectionState } from "@tanstack/react-table" +import { useMemo, useState } from "react" + +import { + DateComparisonOperator, + NumericalComparisonOperator, +} from "@medusajs/types" +import { AdminOrderLineItem } from "@medusajs/types" + +import { useClaimItemTableColumns } from "./use-claim-item-table-columns" +import { useClaimItemTableFilters } from "./use-claim-item-table-filters" +import { useClaimItemTableQuery } from "./use-claim-item-table-query" +import { useDataTable } from "../../../../../hooks/use-data-table" +import { DataTable } from "../../../../../components/table/data-table" +import { getStylizedAmount } from "../../../../../lib/money-amount-helpers" +import { getReturnableQuantity } from "../../../../../lib/rma" + +const PAGE_SIZE = 50 +const PREFIX = "rit" + +type AddReturnItemsTableProps = { + onSelectionChange: (ids: string[]) => void + selectedItems: string[] + items: AdminOrderLineItem[] + currencyCode: string +} + +export const AddClaimItemsTable = ({ + onSelectionChange, + selectedItems, + items, + currencyCode, +}: AddReturnItemsTableProps) => { + const [rowSelection, setRowSelection] = useState( + selectedItems.reduce((acc, id) => { + acc[id] = true + return acc + }, {} as RowSelectionState) + ) + + const updater: OnChangeFn = (fn) => { + const newState: RowSelectionState = + typeof fn === "function" ? fn(rowSelection) : fn + + setRowSelection(newState) + onSelectionChange(Object.keys(newState)) + } + + const { searchParams, raw } = useClaimItemTableQuery({ + pageSize: PAGE_SIZE, + prefix: PREFIX, + }) + + const queriedItems = useMemo(() => { + const { + order, + offset, + limit, + q, + created_at, + updated_at, + refundable_amount, + returnable_quantity, + } = searchParams + + let results: AdminOrderLineItem[] = items + + if (q) { + results = results.filter((i) => { + return ( + i.variant.product.title.toLowerCase().includes(q.toLowerCase()) || + i.variant.title.toLowerCase().includes(q.toLowerCase()) || + i.variant.sku?.toLowerCase().includes(q.toLowerCase()) + ) + }) + } + + if (order) { + const direction = order[0] === "-" ? "desc" : "asc" + const field = order.replace("-", "") + + results = sortItems(results, field, direction) + } + + if (created_at) { + results = filterByDate(results, created_at, "created_at") + } + + if (updated_at) { + results = filterByDate(results, updated_at, "updated_at") + } + + if (returnable_quantity) { + results = filterByNumber( + results, + returnable_quantity, + "returnable_quantity", + currencyCode + ) + } + + if (refundable_amount) { + results = filterByNumber( + results, + refundable_amount, + "refundable_amount", + currencyCode + ) + } + + return results.slice(offset, offset + limit) + }, [items, currencyCode, searchParams]) + + const columns = useClaimItemTableColumns(currencyCode) + const filters = useClaimItemTableFilters() + + const { table } = useDataTable({ + data: queriedItems as AdminOrderLineItem[], + columns: columns, + count: queriedItems.length, + enablePagination: true, + getRowId: (row) => row.id, + pageSize: PAGE_SIZE, + enableRowSelection: (row) => { + return getReturnableQuantity(row.original) > 0 + }, + rowSelection: { + state: rowSelection, + updater, + }, + }) + + return ( +
+ +
+ ) +} + +const sortItems = ( + items: AdminOrderLineItem[], + field: string, + direction: "asc" | "desc" +) => { + return items.sort((a, b) => { + let aValue: any + let bValue: any + + if (field === "product_title") { + aValue = a.variant.product.title + bValue = b.variant.product.title + } else if (field === "variant_title") { + aValue = a.variant.title + bValue = b.variant.title + } else if (field === "sku") { + aValue = a.variant.sku + bValue = b.variant.sku + } else if (field === "returnable_quantity") { + aValue = a.quantity - (a.returned_quantity || 0) + bValue = b.quantity - (b.returned_quantity || 0) + } else if (field === "refundable_amount") { + aValue = a.refundable || 0 + bValue = b.refundable || 0 + } + + if (aValue < bValue) { + return direction === "asc" ? -1 : 1 + } + if (aValue > bValue) { + return direction === "asc" ? 1 : -1 + } + return 0 + }) +} + +const filterByDate = ( + items: AdminOrderLineItem[], + date: DateComparisonOperator, + field: "created_at" | "updated_at" +) => { + const { gt, gte, lt, lte } = date + + return items.filter((i) => { + const itemDate = new Date(i[field]) + let isValid = true + + if (gt) { + isValid = isValid && itemDate > new Date(gt) + } + + if (gte) { + isValid = isValid && itemDate >= new Date(gte) + } + + if (lt) { + isValid = isValid && itemDate < new Date(lt) + } + + if (lte) { + isValid = isValid && itemDate <= new Date(lte) + } + + return isValid + }) +} + +const defaultOperators = { + eq: undefined, + gt: undefined, + gte: undefined, + lt: undefined, + lte: undefined, +} + +const filterByNumber = ( + items: AdminOrderLineItem[], + value: NumericalComparisonOperator | number, + field: "returnable_quantity" | "refundable_amount", + currency_code: string +) => { + const { eq, gt, lt, gte, lte } = + typeof value === "object" + ? { ...defaultOperators, ...value } + : { ...defaultOperators, eq: value } + + return items.filter((i) => { + const returnableQuantity = i.quantity - (i.returned_quantity || 0) + const refundableAmount = getStylizedAmount(i.refundable || 0, currency_code) + + const itemValue = + field === "returnable_quantity" ? returnableQuantity : refundableAmount + + if (eq) { + return itemValue === eq + } + + let isValid = true + + if (gt) { + isValid = isValid && itemValue > gt + } + + if (gte) { + isValid = isValid && itemValue >= gte + } + + if (lt) { + isValid = isValid && itemValue < lt + } + + if (lte) { + isValid = isValid && itemValue <= lte + } + + return isValid + }) +} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/add-claim-items-table/index.ts b/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/add-claim-items-table/index.ts new file mode 100644 index 0000000000..6264783326 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/add-claim-items-table/index.ts @@ -0,0 +1 @@ +export * from "./add-claim-items-table" diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/add-claim-items-table/use-claim-item-table-columns.tsx b/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/add-claim-items-table/use-claim-item-table-columns.tsx new file mode 100644 index 0000000000..6f1ae7271b --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/add-claim-items-table/use-claim-item-table-columns.tsx @@ -0,0 +1,98 @@ +import { useMemo } from "react" +import { Checkbox } from "@medusajs/ui" +import { createColumnHelper } from "@tanstack/react-table" +import { useTranslation } from "react-i18next" + +import { + ProductCell, + ProductHeader, +} from "../../../../../components/table/table-cells/product/product-cell" +import { getStylizedAmount } from "../../../../../lib/money-amount-helpers" +import { getReturnableQuantity } from "../../../../../lib/rma" + +const columnHelper = createColumnHelper() + +export const useClaimItemTableColumns = (currencyCode: string) => { + const { t } = useTranslation() + + return useMemo( + () => [ + columnHelper.display({ + id: "select", + header: ({ table }) => { + return ( + + table.toggleAllPageRowsSelected(!!value) + } + /> + ) + }, + cell: ({ row }) => { + const isSelectable = row.getCanSelect() + + return ( + row.toggleSelected(!!value)} + onClick={(e) => { + e.stopPropagation() + }} + /> + ) + }, + }), + columnHelper.display({ + id: "product", + header: () => , + cell: ({ row }) => ( + + ), + }), + columnHelper.accessor("variant.sku", { + header: t("fields.sku"), + cell: ({ getValue }) => { + return getValue() || "-" + }, + }), + columnHelper.accessor("variant.title", { + header: t("fields.variant"), + }), + columnHelper.accessor("quantity", { + header: () => ( +
+ {t("fields.quantity")} +
+ ), + cell: ({ getValue, row }) => { + return getReturnableQuantity(row.original) + }, + }), + columnHelper.accessor("refundable_total", { + header: () => ( +
+ {t("fields.price")} +
+ ), + cell: ({ getValue }) => { + const amount = getValue() || 0 + + const stylized = getStylizedAmount(amount, currencyCode) + + return ( +
+ {stylized} +
+ ) + }, + }), + ], + [t, currencyCode] + ) +} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/add-claim-items-table/use-claim-item-table-filters.tsx b/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/add-claim-items-table/use-claim-item-table-filters.tsx new file mode 100644 index 0000000000..4957c7782b --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/add-claim-items-table/use-claim-item-table-filters.tsx @@ -0,0 +1,32 @@ +import { useTranslation } from "react-i18next" + +import { Filter } from "../../../../../components/table/data-table" + +export const useClaimItemTableFilters = () => { + const { t } = useTranslation() + + const filters: Filter[] = [ + { + key: "returnable_quantity", + label: t("orders.returns.returnableQuantityLabel"), + type: "number", + }, + { + key: "refundable_amount", + label: t("orders.returns.refundableAmountLabel"), + type: "number", + }, + { + key: "created_at", + label: t("fields.createdAt"), + type: "date", + }, + { + key: "updated_at", + label: t("fields.updatedAt"), + type: "date", + }, + ] + + return filters +} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/add-claim-items-table/use-claim-item-table-query.tsx b/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/add-claim-items-table/use-claim-item-table-query.tsx new file mode 100644 index 0000000000..5cab601bcf --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/add-claim-items-table/use-claim-item-table-query.tsx @@ -0,0 +1,61 @@ +import { + DateComparisonOperator, + NumericalComparisonOperator, +} from "@medusajs/types" +import { useQueryParams } from "../../../../../hooks/use-query-params" + +export type ReturnItemTableQuery = { + q?: string + offset: number + order?: string + created_at?: DateComparisonOperator + updated_at?: DateComparisonOperator + returnable_quantity?: NumericalComparisonOperator | number + refundable_amount?: NumericalComparisonOperator | number +} + +export const useClaimItemTableQuery = ({ + pageSize = 50, + prefix, +}: { + pageSize?: number + prefix?: string +}) => { + const raw = useQueryParams( + [ + "q", + "offset", + "order", + "created_at", + "updated_at", + "returnable_quantity", + "refundable_amount", + ], + prefix + ) + + const { + offset, + created_at, + updated_at, + refundable_amount, + returnable_quantity, + ...rest + } = raw + + const searchParams = { + ...rest, + limit: pageSize, + offset: offset ? Number(offset) : 0, + created_at: created_at ? JSON.parse(created_at) : undefined, + updated_at: updated_at ? JSON.parse(updated_at) : undefined, + refundable_amount: refundable_amount + ? JSON.parse(refundable_amount) + : undefined, + returnable_quantity: returnable_quantity + ? JSON.parse(returnable_quantity) + : undefined, + } + + return { searchParams, raw } +} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/claim-create-form/claim-create-form.tsx b/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/claim-create-form/claim-create-form.tsx new file mode 100644 index 0000000000..c574a71849 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/claim-create-form/claim-create-form.tsx @@ -0,0 +1,715 @@ +import React, { useEffect, useMemo, useState } from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { + Alert, + Button, + CurrencyInput, + Heading, + IconButton, + Switch, + Text, + toast, +} from "@medusajs/ui" +import { useFieldArray, useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" +import { AdminClaim, AdminOrder, InventoryLevelDTO } from "@medusajs/types" +import { PencilSquare } from "@medusajs/icons" + +import { + RouteFocusModal, + StackedFocusModal, + useRouteModal, + useStackedModal, +} from "../../../../../components/modals" + +import { ClaimCreateSchema, ReturnCreateSchemaType } from "./schema" +import { AddClaimItemsTable } from "../add-claim-items-table" +import { Form } from "../../../../../components/common/form" +import { ClaimInboundItem } from "./claim-inbound-item.tsx" +import { Combobox } from "../../../../../components/inputs/combobox" +import { useStockLocations } from "../../../../../hooks/api/stock-locations" +import { useShippingOptions } from "../../../../../hooks/api/shipping-options" +import { getStylizedAmount } from "../../../../../lib/money-amount-helpers" + +import { currencies } from "../../../../../lib/data/currencies" +import { sdk } from "../../../../../lib/client" +import { + useAddClaimInboundItems, + useAddClaimInboundShipping, + useDeleteClaimInboundShipping, + useRemoveClaimInboundItem, + useUpdateClaimInboundItem, + useUpdateClaimInboundShipping, +} from "../../../../../hooks/api/claims" + +type ReturnCreateFormProps = { + order: AdminOrder + claim: AdminClaim + preview: AdminOrder +} + +let selectedItems: string[] = [] + +let IS_CANCELING = false + +export const ClaimCreateForm = ({ + order, + preview, + claim, +}: ReturnCreateFormProps) => { + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + + /** + * STATE + */ + const { setIsOpen } = useStackedModal() + const [isShippingPriceEdit, setIsShippingPriceEdit] = useState(false) + const [customShippingAmount, setCustomShippingAmount] = useState(0) + const [inventoryMap, setInventoryMap] = useState< + Record + >({}) + + /** + * HOOKS + */ + const { stock_locations = [] } = useStockLocations({ limit: 999 }) + const { shipping_options = [] } = useShippingOptions({ + limit: 999, + fields: "*prices,+service_zone.fulfillment_set.location.id", + /** + * TODO: this should accept filter for location_id + */ + }) + + /** + * MUTATIONS + */ + const { mutateAsync: confirmClaimRequest, isPending: isConfirming } = {} // useConfirmClaimRequest(claim.id, order.id) + + const { mutateAsync: cancelClaimRequest, isPending: isCanceling } = {} // useCancelClaimRequest(claim.id, order.id) + + const { mutateAsync: updateClaimRequest, isPending: isUpdating } = {} // useUpdateClaim(claim.id, order.id) + + const { + mutateAsync: addInboundShipping, + isPending: isAddingInboundShipping, + } = useAddClaimInboundShipping(claim.id, order.id) + + const { + mutateAsync: updateInboundShipping, + isPending: isUpdatingInboundShipping, + } = useUpdateClaimInboundShipping(claim.id, order.id) + + const { + mutateAsync: deleteInboundShipping, + isPending: isDeletingInboundShipping, + } = useDeleteClaimInboundShipping(claim.id, order.id) + + const { mutateAsync: addInboundItem, isPending: isAddingInboundItem } = + useAddClaimInboundItems(claim.id, order.id) + + const { mutateAsync: updateInboundItem, isPending: isUpdatingInboundItem } = + useUpdateClaimInboundItem(claim.id, order.id) + + const { mutateAsync: removeInboundItem, isPending: isRemovingInboundItem } = + useRemoveClaimInboundItem(claim.id, order.id) + + const isRequestLoading = + isConfirming || + isCanceling || + isAddingInboundShipping || + isUpdatingInboundShipping || + isDeletingInboundShipping || + isAddingInboundItem || + isRemovingInboundItem || + isUpdatingInboundItem || + isUpdating + + /** + * Only consider items that belong to this claim. + */ + const previewItems = useMemo( + () => + preview.items.filter( + (i) => !!i.actions?.find((a) => a.claim_id === claim.id) + ), + [preview.items] + ) + + const itemsMap = useMemo( + () => new Map(order.items.map((i) => [i.id, i])), + [order.items] + ) + + const previewItemsMap = useMemo( + () => new Map(previewItems.map((i) => [i.id, i])), + [previewItems] + ) + + /** + * FORM + */ + + const form = useForm({ + defaultValues: () => { + const method = preview.shipping_methods.find( + (s) => !!s.actions?.find((a) => a.action === "SHIPPING_ADD") + ) + + return Promise.resolve({ + inbound_items: previewItems.map((i) => ({ + item_id: i.id, + quantity: i.detail.return_requested_quantity, + note: i.actions?.find((a) => a.action === "RETURN_ITEM") + ?.internal_note, + reason_id: i.actions?.find((a) => a.action === "RETURN_ITEM")?.details + ?.reason_id, + })), + inbound_option_id: method ? method.shipping_option_id : "", + location_id: "", + send_notification: false, + }) + }, + resolver: zodResolver(ClaimCreateSchema), + }) + + const { + fields: items, + append, + remove, + update, + } = useFieldArray({ + name: "inbound_items", + control: form.control, + }) + + useEffect(() => { + const existingItemsMap = {} + + previewItems.forEach((i) => { + const ind = items.findIndex((field) => field.item_id === i.id) + + existingItemsMap[i.id] = true + + if (ind > -1) { + if (items[ind].quantity !== i.detail.return_requested_quantity) { + const returnItemAction = i.actions?.find( + (a) => a.action === "RETURN_ITEM" + ) + + update(ind, { + ...items[ind], + quantity: i.detail.return_requested_quantity, + note: returnItemAction?.internal_note, + reason_id: returnItemAction?.details?.reason_id, + }) + } + } else { + append({ item_id: i.id, quantity: i.detail.return_requested_quantity }) + } + }) + + items.forEach((i, ind) => { + if (!(i.item_id in existingItemsMap)) { + remove(ind) + } + }) + }, [previewItems]) + + useEffect(() => { + const method = preview.shipping_methods.find( + (s) => !!s.actions?.find((a) => a.action === "SHIPPING_ADD") + ) + + if (method) { + form.setValue("option_id", method.shipping_option_id) + } + }, [preview.shipping_methods]) + + const showPlaceholder = !items.length + const locationId = form.watch("location_id") + const shippingOptionId = form.watch("option_id") + + const handleSubmit = form.handleSubmit(async (data) => { + try { + await confirmClaimRequest({ no_notification: !data.send_notification }) + + handleSuccess() + } catch (e) { + toast.error(t("general.error"), { + description: e.message, + dismissLabel: t("actions.close"), + }) + } + }) + + const onItemsSelected = () => { + addInboundItem({ + items: selectedItems.map((id) => ({ + id, + quantity: 1, + })), + }) + + setIsOpen("items", false) + } + + const onLocationChange = async (selectedLocationId: string) => { + await updateClaimRequest({ location_id: selectedLocationId }) + } + + const onShippingOptionChange = async (selectedOptionId: string) => { + const promises = preview.shipping_methods + .map((s) => s.actions?.find((a) => a.action === "SHIPPING_ADD")?.id) + .filter(Boolean) + .map(deleteInboundShipping) + + await Promise.all(promises) + + await addInboundShipping({ shipping_option_id: selectedOptionId }) + } + + useEffect(() => { + if (isShippingPriceEdit) { + document.getElementById("js-shipping-input").focus() + } + }, [isShippingPriceEdit]) + + const showLevelsWarning = useMemo(() => { + if (!locationId) { + return false + } + + const allItemsHaveLocation = items + .map((_i) => { + const item = itemsMap.get(_i.item_id) + if (!item?.variant_id) { + return true + } + + if (!item.variant.manage_inventory) { + return true + } + + return inventoryMap[item.variant_id]?.find( + (l) => l.location_id === locationId + ) + }) + .every(Boolean) + + return !allItemsHaveLocation + }, [items, inventoryMap, locationId]) + + useEffect(() => { + const getInventoryMap = async () => { + const ret: Record = {} + + if (!items.length) { + return ret + } + + ;( + await Promise.all( + items.map(async (_i) => { + const item = itemsMap.get(_i.item_id) + + if (!item.variant_id) { + return undefined + } + return await sdk.admin.product.retrieveVariant( + item.variant.product.id, + item.variant_id, + { fields: "*inventory,*inventory.location_levels" } + ) + }) + ) + ) + .filter((it) => it?.variant) + .forEach((item) => { + const { variant } = item + const levels = variant.inventory[0]?.location_levels + + if (!levels) { + return + } + + ret[variant.id] = levels + }) + + return ret + } + + getInventoryMap().then((map) => { + setInventoryMap(map) + }) + }, [items]) + + useEffect(() => { + /** + * Unmount hook + */ + return () => { + if (IS_CANCELING) { + cancelClaimRequest() + // TODO: add this on ESC press + IS_CANCELING = false + } + } + }, []) + + const returnTotal = preview.return_requested_total + + const shippingTotal = useMemo(() => { + const method = preview.shipping_methods.find( + (sm) => !!sm.actions?.find((a) => a.action === "SHIPPING_ADD") + ) + + return method?.total || 0 + }, [preview.shipping_methods]) + + const refundAmount = returnTotal - shippingTotal + + return ( + +
+ + + +
+ {t("orders.claims.create")} +
+ {t("orders.returns.inbound")} + + + + {t("actions.addItems")} + + + + + + i.item_id)} + currencyCode={order.currency_code} + onSelectionChange={(s) => (selectedItems = s)} + /> + +
+
+ + + + +
+
+
+
+
+
+ {showPlaceholder && ( +
+ )} + {items.map((item, index) => ( + { + const actionId = previewItems + .find((i) => i.id === item.item_id) + ?.actions?.find((a) => a.action === "RETURN_ITEM")?.id + + if (actionId) { + removeInboundItem(actionId) + } + }} + onUpdate={(payload) => { + const actionId = previewItems + .find((i) => i.id === item.item_id) + ?.actions?.find((a) => a.action === "RETURN_ITEM")?.id + + if (actionId) { + updateInboundItem({ ...payload, actionId }) + } + }} + index={index} + /> + ))} + {!showPlaceholder && ( +
+ {/*LOCATION*/} +
+
+ {t("orders.returns.location")} + + {t("orders.returns.locationHint")} + +
+ + { + return ( + + + { + onChange(v) + onLocationChange(v) + }} + {...field} + options={(stock_locations ?? []).map( + (stockLocation) => ({ + label: stockLocation.name, + value: stockLocation.id, + }) + )} + /> + + + ) + }} + /> +
+ + {/*INBOUND SHIPPING*/} +
+
+ + {t("orders.returns.inboundShipping")} + + + {t("orders.returns.inboundShippingHint")} + +
+ + {/*TODO: WHAT IF THE RETURN OPTION HAS COMPUTED PRICE*/} + { + return ( + + + { + onChange(v) + onShippingOptionChange(v) + }} + {...field} + options={(shipping_options ?? []) + .filter( + (so) => + (locationId + ? so.service_zone.fulfillment_set! + .location.id === locationId + : true) && + !!so.rules.find( + (r) => + r.attribute === "is_return" && + r.value === "true" + ) + ) + .map((so) => ({ + label: so.name, + value: so.id, + }))} + disabled={!locationId} + /> + + + ) + }} + /> +
+
+ )} + + {showLevelsWarning && ( + +
+ {t("orders.returns.noInventoryLevel")} +
+ + {t("orders.returns.noInventoryLevelDesc")} + +
+ )} + + {/*TOTALS SECTION*/} +
+
+ + {t("orders.returns.returnTotal")} + + + {getStylizedAmount( + returnTotal ? -1 * returnTotal : returnTotal, + order.currency_code + )} + +
+ +
+ + {t("orders.returns.inboundShipping")} + + + {!isShippingPriceEdit && ( + setIsShippingPriceEdit(true)} + variant="transparent" + className="text-ui-fg-muted" + disabled={showPlaceholder || !shippingOptionId} + > + + + )} + {isShippingPriceEdit ? ( + { + let actionId + + preview.shipping_methods.forEach((s) => { + if (s.actions) { + for (let a of s.actions) { + if (a.action === "SHIPPING_ADD") { + actionId = a.id + } + } + } + }) + + if (actionId) { + updateInboundShipping({ + actionId, + custom_price: + typeof customShippingAmount === "string" + ? null + : customShippingAmount, + }) + } + setIsShippingPriceEdit(false) + }} + symbol={ + currencies[order.currency_code.toUpperCase()] + .symbol_native + } + code={order.currency_code} + onValueChange={(value) => + setCustomShippingAmount(value ? parseInt(value) : "") + } + value={customShippingAmount} + disabled={showPlaceholder} + /> + ) : ( + getStylizedAmount(shippingTotal, order.currency_code) + )} + +
+ +
+ + {t("orders.claims.refundAmount")} + + + {getStylizedAmount( + refundAmount ? -1 * refundAmount : refundAmount, + order.currency_code + )} + +
+
+ + {/*SEND NOTIFICATION*/} +
+ { + return ( + +
+ + + +
+ + {t("orders.returns.sendNotification")} + + + {t("orders.returns.sendNotificationHint")} + +
+
+ +
+ ) + }} + /> +
+
+ + +
+
+ + + + +
+
+
+ + + ) +} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/claim-create-form/claim-inbound-item.tsx b/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/claim-create-form/claim-inbound-item.tsx new file mode 100644 index 0000000000..e3e0b21c87 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/claim-create-form/claim-inbound-item.tsx @@ -0,0 +1,247 @@ +import { useTranslation } from "react-i18next" + +import React from "react" +import { IconButton, Input, Text } from "@medusajs/ui" +import { UseFormReturn } from "react-hook-form" +import { HttpTypes, AdminOrderLineItem } from "@medusajs/types" +import { ChatBubble, DocumentText, XCircle, XMark } from "@medusajs/icons" + +import { Thumbnail } from "../../../../../components/common/thumbnail" +import { MoneyAmountCell } from "../../../../../components/table/table-cells/common/money-amount-cell" +import { Form } from "../../../../../components/common/form" +import { ActionMenu } from "../../../../../components/common/action-menu" +import { Combobox } from "../../../../../components/inputs/combobox" +import { useReturnReasons } from "../../../../../hooks/api/return-reasons" + +type OrderEditItemProps = { + item: AdminOrderLineItem + previewItem: AdminOrderLineItem + currencyCode: string + index: number + + onRemove: () => void + onUpdate: (payload: HttpTypes.AdminUpdateReturnItems) => void + + form: UseFormReturn +} + +function ClaimInboundItem({ + item, + previewItem, + currencyCode, + form, + onRemove, + onUpdate, + index, +}: OrderEditItemProps) { + const { t } = useTranslation() + + const { return_reasons = [] } = useReturnReasons({ fields: "+label" }) + + const formItem = form.watch(`inbound_items.${index}`) + + const showReturnReason = typeof formItem.reason_id === "string" + const showNote = typeof formItem.note === "string" + + return ( +
+
+
+ +
+
+ + {item.title}{" "} + + {item.variant.sku && ({item.variant.sku})} +
+ + {item.variant.product.title} + +
+
+ +
+
+ { + return ( + + + { + const val = e.target.value + const payload = val === "" ? null : Number(val) + + field.onChange(payload) + + if (payload) { + // todo: move on blur + onUpdate({ quantity: payload }) + } + }} + /> + + + + ) + }} + /> + + {t("fields.qty")} + +
+ +
+ +
+ + + form.setValue(`inbound_items.${index}.reason_id`, ""), + icon: , + }, + !showNote && { + label: t("actions.addNote"), + onClick: () => + form.setValue(`inbound_items.${index}.note`, ""), + icon: , + }, + { + label: t("actions.remove"), + onClick: onRemove, + icon: , + }, + ].filter(Boolean), + }, + ]} + /> +
+
+ <> + {/*REASON*/} + {showReturnReason && ( +
+
+ {t("orders.returns.reason")} + + {t("orders.returns.reasonHint")} + +
+ +
+
+ { + return ( + + + { + onUpdate({ reason_id: v }) + onChange(v) + }} + {...field} + options={return_reasons.map((reason) => ({ + label: reason.label, + value: reason.id, + }))} + /> + + + + ) + }} + /> +
+ { + onUpdate({ reason_id: null }) // TODO BE: we should be able to set to unset reason here + form.setValue(`inbound_items.${index}.reason_id`, "") + }} + > + + +
+
+ )} + + {/*NOTE*/} + {showNote && ( +
+
+ {t("orders.returns.note")} + + {t("orders.returns.noteHint")} + +
+ +
+
+ { + return ( + + + + onUpdate({ internal_note: field.value }) + } + className="bg-ui-bg-field-component hover:bg-ui-bg-field-component-hover" + /> + + + + ) + }} + /> +
+ { + form.setValue(`items.${index}.note`, { + shouldDirty: true, + shouldTouch: true, + }) + onUpdate({ internal_note: null }) + }} + > + + +
+
+ )} + +
+ ) +} + +export { ClaimInboundItem } diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/claim-create-form/index.ts b/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/claim-create-form/index.ts new file mode 100644 index 0000000000..c4e802d20f --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/claim-create-form/index.ts @@ -0,0 +1 @@ +export * from "./claim-create-form" diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/claim-create-form/schema.ts b/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/claim-create-form/schema.ts new file mode 100644 index 0000000000..c45816df30 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-claim/components/claim-create-form/schema.ts @@ -0,0 +1,23 @@ +import { z } from "zod" + +export const ClaimCreateSchema = z.object({ + inbound_items: z.array( + z.object({ + item_id: z.string(), + quantity: z.number(), + reason_id: z.string().optional().nullable(), + note: z.string().optional().nullable(), + }) + ), + outbound_items: z.array( + z.object({ + item_id: z.string(), // TODO: variant id? + quantity: z.number(), + }) + ), + location_id: z.string().optional(), + inbound_option_id: z.string(), + send_notification: z.boolean().optional(), +}) + +export type ReturnCreateSchemaType = z.infer diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-claim/index.ts b/packages/admin-next/dashboard/src/routes/orders/order-create-claim/index.ts new file mode 100644 index 0000000000..7f927749d7 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-claim/index.ts @@ -0,0 +1 @@ +export { ClaimCreate as Component } from "./claim-create" diff --git a/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-summary-section/order-summary-section.tsx b/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-summary-section/order-summary-section.tsx index 6cbbbcc796..fbfced2a2a 100644 --- a/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-summary-section/order-summary-section.tsx +++ b/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-summary-section/order-summary-section.tsx @@ -10,15 +10,15 @@ import { } from "@medusajs/types" import { ArrowDownRightMini, - ArrowLongRight, ArrowUturnLeft, + ExclamationCircle, + ArrowLongRight, } from "@medusajs/icons" import { Button, Container, Copy, Heading, - IconButton, StatusBadge, Text, } from "@medusajs/ui" @@ -150,6 +150,11 @@ const Header = ({ order }: { order: AdminOrder }) => { to: `/orders/${order.id}/returns`, icon: , }, + { + label: t("orders.claims.create"), + to: `/orders/${order.id}/claims`, + icon: , + }, ], }, ]}