feat(dashboard): Order timeline (#6815)

**What**
- Adds the initial Timeline component.
- Not all events have been added, as it makes sense to add them with their respective RMA flows.
- Emoji picker is omitted from initial PR as it's a nice-to-have that we can add later.
This commit is contained in:
Kasper Fabricius Kristensen
2024-03-25 15:50:27 +01:00
committed by GitHub
parent bacfa3e17b
commit 71efa15088
10 changed files with 645 additions and 2 deletions

View File

@@ -335,6 +335,47 @@
"transferOwnership": "Transfer ownership",
"editBillingAddress": "Edit billing address",
"editShippingAddress": "Edit shipping address"
},
"activity": {
"header": "Activity",
"showMoreActivities_one": "Show {{count}} more activity",
"showMoreActivities_other": "Show {{count}} more activities",
"comment": {
"label": "Comment",
"placeholder": "Leave a comment",
"addButtonText": "Add comment",
"deleteButtonText": "Delete comment"
},
"events": {
"placed": {
"title": "Order placed",
"fromSalesChannel": "from {{salesChannel}}"
},
"canceled": {
"title": "Order canceled"
},
"payment": {
"awaiting": "Awaiting payment",
"captured": "Payment captured",
"canceled": "Payment canceled"
},
"fulfillment": {
"created": "Fulfillment created",
"canceled": "Fulfillment canceled",
"shipped": "Fulfillment shipped",
"itemsFulfilledFrom_one": "{{count}} item fulfilled from {{location}}",
"itemsFulfilledFrom_other": "{{count}} items fulfilled from {{location}}",
"itemsFulfilled_one": "{{count}} item fulfilled",
"itemsFulfilled_other": "{{count}} items fulfilled"
},
"return": {
"created": "Return created"
},
"note": {
"comment": "Comment",
"byLine": "by {{author}}"
}
}
}
},
"draftOrders": {
@@ -814,6 +855,7 @@
"date": "Date",
"order": "Order",
"fulfillment": "Fulfillment",
"provider": "Provider",
"payment": "Payment",
"items": "Items",
"salesChannel": "Sales Channel",

View File

@@ -0,0 +1,46 @@
import { format, formatDistance, sub } from "date-fns"
import { enUS } from "date-fns/locale"
import { useTranslation } from "react-i18next"
import { languages } from "../i18n/config"
export const useDate = () => {
const { i18n } = useTranslation()
const locale =
languages.find((l) => l.code === i18n.language)?.date_locale || enUS
const getFullDate = ({
date,
includeTime = false,
}: {
date: string | Date
includeTime?: boolean
}) => {
const ensuredDate = new Date(date)
if (isNaN(ensuredDate.getTime())) {
return ""
}
const timeFormat = includeTime ? "p" : ""
return format(ensuredDate, `PP ${timeFormat}`, {
locale,
})
}
function getRelativeDate(date: string | Date): string {
const now = new Date()
return formatDistance(sub(new Date(date), { minutes: 0 }), now, {
addSuffix: true,
locale,
})
}
return {
getFullDate,
getRelativeDate,
}
}

View File

@@ -18,7 +18,6 @@ void i18n
escapeValue: false,
},
backend: {
// for all available options read the backend's repository readme file
loadPath: "/locales/{{lng}}/{{ns}}.json",
},
})

View File

@@ -0,0 +1 @@
export * from "./order-activity-section"

View File

@@ -0,0 +1,25 @@
import { Order } from "@medusajs/medusa"
import { Container, Heading } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { OrderNoteForm } from "./order-note-form"
import { OrderTimeline } from "./order-timeline"
type OrderActivityProps = {
order: Order
}
export const OrderActivitySection = ({ order }: OrderActivityProps) => {
const { t } = useTranslation()
return (
<Container className="flex flex-col gap-y-8 px-6 py-4">
<div className="flex flex-col gap-y-4">
<div className="flex items-center justify-between">
<Heading level="h2">{t("orders.activity.header")}</Heading>
</div>
<OrderNoteForm order={order} />
</div>
<OrderTimeline order={order} />
</Container>
)
}

View File

