import { Button, Text, Tooltip, clx, toast, usePrompt } from "@medusajs/ui"
import { Collapsible as RadixCollapsible } from "radix-ui"
import { PropsWithChildren, ReactNode, useMemo, useState } from "react"
import {
AdminClaim,
AdminExchange,
AdminOrder,
AdminOrderAddress,
AdminOrderChange,
AdminOrderItem,
AdminReturn,
} from "@medusajs/types"
import { useTranslation } from "react-i18next"
import { AdminOrderLineItem } from "@medusajs/types"
import { By } from "../../../../../components/common/user-link"
import {
useCancelOrderTransfer,
useCustomer,
useOrder,
useOrderChanges,
useOrderLineItems,
} from "../../../../../hooks/api"
import { useCancelClaim, useClaims } from "../../../../../hooks/api/claims"
import {
useCancelExchange,
useExchanges,
} from "../../../../../hooks/api/exchanges"
import { useCancelReturn, useReturns } from "../../../../../hooks/api/returns"
import { useDate } from "../../../../../hooks/use-date"
import { getFormattedAddress } from "../../../../../lib/addresses"
import { getStylizedAmount } from "../../../../../lib/money-amount-helpers"
import { getPaymentsFromOrder } from "../../../../../lib/orders"
import ActivityItems from "./activity-items"
import ChangeDetailsTooltip from "./change-details-tooltip"
import { Thumbnail } from "../../../../../components/common/thumbnail"
import { ExtendedOrderFulfillment, ExtendedOrder } from "../../constants"
type OrderTimelineProps = {
order: ExtendedOrder
}
/**
* Arbitrary high limit to ensure all notes are fetched
*/
// const NOTE_LIMIT = 9999
/**
* Order Changes that are not related to RMA flows
*/
const NON_RMA_CHANGE_TYPES = ["transfer", "update_order"]
export const OrderTimeline = ({ order }: OrderTimelineProps) => {
const items = useActivityItems(order)
if (items.length <= 3) {
return (
{items.map((item, index) => {
return (
{item.children}
)
})}
)
}
const lastItems = items.slice(0, 2)
const collapsibleItems = items.slice(2, items.length - 1)
const firstItem = items[items.length - 1]
return (
{lastItems.map((item, index) => {
return (
{item.children}
)
})}
{firstItem.children}
)
}
type Activity = {
title: ReactNode
timestamp: string | Date | null
children?: ReactNode
itemsToSend?:
| AdminClaim["additional_items"]
| AdminExchange["additional_items"]
itemsToReturn?: AdminReturn["items"]
itemsMap?: Map
}
const useActivityItems = (order: ExtendedOrder): Activity[] => {
const { t } = useTranslation()
const { order: initialOrder = order } = useOrder(
order.id,
{
version: 1,
fields: "created_at,total,currency_code",
},
{ enabled: order.version !== 1 }
)
const { order_changes: orderChanges = [] } = useOrderChanges(order.id, {
change_type: [
"edit",
"claim",
"exchange",
"return",
"transfer",
"update_order",
],
})
const rmaChanges = orderChanges.filter(
(oc) => !NON_RMA_CHANGE_TYPES.includes(oc.change_type!)
)
const missingLineItemIds = getMissingLineItemIds(order, rmaChanges)
const { order_items: removedLineItems = [] } = useOrderLineItems(
order.id,
{
fields: "+quantity",
item_id: missingLineItemIds,
},
{
enabled: !!rmaChanges.length,
}
) as { order_items: (AdminOrderItem & { quantity: number })[] | undefined }
const itemsMap = useMemo(() => {
const _itemsMap = new Map(order?.items?.map((i) => [i.id, i]))
for (const id of missingLineItemIds) {
const i = removedLineItems.find((i) => i.item.id === id)
if (i) {
_itemsMap.set(id, { ...i.item, quantity: i.quantity }) // copy quantity from OrderItem to OrderLineItem
}
}
return _itemsMap
}, [order.items, removedLineItems, missingLineItemIds])
const { returns = [] } = useReturns({
order_id: order.id,
fields: "+received_at,*items",
})
const { claims = [] } = useClaims({
order_id: order.id,
fields: "*additional_items",
})
const { exchanges = [] } = useExchanges({
order_id: order.id,
fields: "*additional_items",
})
const payments = getPaymentsFromOrder(order)
// This isn't set anywhere
const notes: any[] = []
const isLoading = false
// const { notes, isLoading, isError, error } = useNotes(
// {
// resource_id: order.id,
// limit: NOTE_LIMIT,
// offset: 0,
// },
// {
// keepPreviousData: true,
// }
// )
//
// if (isError) {
// throw error
// }
return useMemo(() => {
if (isLoading) {
return []
}
const items: Activity[] = []
for (const payment of payments) {
const amount = payment.amount as number
items.push({
title: t("orders.activity.events.payment.awaiting"),
timestamp: payment.created_at!,
children: (
{getStylizedAmount(amount, payment.currency_code)}
),
})
if (payment.canceled_at) {
items.push({
title: t("orders.activity.events.payment.canceled"),
timestamp: payment.canceled_at,
children: (
{getStylizedAmount(amount, payment.currency_code)}
),
})
}
if (payment.captured_at) {
items.push({
title: t("orders.activity.events.payment.captured"),
timestamp: payment.captured_at,
children: (
{getStylizedAmount(amount, payment.currency_code)}
),
})
}
for (const refund of payment.refunds || []) {
items.push({
title: t("orders.activity.events.payment.refunded"),
timestamp: refund.created_at,
children: (
{getStylizedAmount(
refund.amount as number,
payment.currency_code
)}
{refund.created_by && (
{t("fields.by")}
)}
),
})
}
}
for (const fulfillment of order.fulfillments || []) {
items.push({
title: t("orders.activity.events.fulfillment.created"),
timestamp: fulfillment.created_at,
children: (
{fulfillment.created_by && (
{t("fields.by")}
)}
),
})
if (fulfillment.delivered_at) {
items.push({
title: t("orders.activity.events.fulfillment.delivered"),
timestamp: fulfillment.delivered_at,
children: ,
})
}
if (fulfillment.shipped_at) {
items.push({
title: t("orders.activity.events.fulfillment.shipped"),
timestamp: fulfillment.shipped_at,
children: (
{fulfillment.marked_shipped_by && (
{t("fields.by")}
)}
),
})
}
if (fulfillment.canceled_at) {
items.push({
title: t("orders.activity.events.fulfillment.canceled"),
timestamp: fulfillment.canceled_at,
})
}
}
const returnMap = new Map()
for (const ret of returns) {
returnMap.set(ret.id, ret)
if (ret.claim_id || ret.exchange_id) {
continue
}
// Always display created action
items.push({
title: t("orders.activity.events.return.created", {
returnId: ret.id.slice(-7),
}),
timestamp: ret.created_at,
itemsToReturn: ret?.items,
itemsMap,
children: (
{ret.created_by && !ret.canceled_at && (
{t("fields.by")}
)}
),
})
if (ret.canceled_at) {
items.push({
title: t("orders.activity.events.return.canceled", {
returnId: ret.id.slice(-7),
}),
timestamp: ret.canceled_at,
})
}
if (ret.status === "received" || ret.status === "partially_received") {
items.push({
title: t("orders.activity.events.return.received", {
returnId: ret.id.slice(-7),
}),
timestamp: ret.received_at,
itemsToReturn: ret?.items,
itemsMap,
children: (
),
})
}
}
for (const claim of claims) {
const claimReturn = returnMap.get(claim.return_id!)
items.push({
title: t(
claim.canceled_at
? "orders.activity.events.claim.canceled"
: "orders.activity.events.claim.created",
{
claimId: claim.id.slice(-7),
}
),
timestamp: claim.canceled_at || claim.created_at,
itemsToSend: claim.additional_items,
itemsToReturn: claimReturn?.items,
itemsMap,
children: (
{claim.created_by && !claim.canceled_at && (
{t("fields.by")}
)}
),
})
}
for (const exchange of exchanges) {
const exchangeReturn = returnMap.get(exchange.return_id!)
items.push({
title: t(
exchange.canceled_at
? "orders.activity.events.exchange.canceled"
: "orders.activity.events.exchange.created",
{
exchangeId: exchange.id.slice(-7),
}
),
timestamp: exchange.canceled_at || exchange.created_at,
itemsToSend: exchange.additional_items,
itemsToReturn: exchangeReturn?.items,
itemsMap,
children: (
{exchange.created_by && !exchange.canceled_at && (
{t("fields.by")}
)}
),
})
}
for (const edit of orderChanges.filter((oc) => oc.change_type === "edit")) {
const isConfirmed = edit.status === "confirmed"
const isPending = edit.status === "pending"
if (isPending) {
continue
}
items.push({
title: t(`orders.activity.events.edit.${edit.status}` as any, {
editId: edit.id.slice(-7),
}),
timestamp:
edit.status === "requested"
? edit.requested_at
: edit.status === "confirmed"
? edit.confirmed_at
: edit.status === "declined"
? edit.declined_at
: edit.status === "canceled"
? edit.canceled_at
: edit.created_at,
children: isConfirmed ? : null,
})
}
for (const transfer of orderChanges.filter(
(oc) => oc.change_type === "transfer"
)) {
if (transfer.requested_at) {
items.push({
title: t(`orders.activity.events.transfer.requested`, {
transferId: transfer.id.slice(-7),
}),
timestamp: transfer.requested_at,
children: ,
})
}
if (transfer.confirmed_at) {
items.push({
title: t(`orders.activity.events.transfer.confirmed`, {
transferId: transfer.id.slice(-7),
}),
timestamp: transfer.confirmed_at,
})
}
if (transfer.declined_at) {
items.push({
title: t(`orders.activity.events.transfer.declined`, {
transferId: transfer.id.slice(-7),
}),
timestamp: transfer.declined_at,
})
}
}
for (const update of orderChanges.filter(
(oc) => oc.change_type === "update_order"
)) {
const updateType = update.actions[0]?.details?.type
if (updateType === "shipping_address") {
items.push({
title: (
{getFormattedAddress({
address: update.actions[0].details
?.old as AdminOrderAddress,
}).join(", ")}
}
next={
{getFormattedAddress({
address: update.actions[0].details
?.new as AdminOrderAddress,
}).join(", ")}
}
/>
),
timestamp: update.created_at,
children: (
{t("fields.by")}
),
})
}
if (updateType === "billing_address") {
items.push({
title: (
{getFormattedAddress({
address: update.actions[0].details
?.old as AdminOrderAddress,
}).join(", ")}
}
next={
{getFormattedAddress({
address: update.actions[0].details
?.new as AdminOrderAddress,
}).join(", ")}
}
/>
),
timestamp: update.created_at,
children: (
{t("fields.by")}
),
})
}
if (updateType === "email") {
items.push({
title: (
{update.actions[0].details?.old as string}
}
next={
{update.actions[0].details?.new as string}
}
/>
),
timestamp: update.created_at,
children: (
{t("fields.by")}
),
})
}
}
// for (const note of notes || []) {
// items.push({
// title: t("orders.activity.events.note.comment"),
// timestamp: note.created_at,
// children: ,
// })
// }
if (order.canceled_at) {
items.push({
title: t("orders.activity.events.canceled.title"),
timestamp: order.canceled_at,
})
}
const sortedActivities = items.sort((a, b) => {
return (
new Date(b.timestamp as string | Date).getTime() -
new Date(a.timestamp as string | Date).getTime()
)
})
if (initialOrder.created_at) {
const createdAt = {
title: t("orders.activity.events.placed.title"),
timestamp: initialOrder.created_at,
children: (
{getStylizedAmount(initialOrder.total, initialOrder.currency_code)}
),
}
sortedActivities.push(createdAt)
}
return [...sortedActivities]
}, [
order,
initialOrder,
payments,
returns,
exchanges,
orderChanges,
notes,
isLoading,
itemsMap,
])
}
type OrderActivityItemProps = PropsWithChildren<{
title: ReactNode
timestamp: string | Date | null
isFirst?: boolean
itemsToSend?:
| AdminClaim["additional_items"]
| AdminExchange["additional_items"]
itemsToReturn?: AdminReturn["items"]
itemsMap?: Map
}>
const OrderActivityItem = ({
title,
timestamp,
isFirst = false,
children,
itemsToSend,
itemsToReturn,
itemsMap,
}: OrderActivityItemProps) => {
const { getFullDate, getRelativeDate } = useDate()
return (
{itemsToSend?.length || itemsToReturn?.length ? (
) : (
{title}
)}
{timestamp && (
{getRelativeDate(timestamp)}
)}
{children}
)
}
const OrderActivityCollapsible = ({
activities,
}: {
activities: Activity[]
}) => {
const [open, setOpen] = useState(false)
const { t } = useTranslation()
if (!activities.length) {
return null
}
return (
{!open && (
{t("orders.activity.showMoreActivities", {
count: activities.length,
})}
)}
{activities.map((item, index) => {
return (
{item.children}
)
})}
)
}
/**
* TODO: Add once notes are supported.
*/
// const NoteBody = ({ note }: { note: Note }) => {
// const { t } = useTranslation()
// const prompt = usePrompt()
// const { first_name, last_name, email } = note.author || {}
// const name = [first_name, last_name].filter(Boolean).join(" ")
// const byLine = t("orders.activity.events.note.byLine", {
// author: name || email,
// })
// const { mutateAsync } = {} // useAdminDeleteNote(note.id)
// const handleDelete = async () => {
// const res = await prompt({
// title: t("general.areYouSure"),
// description: "This action cannot be undone",
// confirmText: t("actions.delete"),
// cancelText: t("actions.cancel"),
// })
// if (!res) {
// return
// }
// await mutateAsync()
// }
// return (
//
//
//
//
// {note.value}
//
//
//
//
// {t("orders.activity.comment.deleteButtonText")}
//
//
//
//
//
//
{byLine}
//
//
// )
// }
const FulfillmentCreatedBody = ({
fulfillment,
}: {
fulfillment: ExtendedOrderFulfillment
}) => {
const { t } = useTranslation()
const numberOfItems = fulfillment.items.reduce((acc, item) => {
return acc + item.quantity
}, 0)
return (
{t("orders.activity.events.fulfillment.items", {
count: numberOfItems,
})}
)
}
const ReturnBody = ({
orderReturn,
isCreated,
isReceived,
}: {
orderReturn: AdminReturn
isCreated: boolean
isReceived?: boolean
}) => {
const prompt = usePrompt()
const { t } = useTranslation()
const { mutateAsync: cancelReturnRequest } = useCancelReturn(
orderReturn.id,
orderReturn.order_id
)
const onCancel = async () => {
const res = await prompt({
title: t("orders.returns.cancel.title"),
description: t("orders.returns.cancel.description"),
confirmText: t("actions.confirm"),
cancelText: t("actions.cancel"),
})
if (!res) {
return
}
await cancelReturnRequest().catch((error) => {
toast.error(error.message)
})
}
const numberOfItems = orderReturn.items.reduce((acc, item) => {
return acc + (isReceived ? item.received_quantity : item.quantity) // TODO: revisit when we add dismissed quantity on ReturnItem
}, 0)
return (
{t("orders.activity.events.return.items", {
count: numberOfItems,
})}
{isCreated && (
<>
⋅
>
)}
)
}
const ClaimBody = ({
claim,
claimReturn,
}: {
claim: AdminClaim
claimReturn?: AdminReturn
}) => {
const prompt = usePrompt()
const { t } = useTranslation()
const isCanceled = !!claim.created_at
const { mutateAsync: cancelClaim } = useCancelClaim(claim.id, claim.order_id)
const onCancel = async () => {
const res = await prompt({
title: t("orders.claims.cancel.title"),
description: t("orders.claims.cancel.description"),
confirmText: t("actions.confirm"),
cancelText: t("actions.cancel"),
})
if (!res) {
return
}
await cancelClaim().catch((error) => {
toast.error(error.message)
})
}
const outboundItems = (claim.additional_items || []).reduce(
(acc, item) => (acc + item.quantity) as number,
0
)
const inboundItems = (claimReturn?.items || []).reduce(
(acc, item) => acc + item.quantity,
0
)
return (
{outboundItems > 0 && (
{t("orders.activity.events.claim.itemsInbound", {
count: outboundItems,
})}
)}
{inboundItems > 0 && (
{t("orders.activity.events.claim.itemsOutbound", {
count: inboundItems,
})}
)}
{!isCanceled && (
)}
)
}
const ExchangeBody = ({
exchange,
exchangeReturn,
}: {
exchange: AdminExchange
exchangeReturn?: AdminReturn
}) => {
const prompt = usePrompt()
const { t } = useTranslation()
const isCanceled = !!exchange.canceled_at
const { mutateAsync: cancelExchange } = useCancelExchange(
exchange.id,
exchange.order_id
)
const onCancel = async () => {
const res = await prompt({
title: t("orders.exchanges.cancel.title"),
description: t("orders.exchanges.cancel.description"),
confirmText: t("actions.confirm"),
cancelText: t("actions.cancel"),
})
if (!res) {
return
}
await cancelExchange().catch((error) => {
toast.error(error.message)
})
}
const outboundItems = (exchange.additional_items || []).reduce(
(acc, item) => (acc + item.quantity) as number,
0
)
const inboundItems = (exchangeReturn?.items || []).reduce(
(acc, item) => acc + item.quantity,
0
)
return (
{outboundItems > 0 && (
{t("orders.activity.events.exchange.itemsInbound", {
count: outboundItems,
})}
)}
{inboundItems > 0 && (
{t("orders.activity.events.exchange.itemsOutbound", {
count: inboundItems,
})}
)}
{!isCanceled && (
)}
)
}
const OrderEditBody = ({ edit }: { edit: AdminOrderChange }) => {
const { t } = useTranslation()
const isConfirmed = edit.status === "confirmed"
const isDeclined = edit.status === "declined"
const isCanceled = edit.status === "canceled"
const userId = isConfirmed
? edit.confirmed_by
: isDeclined
? edit.declined_by
: isCanceled
? edit.canceled_by
: edit.requested_by
const [itemsAdded, itemsRemoved] = useMemo(
() => countItemsChange(edit.actions),
[edit]
)
const ItemChangeDetails = ({
itemChange,
version,
}: {
itemChange: ItemChange
version: number
}) => {
// We need to know items that were completely removed, not just decreasing in quantity, since to get the item information for them
// we need to query the line items for the previous version, since the current order change version doesn't contain them
const deletedItemIdsSet = new Set(
itemChange.detail
.filter((item) => item.isDeleted)
.map((item) => item.lineItemId)
)
const isAllDeleted = deletedItemIdsSet.size === itemChange.detail.length
const isSomeDeleted = deletedItemIdsSet.size > 0
const { isLoading: isLoadingDeleted, order_items: deletedLineItems = [] } =
useOrderLineItems(
edit.order_id,
{
item_id: Array.from(deletedItemIdsSet),
version: version - 1,
},
{
enabled: isSomeDeleted,
}
)
const { isLoading, order_items: nonDeletedLineItems = [] } =
useOrderLineItems(
edit.order_id,
{
item_id: itemChange.detail
.filter((item) => !deletedItemIdsSet.has(item.lineItemId))
.map((item) => item.lineItemId),
version,
},
{
enabled: !isAllDeleted,
}
)
const lineItems = [...deletedLineItems, ...nonDeletedLineItems]
const label =
itemChange.type === "added" ? t("labels.added") : t("labels.removed")
if (isLoading || isLoadingDeleted) {
return (
{t("labels.loading")}
)
}
return (
{lineItems.map((orderItem) => {
const itemChangeItem = itemChange.detail.find(
(item) => item.lineItemId === orderItem.item_id
)
if (!itemChangeItem) {
return null
}
const lineItem = orderItem.item
return (
{lineItem.title}
{lineItem.variant_sku && (
{lineItem.variant_sku}
)}
{`${label}: ${itemChangeItem.count}`}
)
})}
)
}
return (
{itemsAdded.totalCount > 0 && (
}
/>
)}
{itemsRemoved.totalCount > 0 && (
}
/>
)}
{edit.internal_note && (
{edit.internal_note}
)}
{userId && (
{t("fields.by")}
)}
)
}
const TransferOrderRequestBody = ({
transfer,
}: {
transfer: AdminOrderChange
}) => {
const prompt = usePrompt()
const { t } = useTranslation()
const action = transfer.actions[0]
const { customer } = useCustomer(action.reference_id ?? "")
const isCompleted = !!transfer.confirmed_at
const { mutateAsync: cancelTransfer } = useCancelOrderTransfer(
transfer.order_id
)
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("actions.cannotUndo"),
confirmText: t("actions.delete"),
cancelText: t("actions.cancel"),
})
if (!res) {
return
}
await cancelTransfer().catch((error) => {
toast.error(error.message)
})
}
/**
* TODO: change original_email to customer info when action details is changed
*/
return (
{t("orders.activity.from")}:{" "}
{action.details?.original_email as string | undefined}
{t("orders.activity.to")}:{" "}
{customer?.first_name
? `${customer?.first_name} ${customer?.last_name}`
: customer?.email}
{!isCompleted && (
)}
)
}
type ItemChange = {
totalCount: number
type: "added" | "removed"
detail: {
lineItemId: string
count: number
isDeleted?: boolean
}[]
}
/**
* Returns count of added and removed item quantity
*/
function countItemsChange(
actions: AdminOrderChange["actions"]
): [ItemChange, ItemChange] {
const addedChange: ItemChange = {
totalCount: 0,
detail: [],
type: "added",
}
const removedChange: ItemChange = {
totalCount: 0,
detail: [],
type: "removed",
}
actions.forEach((action) => {
if (action.action === "ITEM_ADD") {
addedChange.totalCount += action.details!.quantity as number
addedChange.detail.push({
lineItemId: action.details!.reference_id as string,
count: action.details!.quantity as number,
})
}
if (action.action === "ITEM_UPDATE") {
const quantityDiff = action.details!.quantity_diff as number
if (quantityDiff > 0) {
addedChange.totalCount += quantityDiff
addedChange.detail.push({
lineItemId: action.details!.reference_id as string,
count: quantityDiff,
})
} else {
removedChange.totalCount += Math.abs(quantityDiff)
removedChange.detail.push({
lineItemId: action.details!.reference_id as string,
count: Math.abs(quantityDiff),
isDeleted: action.details!.quantity === 0,
})
}
}
})
return [addedChange, removedChange]
}
/**
* Get IDs of missing line items that were removed from the order.
*/
function getMissingLineItemIds(order: AdminOrder, changes: AdminOrderChange[]) {
if (!changes?.length) {
return []
}
const retIds = new Set()
const existingItemsMap = new Map(order.items.map((item) => [item.id, true]))
changes.forEach((change) => {
change.actions.forEach((action) => {
if (!action.details?.reference_id) {
return
}
if (
(action.details.reference_id as string).startsWith("ordli_") &&
!existingItemsMap.has(action.details.reference_id as string)
) {
retIds.add(action.details.reference_id as string)
}
})
})
return Array.from(retIds)
}