diff --git a/packages/admin-next/dashboard/src/hooks/api/order-edits.tsx b/packages/admin-next/dashboard/src/hooks/api/order-edits.tsx new file mode 100644 index 0000000000..d84b32f6c4 --- /dev/null +++ b/packages/admin-next/dashboard/src/hooks/api/order-edits.tsx @@ -0,0 +1,202 @@ +import { useMutation, UseMutationOptions } from "@tanstack/react-query" + +import { HttpTypes } from "@medusajs/types" + +import { sdk } from "../../lib/client" +import { queryClient } from "../../lib/query-client" +import { ordersQueryKeys } from "./orders" + +export const useCreateOrderEdit = ( + orderId: string, + options?: UseMutationOptions< + HttpTypes.AdminOrderEditPreviewResponse, + Error, + HttpTypes.AdminInitiateOrderEditRequest + > +) => { + return useMutation({ + mutationFn: (payload: HttpTypes.AdminInitiateOrderEditRequest) => + sdk.admin.orderEdit.initiateRequest(payload), + onSuccess: (data: any, variables: any, context: any) => { + queryClient.invalidateQueries({ + queryKey: ordersQueryKeys.details(), + }) + + queryClient.invalidateQueries({ + queryKey: ordersQueryKeys.preview(orderId), + }) + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +export const useRequestOrderEdit = ( + id: string, + options?: UseMutationOptions< + HttpTypes.AdminOrderEditPreviewResponse, + Error, + void + > +) => { + return useMutation({ + mutationFn: () => sdk.admin.orderEdit.request(id), + onSuccess: (data: any, variables: any, context: any) => { + queryClient.invalidateQueries({ + queryKey: ordersQueryKeys.details(), + }) + + queryClient.invalidateQueries({ + queryKey: ordersQueryKeys.preview(id), + }) + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +export const useConfirmOrderEdit = ( + id: string, + options?: UseMutationOptions< + HttpTypes.AdminOrderEditPreviewResponse, + Error, + void + > +) => { + return useMutation({ + mutationFn: () => sdk.admin.orderEdit.confirm(id), + onSuccess: (data: any, variables: any, context: any) => { + queryClient.invalidateQueries({ + queryKey: ordersQueryKeys.details(), + }) + + queryClient.invalidateQueries({ + queryKey: ordersQueryKeys.preview(id), + }) + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +export const useCancelOrderEdit = ( + orderId: string, + options?: UseMutationOptions +) => { + return useMutation({ + mutationFn: () => sdk.admin.orderEdit.cancelRequest(orderId), + onSuccess: (data: any, variables: any, context: any) => { + queryClient.invalidateQueries({ + queryKey: ordersQueryKeys.details(), + }) + + queryClient.invalidateQueries({ + queryKey: ordersQueryKeys.preview(orderId), + }) + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +export const useAddOrderEditItems = ( + id: string, + options?: UseMutationOptions< + HttpTypes.AdminOrderEditPreviewResponse, + Error, + HttpTypes.AdminAddOrderEditItems + > +) => { + return useMutation({ + mutationFn: (payload: HttpTypes.AdminAddOrderEditItems) => + sdk.admin.orderEdit.addItems(id, payload), + onSuccess: (data: any, variables: any, context: any) => { + queryClient.invalidateQueries({ + queryKey: ordersQueryKeys.preview(id), + }) + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +/** + * Update (quantity) of an item that was originally on the order. + */ +export const useUpdateOrderEditOriginalItem = ( + id: string, + options?: UseMutationOptions< + HttpTypes.AdminOrderEditPreviewResponse, + Error, + HttpTypes.AdminUpdateOrderEditItem & { itemId: string } + > +) => { + return useMutation({ + mutationFn: ({ + itemId, + ...payload + }: HttpTypes.AdminUpdateOrderEditItem & { itemId: string }) => { + return sdk.admin.orderEdit.updateOriginalItem(id, itemId, payload) + }, + onSuccess: (data: any, variables: any, context: any) => { + queryClient.invalidateQueries({ + queryKey: ordersQueryKeys.preview(id), + }) + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +/** + * Update (quantity) of an item that was added to the order edit. + */ +export const useUpdateOrderEditAddedItem = ( + id: string, + options?: UseMutationOptions< + HttpTypes.AdminOrderEditPreviewResponse, + Error, + HttpTypes.AdminUpdateOrderEditItem & { actionId: string } + > +) => { + return useMutation({ + mutationFn: ({ + actionId, + ...payload + }: HttpTypes.AdminUpdateOrderEditItem & { actionId: string }) => { + return sdk.admin.orderEdit.updateAddedItem(id, actionId, payload) + }, + onSuccess: (data: any, variables: any, context: any) => { + queryClient.invalidateQueries({ + queryKey: ordersQueryKeys.preview(id), + }) + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +/** + * Remove item that was added to the edit. + * To remove an original item on the order, set quantity to 0. + */ +export const useRemoveOrderEditItem = ( + id: string, + options?: UseMutationOptions< + HttpTypes.AdminOrderEditPreviewResponse, + Error, + string + > +) => { + return useMutation({ + mutationFn: (actionId: string) => + sdk.admin.orderEdit.removeAddedItem(id, actionId), + onSuccess: (data: any, variables: any, context: any) => { + queryClient.invalidateQueries({ + queryKey: ordersQueryKeys.preview(id), + }) + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} diff --git a/packages/admin-next/dashboard/src/hooks/api/orders.tsx b/packages/admin-next/dashboard/src/hooks/api/orders.tsx index 767515d0e7..2e5a3e61f6 100644 --- a/packages/admin-next/dashboard/src/hooks/api/orders.tsx +++ b/packages/admin-next/dashboard/src/hooks/api/orders.tsx @@ -45,6 +45,7 @@ export const useOrder = ( export const useOrderPreview = ( id: string, + query?: HttpTypes.AdminOrderFilters, options?: Omit< UseQueryOptions< HttpTypes.AdminOrderPreviewResponse, @@ -56,7 +57,7 @@ export const useOrderPreview = ( > ) => { const { data, ...rest } = useQuery({ - queryFn: async () => sdk.admin.order.retrievePreview(id), + queryFn: async () => sdk.admin.order.retrievePreview(id, query), queryKey: ordersQueryKeys.preview(id), ...options, }) diff --git a/packages/admin-next/dashboard/src/i18n/translations/en.json b/packages/admin-next/dashboard/src/i18n/translations/en.json index 5e38b2f603..b30df2b76f 100644 --- a/packages/admin-next/dashboard/src/i18n/translations/en.json +++ b/packages/admin-next/dashboard/src/i18n/translations/en.json @@ -29,6 +29,9 @@ "expired": "Expired", "active": "Active", "revoked": "Revoked", + "new": "New", + "modified": "Modified", + "removed": "Removed", "admin": "Admin", "store": "Store", "details": "Details", @@ -94,14 +97,17 @@ "actions": { "save": "Save", "saveAsDraft": "Save as draft", + "duplicate": "Duplicate", "publish": "Publish", "create": "Create", "delete": "Delete", "remove": "Remove", "revoke": "Revoke", "cancel": "Cancel", + "forceConfirm": "Force confirm", "enable": "Enable", "disable": "Disable", + "undo": "Undo", "complete": "Complete", "viewDetails": "View details", "back": "Back", @@ -806,7 +812,7 @@ "summary": { "requestReturn": "Request return", "allocateItems": "Allocate items", - "editItems": "Edit items" + "editOrder": "Edit order" }, "payment": { "title": "Payments", @@ -840,7 +846,6 @@ "paymentLink": "Copy payment link for {{ amount }}", "selectPaymentToRefund": "Select payment to refund" }, - "edits": { "title": "Edit order", "currentItems": "Current items", @@ -849,7 +854,23 @@ "addItems": "Add items", "amountPaid": "Amount paid", "newTotal": "New total", - "differenceDue": "Difference due" + "differenceDue": "Difference due", + "create": "Edit Order", + "currentTotal": "Current total", + "noteHint": "Add an internal note for the edit", + "cancelSuccessToast": "Order edit canceled", + "createSuccessToast": "Order edit request created", + "activeChangeError": "There is already active order change on the order (return, claim, exchange etc.). Please finish or cancel the change before editing the order.", + "panel": { + "title": "Order edit requested" + }, + "toast": { + "canceledSuccessfully": "Order edit cancelled", + "confirmedSuccessfully": "Order edit confirmed" + }, + "validation": { + "quantityLowerThanFulfillment": "Cannot set quantity to be less then or equal to fulfilled quantity" + } }, "returns": { "create": "Create Return", @@ -2326,7 +2347,9 @@ "productVariant": "Product Variant", "prices": "Prices", "available": "Available", - "inStock": "In stock" + "inStock": "In stock", + "added": "Added", + "removed": "Removed" }, "fields": { "amount": "Amount", @@ -2342,7 +2365,6 @@ "inventoryItems": "Inventory items", "inventoryItem": "Inventory item", "requiredQuantity": "Required quantity", - "qty": "Qty", "description": "Description", "email": "Email", "password": "Password", @@ -2365,6 +2387,7 @@ "reason": "Reason", "none": "none", "all": "all", + "search": "Search", "percentage": "Percentage", "sales_channels": "Sales Channels", "customer_groups": "Customer Groups", 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 12ea6d84f4..9ceecb26e9 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 @@ -257,6 +257,10 @@ export const RouteMap: RouteObject[] = [ lazy: () => import("../../routes/orders/order-create-exchange"), }, + { + path: "edits", + lazy: () => import("../../routes/orders/order-create-edit"), + }, { path: "refund", lazy: () => diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-edit/components/add-order-edit-items-table/add-order-edit-items-table.tsx b/packages/admin-next/dashboard/src/routes/orders/order-create-edit/components/add-order-edit-items-table/add-order-edit-items-table.tsx new file mode 100644 index 0000000000..e527583019 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-edit/components/add-order-edit-items-table/add-order-edit-items-table.tsx @@ -0,0 +1,80 @@ +import { OnChangeFn, RowSelectionState } from "@tanstack/react-table" +import { useState } from "react" + +import { DataTable } from "../../../../../components/table/data-table" +import { useVariants } from "../../../../../hooks/api" +import { useDataTable } from "../../../../../hooks/use-data-table" +import { useOrderEditItemTableFilters } from "./use-order-edit-item-table-filters" +import { useOrderEditItemTableQuery } from "./use-order-edit-item-table-query" +import { useOrderEditItemsTableColumns } from "./use-order-edit-item-table-columns" + +const PAGE_SIZE = 50 +const PREFIX = "rit" + +type AddExchangeOutboundItemsTableProps = { + onSelectionChange: (ids: string[]) => void + currencyCode: string +} + +export const AddOrderEditItemsTable = ({ + onSelectionChange, + currencyCode, +}: AddExchangeOutboundItemsTableProps) => { + const [rowSelection, setRowSelection] = useState({}) + + const updater: OnChangeFn = (fn) => { + const newState: RowSelectionState = + typeof fn === "function" ? fn(rowSelection) : fn + + setRowSelection(newState) + onSelectionChange(Object.keys(newState)) + } + + const { searchParams, raw } = useOrderEditItemTableQuery({ + pageSize: PAGE_SIZE, + prefix: PREFIX, + }) + + const { variants = [], count } = useVariants({ + ...searchParams, + fields: "*inventory_items.inventory.location_levels,+inventory_quantity", + }) + + const columns = useOrderEditItemsTableColumns(currencyCode) + const filters = useOrderEditItemTableFilters() + + const { table } = useDataTable({ + data: variants, + columns: columns, + count, + enablePagination: true, + getRowId: (row) => row.id, + pageSize: PAGE_SIZE, + enableRowSelection: (_row) => { + // TODO: Check inventory here. Check if other validations needs to be made + return true + }, + rowSelection: { + state: rowSelection, + updater, + }, + }) + + return ( +
+ +
+ ) +} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-edit/components/add-order-edit-items-table/index.ts b/packages/admin-next/dashboard/src/routes/orders/order-create-edit/components/add-order-edit-items-table/index.ts new file mode 100644 index 0000000000..071c57a095 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-edit/components/add-order-edit-items-table/index.ts @@ -0,0 +1 @@ +export * from "./add-order-edit-items-table.tsx" diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-edit/components/add-order-edit-items-table/use-order-edit-item-table-columns.tsx b/packages/admin-next/dashboard/src/routes/orders/order-create-edit/components/add-order-edit-items-table/use-order-edit-item-table-columns.tsx new file mode 100644 index 0000000000..936c09c633 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-edit/components/add-order-edit-items-table/use-order-edit-item-table-columns.tsx @@ -0,0 +1,68 @@ +import { Checkbox } from "@medusajs/ui" +import { createColumnHelper } from "@tanstack/react-table" +import { useMemo } from "react" +import { useTranslation } from "react-i18next" + +import { + ProductCell, + ProductHeader, +} from "../../../../../components/table/table-cells/product/product-cell" + +const columnHelper = createColumnHelper() + +export const useOrderEditItemsTableColumns = (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 }) => { + return + }, + }), + columnHelper.accessor("sku", { + header: t("fields.sku"), + cell: ({ getValue }) => { + return getValue() || "-" + }, + }), + columnHelper.accessor("title", { + header: t("fields.title"), + }), + ], + [t, currencyCode] + ) +} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-edit/components/add-order-edit-items-table/use-order-edit-item-table-filters.tsx b/packages/admin-next/dashboard/src/routes/orders/order-create-edit/components/add-order-edit-items-table/use-order-edit-item-table-filters.tsx new file mode 100644 index 0000000000..f4d681c6bb --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-edit/components/add-order-edit-items-table/use-order-edit-item-table-filters.tsx @@ -0,0 +1,22 @@ +import { useTranslation } from "react-i18next" + +import { Filter } from "../../../../../components/table/data-table" + +export const useOrderEditItemTableFilters = () => { + const { t } = useTranslation() + + const filters: Filter[] = [ + { + 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-edit/components/add-order-edit-items-table/use-order-edit-item-table-query.tsx b/packages/admin-next/dashboard/src/routes/orders/order-create-edit/components/add-order-edit-items-table/use-order-edit-item-table-query.tsx new file mode 100644 index 0000000000..038363a44a --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-edit/components/add-order-edit-items-table/use-order-edit-item-table-query.tsx @@ -0,0 +1,26 @@ +import { useQueryParams } from "../../../../../hooks/use-query-params" + +export const useOrderEditItemTableQuery = ({ + pageSize = 50, + prefix, +}: { + pageSize?: number + prefix?: string +}) => { + const raw = useQueryParams( + ["q", "offset", "order", "created_at", "updated_at"], + prefix + ) + + const { offset, created_at, updated_at, ...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, + } + + return { searchParams, raw } +} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-edit/components/order-edit-create-form/index.ts b/packages/admin-next/dashboard/src/routes/orders/order-create-edit/components/order-edit-create-form/index.ts new file mode 100644 index 0000000000..01733d66fe --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-edit/components/order-edit-create-form/index.ts @@ -0,0 +1 @@ +export * from "./order-edit-create-form.tsx" diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-edit/components/order-edit-create-form/order-edit-create-form.tsx b/packages/admin-next/dashboard/src/routes/orders/order-create-edit/components/order-edit-create-form/order-edit-create-form.tsx new file mode 100644 index 0000000000..1f60214681 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-edit/components/order-edit-create-form/order-edit-create-form.tsx @@ -0,0 +1,213 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { AdminOrder, AdminOrderPreview } from "@medusajs/types" +import { Button, Heading, Input, Switch, toast } from "@medusajs/ui" +import { useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" + +import { + RouteFocusModal, + useRouteModal, +} from "../../../../../components/modals" + +import { Form } from "../../../../../components/common/form" +import { getStylizedAmount } from "../../../../../lib/money-amount-helpers" +import { CreateOrderEditSchemaType, OrderEditCreateSchema } from "./schema" +import { + useCancelOrderEdit, + useRequestOrderEdit, +} from "../../../../../hooks/api/order-edits" +import { OrderEditItemsSection } from "./order-edit-items-section" + +type ReturnCreateFormProps = { + order: AdminOrder + preview: AdminOrderPreview +} + +export const OrderEditCreateForm = ({ + order, + preview, +}: ReturnCreateFormProps) => { + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + + /** + * MUTATIONS + */ + + const { mutateAsync: cancelOrderEditRequest, isPending: isCanceling } = + useCancelOrderEdit(order.id) + + const { mutateAsync: requestOrderEdit, isPending: isRequesting } = + useRequestOrderEdit(order.id) + + const isRequestRunning = isCanceling || isRequesting + + /** + * FORM + */ + const form = useForm({ + defaultValues: () => { + return Promise.resolve({ + note: "", // TODO: add note when update edit route is added + send_notification: false, // TODO: not supported in the API ATM + }) + }, + resolver: zodResolver(OrderEditCreateSchema), + }) + + const handleSubmit = form.handleSubmit(async (data) => { + try { + await requestOrderEdit() + + toast.success(t("orders.edits.createSuccessToast")) + handleSuccess() + } catch (e) { + toast.error(t("general.error"), { + description: e.message, + }) + } + }) + + return ( + { + if (!isSubmitSuccessful) { + cancelOrderEditRequest(undefined, { + onSuccess: () => { + toast.success(t("orders.edits.cancelSuccessToast")) + }, + onError: (error) => { + toast.error(error.message) + }, + }) + } + }} + > +
+ + + +
+ {t("orders.edits.create")} + + + + {/*TOTALS SECTION*/} +
+
+ + {t("orders.edits.currentTotal")} + + + + {getStylizedAmount(order.total, order.currency_code)} + +
+ +
+ + {t("orders.edits.newTotal")} + + + + {getStylizedAmount(preview.total, order.currency_code)} + +
+ +
+ + {t("orders.exchanges.refundAmount")} + + + {getStylizedAmount( + preview.summary.pending_difference, + order.currency_code + )} + +
+
+ + {/*NOTE*/} + { + return ( + +
+
+ {t("fields.note")} + + {t("orders.edits.noteHint")} + +
+
+ + + +
+
+
+ ) + }} + /> + + {/*SEND NOTIFICATION*/} +
+ { + return ( + +
+ + + +
+ + {t("orders.returns.sendNotification")} + + + {t("orders.returns.sendNotificationHint")} + +
+
+ +
+ ) + }} + /> +
+
+
+ +
+
+ + + + +
+
+
+ +
+ ) +} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-edit/components/order-edit-create-form/order-edit-item.tsx b/packages/admin-next/dashboard/src/routes/orders/order-create-edit/components/order-edit-create-form/order-edit-item.tsx new file mode 100644 index 0000000000..16e4608eb2 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-edit/components/order-edit-create-form/order-edit-item.tsx @@ -0,0 +1,231 @@ +import { ArrowUturnLeft, DocumentSeries, XCircle } from "@medusajs/icons" +import { AdminOrderLineItem } from "@medusajs/types" +import { Badge, Input, Text, toast } from "@medusajs/ui" +import { useTranslation } from "react-i18next" + +import { ActionMenu } from "../../../../../components/common/action-menu" +import { Thumbnail } from "../../../../../components/common/thumbnail" +import { MoneyAmountCell } from "../../../../../components/table/table-cells/common/money-amount-cell" +import { useMemo } from "react" +import { + useAddOrderEditItems, + useRemoveOrderEditItem, + useUpdateOrderEditAddedItem, + useUpdateOrderEditOriginalItem, +} from "../../../../../hooks/api/order-edits" + +type OrderEditItemProps = { + item: AdminOrderLineItem + currencyCode: string + orderId: string +} + +function OrderEditItem({ item, currencyCode, orderId }: OrderEditItemProps) { + const { t } = useTranslation() + + const { mutateAsync: addItems } = useAddOrderEditItems(orderId) + const { mutateAsync: updateAddedItem } = useUpdateOrderEditAddedItem(orderId) + const { mutateAsync: updateOriginalItem } = + useUpdateOrderEditOriginalItem(orderId) + const { mutateAsync: undoAction } = useRemoveOrderEditItem(orderId) + + const isAddedItem = useMemo( + () => !!item.actions?.find((a) => a.action === "ITEM_ADD"), + [item] + ) + + const isItemUpdated = useMemo( + () => !!item.actions?.find((a) => a.action === "ITEM_UPDATE"), + [item] + ) + + const isItemRemoved = useMemo(() => { + // To be removed item needs to have updated quantity + const updateAction = item.actions?.find((a) => a.action === "ITEM_UPDATE") + return !!updateAction && item.quantity === item.detail.fulfilled_quantity + }, [item]) + + /** + * HANDLERS + */ + + const onUpdate = async (quantity: number) => { + if (quantity <= item.detail.fulfilled_quantity) { + toast.error(t("orders.edits.validation.quantityLowerThanFulfillment")) + return + } + + if (quantity === item.quantity) { + return + } + + const addItemAction = item.actions?.find((a) => a.action === "ITEM_ADD") + + try { + if (addItemAction) { + await updateAddedItem({ quantity, actionId: addItemAction.id }) + } else { + await updateOriginalItem({ quantity, itemId: item.id }) + } + } catch (e) { + toast.error(e.message) + } + } + + const onRemove = async () => { + const addItemAction = item.actions?.find((a) => a.action === "ITEM_ADD") + + try { + if (addItemAction) { + await undoAction(addItemAction.id) + } else { + await updateOriginalItem({ + quantity: item.detail.fulfilled_quantity, // + itemId: item.id, + }) + } + } catch (e) { + toast.error(e.message) + } + } + + const onRemoveUndo = async () => { + const updateItemAction = item.actions?.find( + (a) => a.action === "ITEM_UPDATE" + ) + + try { + if (updateItemAction) { + await undoAction(updateItemAction.id) // Remove action that updated items quantity to fulfilled quantity which makes it "removed" + } + } catch (e) { + toast.error(e.message) + } + } + + const onDuplicate = async () => { + try { + await addItems({ + items: [ + { + variant_id: item.variant_id, + quantity: item.quantity, + }, + ], + }) + } catch (e) { + toast.error(e.message) + } + } + + return ( +
+
+
+
+ + +
+
+ + {item.title}{" "} + + + {item.variant_sku && ({item.variant_sku})} +
+ + {item.product_title} + +
+
+ + {isAddedItem && ( + + {t("general.new")} + + )} + + {isItemRemoved ? ( + + {t("general.removed")} + + ) : ( + isItemUpdated && ( + + {t("general.modified")} + + ) + )} +
+ +
+
+ { + const val = e.target.value + const payload = val === "" ? null : Number(val) + + if (payload) { + onUpdate(payload) + } + }} + /> + + {t("fields.qty")} + +
+ +
+ +
+ + , + }, + ], + }, + { + actions: [ + !isItemRemoved + ? { + label: t("actions.remove"), + onClick: onRemove, + icon: , + disabled: + item.detail.fulfilled_quantity === item.quantity, + } + : { + label: t("actions.undo"), + onClick: onRemoveUndo, + icon: , + }, + ].filter(Boolean), + }, + ]} + /> +
+
+
+ ) +} + +export { OrderEditItem } diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-edit/components/order-edit-create-form/order-edit-items-section.tsx b/packages/admin-next/dashboard/src/routes/orders/order-create-edit/components/order-edit-create-form/order-edit-items-section.tsx new file mode 100644 index 0000000000..1f21230817 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-edit/components/order-edit-create-form/order-edit-items-section.tsx @@ -0,0 +1,142 @@ +import { useMemo, useState } from "react" +import { AdminOrder, AdminOrderPreview } from "@medusajs/types" +import { Button, Heading, Input, toast } from "@medusajs/ui" +import { useTranslation } from "react-i18next" +import { + RouteFocusModal, + StackedFocusModal, + useStackedModal, +} from "../../../../../components/modals" +import { AddOrderEditItemsTable } from "../add-order-edit-items-table" +import { OrderEditItem } from "./order-edit-item" +import { useAddOrderEditItems } from "../../../../../hooks/api/order-edits" + +type ExchangeInboundSectionProps = { + order: AdminOrder + preview: AdminOrderPreview +} + +let addedVariants: string[] = [] + +export const OrderEditItemsSection = ({ + order, + preview, +}: ExchangeInboundSectionProps) => { + const { t } = useTranslation() + + /** + * STATE + */ + const { setIsOpen } = useStackedModal() + const [filterTerm, setFilterTerm] = useState("") + + /* + * MUTATIONS + */ + const { mutateAsync: addItems, isPending } = useAddOrderEditItems(order.id) + + /** + * CALLBACKS + */ + const onItemsSelected = async () => { + try { + await addItems({ + items: addedVariants.map((i) => ({ + variant_id: i, + quantity: 1, + })), + }) + } catch (e) { + toast.error(e.message) + } + + setIsOpen("inbound-items", false) + } + + const filteredItems = useMemo(() => { + return preview.items.filter( + (i) => + i.title.toLowerCase().includes(filterTerm) || + i.product_title.toLowerCase().includes(filterTerm) + ) + }, [preview, filterTerm]) + + return ( +
+
+ {t("fields.items")} + +
+ setFilterTerm(e.target.value)} + placeholder={t("fields.search")} + autoComplete="off" + type="search" + /> + + + + + + + + + + { + addedVariants = finalSelection + }} + /> + + +
+
+ + + + +
+
+
+
+
+
+
+ + {filteredItems.map((item) => ( + + ))} + + {filterTerm && !filteredItems.length && ( +
+ )} +
+ ) +} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-edit/components/order-edit-create-form/schema.ts b/packages/admin-next/dashboard/src/routes/orders/order-create-edit/components/order-edit-create-form/schema.ts new file mode 100644 index 0000000000..ccfad2bf59 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-edit/components/order-edit-create-form/schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod" + +export const OrderEditCreateSchema = z.object({ + note: z.string().optional(), + send_notification: z.boolean().optional(), +}) + +export type CreateOrderEditSchemaType = z.infer diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-edit/index.ts b/packages/admin-next/dashboard/src/routes/orders/order-create-edit/index.ts new file mode 100644 index 0000000000..6dbbe171fd --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-edit/index.ts @@ -0,0 +1 @@ +export { OrderEditCreate as Component } from "./order-edit-create.tsx" diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-edit/order-edit-create.tsx b/packages/admin-next/dashboard/src/routes/orders/order-create-edit/order-edit-create.tsx new file mode 100644 index 0000000000..27135c4da7 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-edit/order-edit-create.tsx @@ -0,0 +1,65 @@ +import { toast } from "@medusajs/ui" +import { useEffect, useState } from "react" +import { useTranslation } from "react-i18next" +import { useNavigate, useParams } from "react-router-dom" + +import { RouteFocusModal } from "../../../components/modals" +import { useOrder, useOrderPreview } from "../../../hooks/api/orders" +import { DEFAULT_FIELDS } from "../order-detail/constants" +import { OrderEditCreateForm } from "./components/order-edit-create-form" +import { useCreateOrderEdit } from "../../../hooks/api/order-edits" + +let IS_REQUEST_RUNNING = false + +export const OrderEditCreate = () => { + const { id } = useParams() + const navigate = useNavigate() + const { t } = useTranslation() + + const { order } = useOrder(id!, { + fields: DEFAULT_FIELDS, + }) + + const { order: preview } = useOrderPreview(id!) + const { mutateAsync: createOrderEdit } = useCreateOrderEdit(order.id) + + useEffect(() => { + async function run() { + if (IS_REQUEST_RUNNING || !preview) { + return + } + + if (preview.order_change) { + if (preview.order_change.change_type !== "edit") { + navigate(`/orders/${preview.id}`, { replace: true }) + toast.error(t("orders.edits.activeChangeError")) + } + + return + } + + IS_REQUEST_RUNNING = true + + try { + const { order } = await createOrderEdit({ + order_id: preview.id, + }) + } catch (e) { + toast.error(e.message) + navigate(`/orders/${preview.id}`, { replace: true }) + } finally { + IS_REQUEST_RUNNING = false + } + } + + run() + }, [preview]) + + return ( + + {preview && order && ( + + )} + + ) +} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-exchange/components/exchange-create-form/exchange-inbound-item.tsx b/packages/admin-next/dashboard/src/routes/orders/order-create-exchange/components/exchange-create-form/exchange-inbound-item.tsx index 21fe6dc7b6..fd3b6fe230 100644 --- a/packages/admin-next/dashboard/src/routes/orders/order-create-exchange/components/exchange-create-form/exchange-inbound-item.tsx +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-exchange/components/exchange-create-form/exchange-inbound-item.tsx @@ -41,7 +41,7 @@ function ExchangeInboundItem({ return (
-
+
diff --git a/packages/admin-next/dashboard/src/routes/orders/order-create-exchange/components/exchange-create-form/exchange-outbound-item.tsx b/packages/admin-next/dashboard/src/routes/orders/order-create-exchange/components/exchange-create-form/exchange-outbound-item.tsx index d620f3efbe..22939760bd 100644 --- a/packages/admin-next/dashboard/src/routes/orders/order-create-exchange/components/exchange-create-form/exchange-outbound-item.tsx +++ b/packages/admin-next/dashboard/src/routes/orders/order-create-exchange/components/exchange-create-form/exchange-outbound-item.tsx @@ -34,7 +34,7 @@ function ExchangeOutboundItem({ return (
-
+
diff --git a/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-active-edit-section/index.ts b/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-active-edit-section/index.ts new file mode 100644 index 0000000000..2e056d8882 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-active-edit-section/index.ts @@ -0,0 +1 @@ +export * from "./order-active-edit-section" diff --git a/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-active-edit-section/order-active-edit-section.tsx b/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-active-edit-section/order-active-edit-section.tsx new file mode 100644 index 0000000000..76a855cdb3 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-active-edit-section/order-active-edit-section.tsx @@ -0,0 +1,177 @@ +import { Button, Container, Copy, Heading, toast } from "@medusajs/ui" +import { useTranslation } from "react-i18next" +import { ExclamationCircleSolid } from "@medusajs/icons" + +import { useOrderPreview } from "../../../../../hooks/api" +import { + useCancelOrderEdit, + useConfirmOrderEdit, +} from "../../../../../hooks/api/order-edits" +import { useMemo } from "react" +import { HttpTypes } from "@medusajs/types" +import { Thumbnail } from "../../../../../components/common/thumbnail" + +type OrderActiveEditSectionProps = { + order: HttpTypes.AdminOrder + quantity: number +} + +function EditItem({ + item, + quantity, +}: { + item: HttpTypes.AdminOrderLineItem + quantity: number +}) { + return ( +
+
+
+ {quantity}x +
+ + + + {item.title} + + {item.variant_sku && " ยท "} + + {item.variant_sku && ( +
+ {item.variant_sku} + +
+ )} +
+
+ ) +} + +export const OrderActiveEditSection = ({ + order, +}: OrderActiveEditSectionProps) => { + const { t } = useTranslation() + + const { order: orderPreview } = useOrderPreview(order.id) + + const { mutateAsync: cancelOrderEdit } = useCancelOrderEdit(order.id) + const { mutateAsync: confirmOrderEdit } = useConfirmOrderEdit(order.id) + + const [addedItems, removedItems] = useMemo(() => { + const added = [] + const removed = [] + + const orderLookupMap = new Map(order.items!.map((i) => [i.id, i])) + + ;(orderPreview?.items || []).forEach((currentItem) => { + const originalItem = orderLookupMap.get(currentItem.id) + + if (!originalItem) { + added.push({ item: currentItem, quantity: currentItem.quantity }) + return + } + + if (originalItem.quantity > currentItem.quantity) { + removed.push({ + item: currentItem, + quantity: originalItem.quantity - currentItem.quantity, + }) + } + + if (originalItem.quantity < currentItem.quantity) { + added.push({ + item: currentItem, + quantity: currentItem.quantity - originalItem.quantity, + }) + } + }) + + return [added, removed] + }, [orderPreview]) + + const onConfirmOrderEdit = async () => { + try { + await confirmOrderEdit() + + toast.success(t("orders.edits.toast.confirmedSuccessfully")) + } catch (e) { + toast.error(e.message) + } + } + + const onCancelOrderEdit = async () => { + try { + await cancelOrderEdit() + + toast.success(t("orders.edits.toast.canceledSuccessfully")) + } catch (e) { + toast.error(e.message) + } + } + + if (!orderPreview || orderPreview.order_change?.change_type !== "edit") { + return null + } + + return ( +
+ +
+
+ + {t("orders.edits.panel.title")} +
+ + {/*ADDED ITEMS*/} + {!!addedItems.length && ( +
+ {t("labels.added")} + +
+ {addedItems.map(({ item, quantity }) => ( + + ))} +
+
+ )} + + {/*REMOVED ITEMS*/} + {!!removedItems.length && ( +
+ {t("labels.removed")} + +
+ {removedItems.map(({ item, quantity }) => ( + + ))} +
+
+ )} + +
+ + +
+
+
+
+ ) +} 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 eb801e9bf0..865511abcd 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 @@ -9,6 +9,7 @@ import { ArrowUturnLeft, DocumentText, ExclamationCircle, + PencilSquare, } from "@medusajs/icons" import { AdminClaim, @@ -255,11 +256,20 @@ const Header = ({ groups={[ { actions: [ - // { - // label: t("orders.summary.editItems"), - // to: `/orders/${order.id}/edit`, - // icon: , - // }, + { + label: t("orders.summary.editOrder"), + to: `/orders/${order.id}/edits`, + icon: , + disabled: + (orderPreview?.order_change && + orderPreview?.order_change?.change_type !== "edit") || + (orderPreview?.order_change?.change_type === "edit" && + orderPreview?.order_change?.status === "requested"), + }, + ], + }, + { + actions: [ // { // label: t("orders.summary.allocateItems"), // to: "#", // TODO: Open modal to allocate items diff --git a/packages/admin-next/dashboard/src/routes/orders/order-detail/order-detail.tsx b/packages/admin-next/dashboard/src/routes/orders/order-detail/order-detail.tsx index 0630c22f17..38f8a5ecee 100644 --- a/packages/admin-next/dashboard/src/routes/orders/order-detail/order-detail.tsx +++ b/packages/admin-next/dashboard/src/routes/orders/order-detail/order-detail.tsx @@ -15,6 +15,7 @@ import after from "virtual:medusa/widgets/order/details/after" import before from "virtual:medusa/widgets/order/details/before" import sideAfter from "virtual:medusa/widgets/order/details/side/after" import sideBefore from "virtual:medusa/widgets/order/details/side/before" +import { OrderActiveEditSection } from "./components/order-active-edit-section" export const OrderDetail = () => { const initialData = useLoaderData() as Awaited> @@ -50,6 +51,7 @@ export const OrderDetail = () => { })}
+ diff --git a/packages/core/core-flows/src/order/workflows/order-edit/begin-order-edit.ts b/packages/core/core-flows/src/order/workflows/order-edit/begin-order-edit.ts index 00d7d6ce47..c74e9224e5 100644 --- a/packages/core/core-flows/src/order/workflows/order-edit/begin-order-edit.ts +++ b/packages/core/core-flows/src/order/workflows/order-edit/begin-order-edit.ts @@ -13,7 +13,7 @@ import { throwIfOrderIsCancelled } from "../../utils/order-validation" /** * This step validates that an order-edit can be requested for an order. */ -export const beginorderEditValidationStep = createStep( +export const beginOrderEditValidationStep = createStep( "begin-order-edit-validation", async function ({ order }: { order: OrderDTO }) { throwIfOrderIsCancelled({ order }) @@ -37,7 +37,7 @@ export const beginOrderEditOrderWorkflow = createWorkflow( throw_if_key_not_found: true, }) - beginorderEditValidationStep({ order }) + beginOrderEditValidationStep({ order }) const orderChangeInput = transform({ input }, ({ input }) => { return { diff --git a/packages/core/js-sdk/src/admin/index.ts b/packages/core/js-sdk/src/admin/index.ts index 7cede07ac2..25323f6252 100644 --- a/packages/core/js-sdk/src/admin/index.ts +++ b/packages/core/js-sdk/src/admin/index.ts @@ -33,6 +33,7 @@ import { TaxRate } from "./tax-rate" import { TaxRegion } from "./tax-region" import { Upload } from "./upload" import { User } from "./user" +import { OrderEdit } from "./order-edit" export class Admin { public invite: Invite @@ -56,6 +57,7 @@ export class Admin { public inventoryItem: InventoryItem public notification: Notification public order: Order + public orderEdit: OrderEdit public return: Return public claim: Claim public exchange: Exchange @@ -92,6 +94,7 @@ export class Admin { this.inventoryItem = new InventoryItem(client) this.notification = new Notification(client) this.order = new Order(client) + this.orderEdit = new OrderEdit(client) this.return = new Return(client) this.claim = new Claim(client) this.taxRate = new TaxRate(client) diff --git a/packages/core/js-sdk/src/admin/order-edit.ts b/packages/core/js-sdk/src/admin/order-edit.ts new file mode 100644 index 0000000000..d48cc0195f --- /dev/null +++ b/packages/core/js-sdk/src/admin/order-edit.ts @@ -0,0 +1,140 @@ +import { HttpTypes } from "@medusajs/types" +import { Client } from "../client" +import { ClientHeaders } from "../types" + +export class OrderEdit { + private client: Client + constructor(client: Client) { + this.client = client + } + + async initiateRequest( + body: HttpTypes.AdminInitiateOrderEditRequest, + query?: HttpTypes.SelectParams, + headers?: ClientHeaders + ) { + return await this.client.fetch( + `/admin/order-edits`, + { + method: "POST", + headers, + body, + query, + } + ) + } + + async request( + id: string, + query?: HttpTypes.SelectParams, + headers?: ClientHeaders + ) { + return await this.client.fetch( + `/admin/order-edits/${id}/request`, + { + method: "POST", + headers, + query, + } + ) + } + + async confirm( + id: string, + query?: HttpTypes.SelectParams, + headers?: ClientHeaders + ) { + return await this.client.fetch( + `/admin/order-edits/${id}/confirm`, + { + method: "POST", + headers, + query, + } + ) + } + + async cancelRequest( + id: string, + query?: HttpTypes.SelectParams, + headers?: ClientHeaders + ) { + return await this.client.fetch( + `/admin/order-edits/${id}`, + { + method: "DELETE", + headers, + query, + } + ) + } + + async addItems( + id: string, + body: HttpTypes.AdminAddOrderEditItems, + query?: HttpTypes.SelectParams, + headers?: ClientHeaders + ) { + return await this.client.fetch( + `/admin/order-edits/${id}/items`, + { + method: "POST", + headers, + body, + query, + } + ) + } + + async updateOriginalItem( + id: string, + itemId: string, + body: HttpTypes.AdminUpdateOrderEditItem, + query?: HttpTypes.SelectParams, + headers?: ClientHeaders + ) { + return await this.client.fetch( + `/admin/order-edits/${id}/items/item/${itemId}`, + { + method: "POST", + headers, + body, + query, + } + ) + } + + async updateAddedItem( + id: string, + actionId: string, + body: HttpTypes.AdminUpdateOrderEditItem, + query?: HttpTypes.SelectParams, + headers?: ClientHeaders + ) { + return await this.client.fetch( + `/admin/order-edits/${id}/items/${actionId}`, + { + method: "POST", + headers, + body, + query, + } + ) + } + + async removeAddedItem( + id: string, + actionId: string, + query?: HttpTypes.SelectParams, + headers?: ClientHeaders + ) { + return await this.client.fetch( + `/admin/order-edits/${id}/items/${actionId}`, + { + method: "DELETE", + headers, + query, + } + ) + } +} diff --git a/packages/core/js-sdk/src/admin/order.ts b/packages/core/js-sdk/src/admin/order.ts index 2cbed87ee7..b15f2479a5 100644 --- a/packages/core/js-sdk/src/admin/order.ts +++ b/packages/core/js-sdk/src/admin/order.ts @@ -23,7 +23,11 @@ export class Order { ) } - async retrievePreview(id: string, headers?: ClientHeaders) { + async retrievePreview( + id: string, + query?: SelectParams, + headers?: ClientHeaders + ) { return await this.client.fetch( `/admin/orders/${id}/preview`, { diff --git a/packages/core/types/src/http/order-edit/admin/index.ts b/packages/core/types/src/http/order-edit/admin/index.ts index fdedd9d752..6603ce560c 100644 --- a/packages/core/types/src/http/order-edit/admin/index.ts +++ b/packages/core/types/src/http/order-edit/admin/index.ts @@ -1 +1,2 @@ export * from "./responses" +export * from "./payloads" diff --git a/packages/core/types/src/http/order-edit/admin/payloads.ts b/packages/core/types/src/http/order-edit/admin/payloads.ts new file mode 100644 index 0000000000..5242d2e95d --- /dev/null +++ b/packages/core/types/src/http/order-edit/admin/payloads.ts @@ -0,0 +1,22 @@ +export interface AdminInitiateOrderEditRequest { + order_id: string + description?: string + internal_note?: string + metadata?: Record +} + +export interface AdminAddOrderEditItems { + items: { + variant_id: string + quantity: number + unit_price?: number + internal_note?: string + allow_backorder?: boolean + metadata?: Record + }[] +} + +export interface AdminUpdateOrderEditItem { + quantity?: number + internal_note?: string | null +} diff --git a/packages/core/types/src/http/order-edit/admin/responses.ts b/packages/core/types/src/http/order-edit/admin/responses.ts index 1264fc2aa7..b43a735fc3 100644 --- a/packages/core/types/src/http/order-edit/admin/responses.ts +++ b/packages/core/types/src/http/order-edit/admin/responses.ts @@ -7,3 +7,9 @@ export interface AdminOrderEditPreviewResponse { export interface AdminOrderEditResponse { order_change: OrderChangeDTO } + +export interface AdminOrderEditDeleteResponse { + id: string + object: "order-edit" + deleted: true +}