@@ -0,0 +1,112 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { ArrowUpCircleSolid } from "@medusajs/icons"
import { Order } from "@medusajs/medusa"
import { IconButton } from "@medusajs/ui"
import { useAdminCreateNote } from "medusa-react"
import { useRef } from "react"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { useTranslation } from "react-i18next"
import { Form } from "../../../../../components/common/form"
type OrderNoteFormProps = {
order: Order
}
const OrderNoteSchema = z.object({
value: z.string().min(1),
})
export const OrderNoteForm = ({ order }: OrderNoteFormProps) => {
const { t } = useTranslation()
const textareaRef = useRef<HTMLTextAreaElement>(null)
const form = useForm<z.infer<typeof OrderNoteSchema>>({
defaultValues: {
value: "",
},
resolver: zodResolver(OrderNoteSchema),
})
const { mutateAsync, isLoading } = useAdminCreateNote()
const handleSubmit = form.handleSubmit(async (values) => {
mutateAsync(
{
resource_id: order.id,
resource_type: "order",
value: values.value,
},
{
onSuccess: () => {
form.reset()
handleResetSize()
},
}
)
})
const handleResize = () => {
const textarea = textareaRef.current
if (textarea) {
textarea.style.height = "auto"
textarea.style.height = textarea.scrollHeight + "px"
}
}
const handleResetSize = () => {
const textarea = textareaRef.current
if (textarea) {
textarea.style.height = "auto"
}
}
return (
<div>
<Form {...form}>
<form onSubmit={handleSubmit}>
<div className="bg-ui-bg-field shadow-borders-base flex flex-col gap-y-2 rounded-md px-2 py-1.5">
<Form.Field
control={form.control}
name="value"
render={({ field }) => {
return (
<Form.Item>
<Form.Label hidden>
{t("orders.activity.comment.label")}
</Form.Label>
<Form.Control>
<textarea
{...field}
ref={textareaRef}
onInput={handleResize}
className="txt-small text-ui-fg-base placeholder:text-ui-fg-muted resize-none overflow-hidden bg-transparent outline-none"
placeholder={t("orders.activity.comment.placeholder")}
rows={1}
/>
</Form.Control>
</Form.Item>
)
}}
/>
<div className="flex items-center justify-end">
<IconButton
type="submit"
isLoading={isLoading}
variant="transparent"
size="small"
className="text-ui-fg-muted hover:text-ui-fg-subtle active:text-ui-fg-subtle"
>
<span className="sr-only">
{t("orders.activity.comment.addButtonText")}
</span>
<ArrowUpCircleSolid />
</IconButton>
</div>
</div>
</form>
</Form>
</div>
)
}

View File

@@ -0,0 +1,405 @@
import { Fulfillment, Note, Order } from "@medusajs/medusa"
import { IconButton, Text, Tooltip, clx, usePrompt } from "@medusajs/ui"
import * as Collapsible from "@radix-ui/react-collapsible"
import {
useAdminDeleteNote,
useAdminNotes,
useAdminStockLocation,
} from "medusa-react"
import { PropsWithChildren, ReactNode, useMemo, useState } from "react"
import { Link } from "react-router-dom"
import { XMarkMini } from "@medusajs/icons"
import { useTranslation } from "react-i18next"
import { Skeleton } from "../../../../../components/common/skeleton"
import { useDate } from "../../../../../hooks/use-date"
import { getStylizedAmount } from "../../../../../lib/money-amount-helpers"
type OrderTimelineProps = {
order: Order
}
/**
* Arbitrary high limit to ensure all notes are fetched
*/
const NOTE_LIMIT = 9999
export const OrderTimeline = ({ order }: OrderTimelineProps) => {
const items = useActivityItems(order)
if (items.length <= 3) {
return (
<div className="flex flex-col gap-y-0.5">
{items.map((item, index) => {
return (
<OrderActivityItem
key={index}
title={item.title}
timestamp={item.timestamp}
isFirst={index === items.length - 1}
>
{item.children}
</OrderActivityItem>
)
})}
</div>
)
}
const lastItems = items.slice(0, 2)
const collapsibleItems = items.slice(2, items.length - 1)
const firstItem = items[items.length - 1]
return (
<div className="flex flex-col gap-y-0.5">
{lastItems.map((item, index) => {
return (
<OrderActivityItem
key={index}
title={item.title}
timestamp={item.timestamp}
>
{item.children}
</OrderActivityItem>
)
})}
<OrderActivityCollapsible activities={collapsibleItems} />
<OrderActivityItem
title={firstItem.title}
timestamp={firstItem.timestamp}
isFirst
>
{firstItem.children}
</OrderActivityItem>
</div>
)
}
type Activity = {
title: string
timestamp: string | Date
children?: ReactNode
}
const useActivityItems = (order: Order) => {
const { t } = useTranslation()
const { notes, isLoading, isError, error } = useAdminNotes(
{
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 order.payments) {
items.push({
title: t("orders.activity.events.payment.awaiting"),
timestamp: payment.created_at,
children: (
<Text size="small" className="text-ui-fg-subtle">
{getStylizedAmount(payment.amount, payment.currency_code)}
</Text>
),
})
if (payment.canceled_at) {
items.push({
title: t("orders.activity.events.payment.canceled"),
timestamp: payment.canceled_at,
children: (
<Text size="small" className="text-ui-fg-subtle">
{getStylizedAmount(payment.amount, payment.currency_code)}
</Text>
),
})
}
if (payment.captured_at) {
items.push({
title: t("orders.activity.events.payment.captured"),
timestamp: payment.captured_at,
children: (
<Text size="small" className="text-ui-fg-subtle">
{getStylizedAmount(payment.amount, payment.currency_code)}
</Text>
),
})
}
}
for (const fulfillment of order.fulfillments) {
items.push({
title: t("orders.activity.events.fulfillment.created"),
timestamp: fulfillment.created_at,
children: <FulfillmentCreatedBody fulfillment={fulfillment} />,
})
if (fulfillment.shipped_at) {
items.push({
title: t("orders.activity.events.fulfillment.shipped"),
timestamp: fulfillment.shipped_at,
})
}
}
for (const ret of order.returns) {
items.push({
title: t("orders.activity.events.return.created"),
timestamp: ret.created_at,
})
}
for (const note of notes || []) {
items.push({
title: t("orders.activity.events.note.comment"),
timestamp: note.created_at,
children: <NoteBody note={note} />,
})
}
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).getTime() - new Date(a.timestamp).getTime()
})
const createdAt = {
title: t("orders.activity.events.placed.title"),
timestamp: order.created_at,
children: (
<Text size="small" className="text-ui-fg-subtle">
{t("orders.activity.events.placed.fromSalesChannel", {
salesChannel: order.sales_channel.name,
})}
</Text>
),
}
return [...sortedActivities, createdAt]
}, [order, notes, isLoading, t])
}
type OrderActivityItemProps = PropsWithChildren<{
title: string
timestamp: string | Date
isFirst?: boolean
}>
const OrderActivityItem = ({
title,
timestamp,
isFirst = false,
children,
}: OrderActivityItemProps) => {
const { getFullDate, getRelativeDate } = useDate()
return (
<div className="grid grid-cols-[20px_1fr] items-start gap-2">
<div className="flex size-full flex-col items-center gap-y-0.5">
<div className="flex size-5 items-center justify-center">
<div className="bg-ui-bg-base shadow-borders-base flex size-2.5 items-center justify-center rounded-full">
<div className="bg-ui-tag-neutral-icon size-1.5 rounded-full" />
</div>
</div>
{!isFirst && <div className="bg-ui-border-base w-px flex-1" />}
</div>
<div
className={clx({
"pb-4": !isFirst,
})}
>
<div className="flex items-center justify-between">
<Text size="small" leading="compact" weight="plus">
{title}
</Text>
<Tooltip
content={getFullDate({ date: timestamp, includeTime: true })}
>
<Text size="small" leading="compact" className="text-ui-fg-subtle">
{getRelativeDate(timestamp)}
</Text>
</Tooltip>
</div>
<div>{children}</div>
</div>
</div>
)
}
const OrderActivityCollapsible = ({
activities,
}: {
activities: Activity[]
}) => {
const [open, setOpen] = useState(false)
const { t } = useTranslation()
if (!activities.length) {
return null
}
return (
<Collapsible.Root open={open} onOpenChange={setOpen}>
{!open && (
<div className="grid grid-cols-[20px_1fr] items-start gap-2">
<div className="flex size-full flex-col items-center">
<div className="border-ui-border-strong w-px flex-1 bg-[linear-gradient(var(--border-strong)_33%,rgba(255,255,255,0)_0%)] bg-[length:1px_3px] bg-right bg-repeat-y" />
</div>
<div className="pb-4">
<Collapsible.Trigger className="text-left">
<Text
size="small"
leading="compact"
weight="plus"
className="text-ui-fg-muted"
>
{t("orders.activity.showMoreActivities", {
count: activities.length,
})}
</Text>
</Collapsible.Trigger>
</div>
</div>
)}
<Collapsible.Content>
<div className="flex flex-col gap-y-0.5">
{activities.map((item, index) => {
return (
<OrderActivityItem
key={index}
title={item.title}
timestamp={item.timestamp}
>
{item.children}
</OrderActivityItem>
)
})}
</div>
</Collapsible.Content>
</Collapsible.Root>
)
}
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 (
<div className="flex flex-col gap-y-2 pt-2">
<div className="bg-ui-bg-component shadow-borders-base group grid grid-cols-[1fr_20px] items-start gap-x-2 text-pretty rounded-r-2xl rounded-bl-md rounded-tl-xl px-3 py-1.5">
<div className="flex h-full min-h-7 items-center">
<Text size="xsmall" className="text-ui-fg-subtle">
{note.value}
</Text>
</div>
<IconButton
size="small"
variant="transparent"
className="transition-fg invisible opacity-0 group-hover:visible group-hover:opacity-100"
type="button"
onClick={handleDelete}
>
<span className="sr-only">
{t("orders.activity.comment.deleteButtonText")}
</span>
<XMarkMini className="text-ui-fg-muted" />
</IconButton>
</div>
<Link
to={`/settings/users/${note.author_id}`}
className="text-ui-fg-subtle hover:text-ui-fg-base transition-fg w-fit"
>
<Text size="small">{byLine}</Text>
</Link>
</div>
)
}
const FulfillmentCreatedBody = ({
fulfillment,
}: {
fulfillment: Fulfillment
}) => {
const { t } = useTranslation()
const { stock_location, isLoading, isError, error } = useAdminStockLocation(
fulfillment.location_id!,
{
enabled: !!fulfillment.location_id,
}
)
const numberOfItems = fulfillment.items.reduce((acc, item) => {
return acc + item.quantity
}, 0)
const triggerText = stock_location
? t("orders.activity.events.fulfillment.itemsFulfilledFrom", {
count: numberOfItems,
location: stock_location.name,
})
: t("orders.activity.events.fulfillment.itemsFulfilled", {
count: numberOfItems,
})
if (isError) {
throw error
}
return (
<div>
{isLoading ? (
<Skeleton className="h-7 w-full" />
) : (
<Text size="small" className="text-ui-fg-subtle">
{triggerText}
</Text>
)}
</div>
)
}

View File

@@ -20,6 +20,7 @@ import { Link } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { Skeleton } from "../../../../../components/common/skeleton"
import { Thumbnail } from "../../../../../components/common/thumbnail"
import { formatProvider } from "../../../../../lib/format-provider"
import { getLocaleAmount } from "../../../../../lib/money-amount-helpers"
type OrderFulfillmentSectionProps = {
@@ -265,6 +266,15 @@ const Fulfillment = ({
)}
</div>
)}
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("fields.provider")}
</Text>
<Text size="small" leading="compact">
{formatProvider(fulfillment.provider_id)}
</Text>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("orders.fulfillment.trackingLabel")}

View File

@@ -1,2 +1,2 @@
export const orderExpand =
"items,items.variant,items.variant.options,sales_channel,shipping_methods,shipping_methods.shipping_option,discounts,payments,customer,shipping_address,shipping_address.country,billing_address,billing_address.country,fulfillments,fulfillments.items,fulfillments.items.item,fulfillments.tracking_links,refunds,edits,edits.items,edits.items.variant,edits.items.variant.product"
"items,items.variant,items.variant.options,sales_channel,shipping_methods,shipping_methods.shipping_option,discounts,payments,customer,shipping_address,shipping_address.country,billing_address,billing_address.country,fulfillments,fulfillments.items,fulfillments.items.item,fulfillments.tracking_links,refunds,edits,edits.items,edits.items.variant,edits.items.variant.product,returns"

View File

@@ -1,6 +1,7 @@
import { useAdminOrder } from "medusa-react"
import { Outlet, useLoaderData, useParams } from "react-router-dom"
import { JsonViewSection } from "../../../components/common/json-view-section"
import { OrderActivitySection } from "./components/order-activity-section"
import { OrderCustomerSection } from "./components/order-customer-section"
import { OrderFulfillmentSection } from "./components/order-fulfillment-section"
import { OrderGeneralSection } from "./components/order-general-section"
@@ -41,11 +42,13 @@ export const OrderDetail = () => {
<OrderFulfillmentSection order={order} />
<div className="flex flex-col gap-y-2 xl:hidden">
<OrderCustomerSection order={order} />
<OrderActivitySection order={order} />
</div>
<JsonViewSection data={order} />
</div>
<div className="hidden w-full max-w-[400px] flex-col gap-y-2 xl:flex">
<OrderCustomerSection order={order} />
<OrderActivitySection order={order} />
</div>
<Outlet />
</div>