Pārlūkot izejas kodu

feat(dashboard): Add support for custom history entries (#3831)

Michael Bromley 3 mēneši atpakaļ
vecāks
revīzija
1db50a6f48
37 mainītis faili ar 1583 papildinājumiem un 694 dzēšanām
  1. 8 8
      docs/docs/reference/dashboard/components/asset-gallery.md
  2. 4 3
      docs/docs/reference/dashboard/components/detail-page-button.md
  3. 10 2
      docs/docs/reference/dashboard/extensions-api/define-dashboard-extension.md
  4. 250 0
      docs/docs/reference/dashboard/extensions-api/history-entries.md
  5. 5 2
      docs/docs/reference/dashboard/list-views/list-page.md
  6. 2 2
      docs/docs/reference/dashboard/page-layout/index.md
  7. 3 3
      docs/docs/reference/dashboard/page-layout/page-action-bar.md
  8. 4 4
      docs/docs/reference/dashboard/page-layout/page-block.md
  9. 1 1
      docs/docs/reference/dashboard/page-layout/page-title.md
  10. 2 2
      docs/docs/reference/dashboard/page-layout/page.md
  11. 2 2
      packages/dashboard/src/app/routes/_authenticated/_customers/components/customer-history/customer-history-container.tsx
  12. 5 0
      packages/dashboard/src/app/routes/_authenticated/_customers/components/customer-history/customer-history-types.ts
  13. 124 0
      packages/dashboard/src/app/routes/_authenticated/_customers/components/customer-history/customer-history-utils.tsx
  14. 91 59
      packages/dashboard/src/app/routes/_authenticated/_customers/components/customer-history/customer-history.tsx
  15. 176 0
      packages/dashboard/src/app/routes/_authenticated/_customers/components/customer-history/default-customer-history-components.tsx
  16. 4 2
      packages/dashboard/src/app/routes/_authenticated/_customers/components/customer-history/index.ts
  17. 2 0
      packages/dashboard/src/app/routes/_authenticated/_customers/customers.graphql.ts
  18. 98 0
      packages/dashboard/src/app/routes/_authenticated/_orders/components/order-history/default-order-history-components.tsx
  19. 9 7
      packages/dashboard/src/app/routes/_authenticated/_orders/components/order-history/order-history-container.tsx
  20. 5 0
      packages/dashboard/src/app/routes/_authenticated/_orders/components/order-history/order-history-types.ts
  21. 173 0
      packages/dashboard/src/app/routes/_authenticated/_orders/components/order-history/order-history-utils.tsx
  22. 64 408
      packages/dashboard/src/app/routes/_authenticated/_orders/components/order-history/order-history.tsx
  23. 4 0
      packages/dashboard/src/app/routes/_authenticated/_orders/orders.graphql.ts
  24. 0 188
      packages/dashboard/src/lib/components/shared/history-timeline/history-entry.tsx
  25. 65 0
      packages/dashboard/src/lib/components/shared/history-timeline/history-note-entry.tsx
  26. 141 0
      packages/dashboard/src/lib/components/shared/history-timeline/history-timeline-with-grouping.tsx
  27. 26 0
      packages/dashboard/src/lib/components/shared/history-timeline/use-history-note-editor.ts
  28. 5 0
      packages/dashboard/src/lib/framework/extension-api/define-dashboard-extension.ts
  29. 7 0
      packages/dashboard/src/lib/framework/extension-api/extension-api-types.ts
  30. 24 0
      packages/dashboard/src/lib/framework/extension-api/logic/history-entries.ts
  31. 1 0
      packages/dashboard/src/lib/framework/extension-api/logic/index.ts
  32. 120 0
      packages/dashboard/src/lib/framework/extension-api/types/history-entries.ts
  33. 1 0
      packages/dashboard/src/lib/framework/extension-api/types/index.ts
  34. 11 0
      packages/dashboard/src/lib/framework/history-entry/history-entry-extensions.ts
  35. 129 0
      packages/dashboard/src/lib/framework/history-entry/history-entry.tsx
  36. 2 0
      packages/dashboard/src/lib/framework/registry/registry-types.ts
  37. 5 1
      packages/dashboard/src/lib/index.ts

+ 8 - 8
docs/docs/reference/dashboard/components/asset-gallery.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## AssetGallery
 
-<GenerationInfo sourceFile="packages/dashboard/src/lib/components/shared/asset/asset-gallery.tsx" sourceLine="153" packageName="@vendure/dashboard" />
+<GenerationInfo sourceFile="packages/dashboard/src/lib/components/shared/asset/asset-gallery.tsx" sourceLine="155" packageName="@vendure/dashboard" />
 
 A component for displaying a gallery of assets.
 
@@ -19,12 +19,12 @@ A component for displaying a gallery of assets.
 
 ```tsx
  <AssetGallery
-     onSelect={handleAssetSelect}
-     multiSelect="manual"
-     initialSelectedAssets={initialSelectedAssets}
-     fixedHeight={false}
-     displayBulkActions={false}
- />
+onSelect={handleAssetSelect}
+multiSelect="manual"
+initialSelectedAssets={initialSelectedAssets}
+fixedHeight={false}
+displayBulkActions={false}
+/>
 ```
 
 ```ts title="Signature"
@@ -40,7 +40,7 @@ Parameters
 
 ## AssetGalleryProps
 
-<GenerationInfo sourceFile="packages/dashboard/src/lib/components/shared/asset/asset-gallery.tsx" sourceLine="81" packageName="@vendure/dashboard" />
+<GenerationInfo sourceFile="packages/dashboard/src/lib/components/shared/asset/asset-gallery.tsx" sourceLine="83" packageName="@vendure/dashboard" />
 
 Props for the <a href='/reference/dashboard/components/asset-gallery#assetgallery'>AssetGallery</a> component.
 

+ 4 - 3
docs/docs/reference/dashboard/components/detail-page-button.md

@@ -36,17 +36,18 @@ navigate to detail views.
 ```
 
 ```ts title="Signature"
-function DetailPageButton(props: {
+function DetailPageButton(props: Readonly<{
     label: string | React.ReactNode;
     id?: string;
     href?: string;
     disabled?: boolean;
     search?: Record<string, string>;
-}): void
+    className?: string;
+}>): void
 ```
 Parameters
 
 ### props
 
-<MemberInfo kind="parameter" type={`{     label: string | React.ReactNode;     id?: string;     href?: string;     disabled?: boolean;     search?: Record&#60;string, string&#62;; }`} />
+<MemberInfo kind="parameter" type={`Readonly&#60;{     label: string | React.ReactNode;     id?: string;     href?: string;     disabled?: boolean;     search?: Record&#60;string, string&#62;;     className?: string; }&#62;`} />
 

+ 10 - 2
docs/docs/reference/dashboard/extensions-api/define-dashboard-extension.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## defineDashboardExtension
 
-<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/extension-api/define-dashboard-extension.ts" sourceLine="60" packageName="@vendure/dashboard" since="3.3.0" />
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/extension-api/define-dashboard-extension.ts" sourceLine="62" packageName="@vendure/dashboard" since="3.3.0" />
 
 The main entry point for extensions to the React-based dashboard. Every dashboard extension
 must contain a call to this function, usually in the entry point file that is referenced by
@@ -26,6 +26,7 @@ Every type of customisation of the dashboard can be defined here, including:
 - Data tables
 - Detail forms
 - Login
+- Custom history entries
 
 *Example*
 
@@ -51,7 +52,7 @@ Parameters
 
 ## DashboardExtension
 
-<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/extension-api/extension-api-types.ts" sourceLine="33" packageName="@vendure/dashboard" since="3.3.0" />
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/extension-api/extension-api-types.ts" sourceLine="34" packageName="@vendure/dashboard" since="3.3.0" />
 
 This is the main interface for defining _all_ extensions to the dashboard.
 
@@ -77,6 +78,7 @@ interface DashboardExtension {
     dataTables?: DashboardDataTableExtensionDefinition[];
     detailForms?: DashboardDetailFormExtensionDefinition[];
     login?: DashboardLoginExtensions;
+    historyEntries?: DashboardHistoryEntryComponent[];
 }
 ```
 
@@ -133,6 +135,12 @@ Allows you to customize the detail form for any page in the dashboard.
 <MemberInfo kind="property" type={`<a href='/reference/dashboard/extensions-api/login#dashboardloginextensions'>DashboardLoginExtensions</a>`}   />
 
 Allows you to customize the login page with custom components.
+### historyEntries
+
+<MemberInfo kind="property" type={`<a href='/reference/dashboard/extensions-api/history-entries#dashboardhistoryentrycomponent'>DashboardHistoryEntryComponent</a>[]`}   />
+
+Allows a custom component to be used to render a history entry item
+in the Order or Customer history lists.
 
 
 </div>

+ 250 - 0
docs/docs/reference/dashboard/extensions-api/history-entries.md

@@ -0,0 +1,250 @@
+---
+title: "HistoryEntries"
+isDefaultIndex: false
+generated: true
+---
+<!-- This file was generated from the Vendure source. Do not modify. Instead, re-run the "docs:build" script -->
+import MemberInfo from '@site/src/components/MemberInfo';
+import GenerationInfo from '@site/src/components/GenerationInfo';
+import MemberDescription from '@site/src/components/MemberDescription';
+
+
+## DashboardHistoryEntryComponent
+
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/extension-api/types/history-entries.ts" sourceLine="96" packageName="@vendure/dashboard" since="3.4.3" />
+
+A definition of a custom component that will be used to render the given
+type of history entry.
+
+*Example*
+
+```tsx
+import { defineDashboardExtension, HistoryEntry } from '@vendure/dashboard';
+import { IdCard } from 'lucide-react';
+
+defineDashboardExtension({
+    historyEntries: [
+        {
+            type: 'CUSTOMER_TAX_ID_APPROVAL',
+            component: ({ entry, entity }) => {
+                return (
+                    <HistoryEntry
+                        entry={entry}
+                        title={'Tax ID verified'}
+                        timelineIconClassName={'bg-success text-success-foreground'}
+                        timelineIcon={<IdCard />}
+                    >
+                        <div className="text-xs">Approval reference: {entry.data.ref}</div>
+                    </HistoryEntry>
+                );
+            },
+        },
+    ],
+ });
+ ```
+
+```ts title="Signature"
+interface DashboardHistoryEntryComponent {
+    type: string;
+    component: React.ComponentType<{
+        entry: HistoryEntryItem;
+        entity: OrderHistoryOrderDetail | CustomerHistoryCustomerDetail;
+    }>;
+}
+```
+
+<div className="members-wrapper">
+
+### type
+
+<MemberInfo kind="property" type={`string`}   />
+
+The `type` should correspond to a valid `HistoryEntryType`, such as
+
+- `CUSTOMER_REGISTERED`
+- `ORDER_STATE_TRANSITION`
+- some custom type - see the <a href='/reference/typescript-api/services/history-service#historyservice'>HistoryService</a> docs for a guide on
+  how to define custom history entry types.
+### component
+
+<MemberInfo kind="property" type={`React.ComponentType&#60;{         entry: <a href='/reference/dashboard/extensions-api/history-entries#historyentryitem'>HistoryEntryItem</a>;         entity: OrderHistoryOrderDetail | CustomerHistoryCustomerDetail;     }&#62;`}   />
+
+The component which is used to render the timeline entry. It should use the
+<a href='/reference/dashboard/extensions-api/history-entries#historyentry'>HistoryEntry</a> component and pass the appropriate props to configure
+how it will be displayed.
+
+The `entity` prop will be a subset of the Order object for Order history entries,
+or a subset of the Customer object for customer history entries.
+
+
+</div>
+
+
+## HistoryEntryItem
+
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/extension-api/types/history-entries.ts" sourceLine="14" packageName="@vendure/dashboard" since="3.4.3" />
+
+This object contains the information about the history entry.
+
+```ts title="Signature"
+interface HistoryEntryItem {
+    id: string;
+    type: string;
+    createdAt: string;
+    isPublic: boolean;
+    administrator?: {
+        id: string;
+        firstName: string;
+        lastName: string;
+    } | null;
+    data: any;
+}
+```
+
+<div className="members-wrapper">
+
+### id
+
+<MemberInfo kind="property" type={`string`}   />
+
+
+### type
+
+<MemberInfo kind="property" type={`string`}   />
+
+The `HistoryEntryType`, such as `ORDER_STATE_TRANSITION`.
+### createdAt
+
+<MemberInfo kind="property" type={`string`}   />
+
+
+### isPublic
+
+<MemberInfo kind="property" type={`boolean`}   />
+
+Whether this entry is visible to customers via the Shop API
+### administrator
+
+<MemberInfo kind="property" type={`{         id: string;         firstName: string;         lastName: string;     } | null`}   />
+
+If an Administrator created this entry, their details will
+be available here.
+### data
+
+<MemberInfo kind="property" type={`any`}   />
+
+The entry payload data. This will be an object, which is different
+for each type of history entry.
+
+For example, the `CUSTOMER_ADDED_TO_GROUP` data looks like this:
+```json
+{
+  groupName: 'Some Group',
+}
+```
+
+and the `ORDER_STATE_TRANSITION` data looks like this:
+```json
+{
+  from: 'ArrangingPayment',
+  to: 'PaymentSettled',
+}
+```
+
+
+</div>
+
+
+## HistoryEntryProps
+
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/history-entry/history-entry.tsx" sourceLine="14" packageName="@vendure/dashboard" since="3.4.3" />
+
+The props for the <a href='/reference/dashboard/extensions-api/history-entries#historyentry'>HistoryEntry</a> component.
+
+```ts title="Signature"
+interface HistoryEntryProps {
+    entry: HistoryEntryItem;
+    title: string | React.ReactNode;
+    timelineIcon?: React.ReactNode;
+    timelineIconClassName?: string;
+    actorName?: string;
+    children: React.ReactNode;
+    isPrimary?: boolean;
+}
+```
+
+<div className="members-wrapper">
+
+### entry
+
+<MemberInfo kind="property" type={`<a href='/reference/dashboard/extensions-api/history-entries#historyentryitem'>HistoryEntryItem</a>`}   />
+
+The entry itself, which will get passed down to your custom component
+### title
+
+<MemberInfo kind="property" type={`string | React.ReactNode`}   />
+
+The title of the entry
+### timelineIcon
+
+<MemberInfo kind="property" type={`React.ReactNode`}   />
+
+An icon which is used to represent the entry. Note that this will only
+display if `isPrimary` is `true`.
+### timelineIconClassName
+
+<MemberInfo kind="property" type={`string`}   />
+
+Optional tailwind classes to apply to the icon. For instance
+
+```ts
+const success = 'bg-success text-success-foreground';
+const destructive = 'bg-danger text-danger-foreground';
+```
+### actorName
+
+<MemberInfo kind="property" type={`string`}   />
+
+The name to display of "who did the action". For instance:
+
+```ts
+const getActorName = (entry: HistoryEntryItem) => {
+    if (entry.administrator) {
+        return `${entry.administrator.firstName} ${entry.administrator.lastName}`;
+    } else if (entity?.customer) {
+        return `${entity.customer.firstName} ${entity.customer.lastName}`;
+    }
+    return '';
+};
+```
+### children
+
+<MemberInfo kind="property" type={`React.ReactNode`}   />
+
+
+### isPrimary
+
+<MemberInfo kind="property" type={`boolean`}   />
+
+When set to `true`, the timeline entry will feature the specified icon and will not
+be collapsible.
+
+
+</div>
+
+
+## HistoryEntry
+
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/history-entry/history-entry.tsx" sourceLine="74" packageName="@vendure/dashboard" since="3.4.3" />
+
+A component which is used to display a history entry in the order/customer history timeline.
+
+```ts title="Signature"
+function HistoryEntry(props: Readonly<HistoryEntryProps>): void
+```
+Parameters
+
+### props
+
+<MemberInfo kind="parameter" type={`Readonly&#60;<a href='/reference/dashboard/extensions-api/history-entries#historyentryprops'>HistoryEntryProps</a>&#62;`} />
+

+ 5 - 2
docs/docs/reference/dashboard/list-views/list-page.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## ListPage
 
-<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/page/list-page.tsx" sourceLine="158" packageName="@vendure/dashboard" since="3.3.0" />
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/page/list-page.tsx" sourceLine="165" packageName="@vendure/dashboard" since="3.3.0" />
 
 Auto-generates a list page with columns generated based on the provided query document fields.
 
@@ -169,7 +169,10 @@ interface ListPageProps<T extends TypedDocumentNode<U, V>, U extends ListQuerySh
 
 <MemberInfo kind="property" type={`TypedDocumentNode&#60;any, { id: string }&#62;`}   />
 
-
+Providing the `deleteMutation` will automatically add a "delete" menu item to the
+actions column dropdown. Note that if this table already has a "delete" bulk action,
+you don't need to additionally provide a delete mutation, because the bulk action
+will be added to the action column dropdown already.
 ### transformVariables
 
 <MemberInfo kind="property" type={`(variables: V) =&#62; V`}   />

+ 2 - 2
docs/docs/reference/dashboard/page-layout/index.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## PageLayout
 
-<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="204" packageName="@vendure/dashboard" since="3.3.0" />
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="211" packageName="@vendure/dashboard" since="3.3.0" />
 
 *
 This component governs the layout of the contents of a <a href='/reference/dashboard/page-layout/page#page'>Page</a> component.
@@ -30,7 +30,7 @@ Parameters
 
 ## PageLayoutProps
 
-<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="176" packageName="@vendure/dashboard" since="3.3.0" />
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="183" packageName="@vendure/dashboard" since="3.3.0" />
 
 **Status: Developer Preview**
 

+ 3 - 3
docs/docs/reference/dashboard/page-layout/page-action-bar.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## PageActionBar
 
-<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="305" packageName="@vendure/dashboard" since="3.3.0" />
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="312" packageName="@vendure/dashboard" since="3.3.0" />
 
 *
 A component for displaying the main actions for a page. This should be used inside the <a href='/reference/dashboard/page-layout/page#page'>Page</a> component.
@@ -31,7 +31,7 @@ Parameters
 
 ## PageActionBarLeft
 
-<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="327" packageName="@vendure/dashboard" since="3.3.0" />
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="334" packageName="@vendure/dashboard" since="3.3.0" />
 
 The PageActionBarLeft component should be used to display the left content of the action bar.
 
@@ -48,7 +48,7 @@ Parameters
 
 ## PageActionBarRight
 
-<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="341" packageName="@vendure/dashboard" since="3.3.0" />
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="427" packageName="@vendure/dashboard" since="3.3.0" />
 
 The PageActionBarRight component should be used to display the right content of the action bar.
 

+ 4 - 4
docs/docs/reference/dashboard/page-layout/page-block.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## PageBlock
 
-<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="465" packageName="@vendure/dashboard" since="3.3.0" />
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="552" packageName="@vendure/dashboard" since="3.3.0" />
 
 *
 A component for displaying a block of content on a page. This should be used inside the <a href='/reference/dashboard/page-layout/#pagelayout'>PageLayout</a> component.
@@ -39,7 +39,7 @@ Parameters
 
 ## PageBlockProps
 
-<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="414" packageName="@vendure/dashboard" since="3.3.0" />
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="501" packageName="@vendure/dashboard" since="3.3.0" />
 
 Props used to configure the <a href='/reference/dashboard/page-layout/page-block#pageblock'>PageBlock</a> component.
 
@@ -93,7 +93,7 @@ An optional set of CSS classes to apply to the block.
 
 ## FullWidthPageBlock
 
-<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="510" packageName="@vendure/dashboard" since="3.3.0" />
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="597" packageName="@vendure/dashboard" since="3.3.0" />
 
 **Status: Developer Preview**
 
@@ -113,7 +113,7 @@ Parameters
 
 ## CustomFieldsPageBlock
 
-<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="540" packageName="@vendure/dashboard" since="3.3.0" />
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="627" packageName="@vendure/dashboard" since="3.3.0" />
 
 *
 A component for displaying an auto-generated form for custom fields on a page.

+ 1 - 1
docs/docs/reference/dashboard/page-layout/page-title.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## PageTitle
 
-<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="290" packageName="@vendure/dashboard" since="3.3.0" />
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="297" packageName="@vendure/dashboard" since="3.3.0" />
 
 A component for displaying the title of a page. This should be used inside the <a href='/reference/dashboard/page-layout/page#page'>Page</a> component.
 

+ 2 - 2
docs/docs/reference/dashboard/page-layout/page.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## Page
 
-<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="84" packageName="@vendure/dashboard" since="3.3.0" />
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="91" packageName="@vendure/dashboard" since="3.3.0" />
 
 This component should be used to wrap _all_ pages in the dashboard. It provides
 a consistent layout as well as a context for the slot-based PageBlock system.
@@ -61,7 +61,7 @@ Parameters
 
 ## PageProps
 
-<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="31" packageName="@vendure/dashboard" since="3.3.0" />
+<GenerationInfo sourceFile="packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx" sourceLine="38" packageName="@vendure/dashboard" since="3.3.0" />
 
 The props used to configure the <a href='/reference/dashboard/page-layout/page#page'>Page</a> component.
 

+ 2 - 2
packages/dashboard/src/app/routes/_authenticated/_customers/components/customer-history/customer-history-container.tsx

@@ -63,13 +63,13 @@ export function CustomerHistoryContainer({ customerId }: Readonly<CustomerHistor
         <>
             <CustomerHistory
                 customer={customer}
-                historyEntries={historyEntries ?? []}
+                historyEntries={historyEntries}
                 onAddNote={addNote}
                 onUpdateNote={updateNote}
                 onDeleteNote={deleteNote}
             />
             {hasNextPage && (
-                <Button type="button" variant="outline" onClick={() => fetchNextPage()}>
+                <Button type="button" variant="outline" onClick={() => fetchNextPage?.()}>
                     <Trans>Load more</Trans>
                 </Button>
             )}

+ 5 - 0
packages/dashboard/src/app/routes/_authenticated/_customers/components/customer-history/customer-history-types.ts

@@ -0,0 +1,5 @@
+import { ResultOf } from 'gql.tada';
+
+import { customerHistoryDocument } from '../../customers.graphql.js';
+
+export type CustomerHistoryCustomerDetail = NonNullable<ResultOf<typeof customerHistoryDocument>>['customer'];

+ 124 - 0
packages/dashboard/src/app/routes/_authenticated/_customers/components/customer-history/customer-history-utils.tsx

@@ -0,0 +1,124 @@
+import { HistoryEntryItem } from '@/vdb/framework/extension-api/types/index.js';
+import { Trans } from '@/vdb/lib/trans.js';
+import { CheckIcon, Edit3, KeyIcon, Mail, MapPin, SquarePen, User, UserCheck, Users } from 'lucide-react';
+import { CustomerHistoryCustomerDetail } from './customer-history-types.js';
+
+export function customerHistoryUtils(customer: CustomerHistoryCustomerDetail) {
+    const getTimelineIcon = (entry: HistoryEntryItem) => {
+        switch (entry.type) {
+            case 'CUSTOMER_REGISTERED':
+                return <User className="h-4 w-4" />;
+            case 'CUSTOMER_VERIFIED':
+                return <UserCheck className="h-4 w-4" />;
+            case 'CUSTOMER_NOTE':
+                return <SquarePen className="h-4 w-4" />;
+            case 'CUSTOMER_ADDED_TO_GROUP':
+            case 'CUSTOMER_REMOVED_FROM_GROUP':
+                return <Users className="h-4 w-4" />;
+            case 'CUSTOMER_DETAIL_UPDATED':
+                return <Edit3 className="h-4 w-4" />;
+            case 'CUSTOMER_ADDRESS_CREATED':
+            case 'CUSTOMER_ADDRESS_UPDATED':
+            case 'CUSTOMER_ADDRESS_DELETED':
+                return <MapPin className="h-4 w-4" />;
+            case 'CUSTOMER_PASSWORD_UPDATED':
+            case 'CUSTOMER_PASSWORD_RESET_REQUESTED':
+            case 'CUSTOMER_PASSWORD_RESET_VERIFIED':
+                return <KeyIcon className="h-4 w-4" />;
+            case 'CUSTOMER_EMAIL_UPDATE_REQUESTED':
+            case 'CUSTOMER_EMAIL_UPDATE_VERIFIED':
+                return <Mail className="h-4 w-4" />;
+            default:
+                return <CheckIcon className="h-4 w-4" />;
+        }
+    };
+
+    const getTitle = (entry: HistoryEntryItem) => {
+        switch (entry.type) {
+            case 'CUSTOMER_REGISTERED':
+                return <Trans>Customer registered</Trans>;
+            case 'CUSTOMER_VERIFIED':
+                return <Trans>Customer verified</Trans>;
+            case 'CUSTOMER_NOTE':
+                return <Trans>Note added</Trans>;
+            case 'CUSTOMER_DETAIL_UPDATED':
+                return <Trans>Customer details updated</Trans>;
+            case 'CUSTOMER_ADDED_TO_GROUP':
+                return <Trans>Added to group</Trans>;
+            case 'CUSTOMER_REMOVED_FROM_GROUP':
+                return <Trans>Removed from group</Trans>;
+            case 'CUSTOMER_ADDRESS_CREATED':
+                return <Trans>Address created</Trans>;
+            case 'CUSTOMER_ADDRESS_UPDATED':
+                return <Trans>Address updated</Trans>;
+            case 'CUSTOMER_ADDRESS_DELETED':
+                return <Trans>Address deleted</Trans>;
+            case 'CUSTOMER_PASSWORD_UPDATED':
+                return <Trans>Password updated</Trans>;
+            case 'CUSTOMER_PASSWORD_RESET_REQUESTED':
+                return <Trans>Password reset requested</Trans>;
+            case 'CUSTOMER_PASSWORD_RESET_VERIFIED':
+                return <Trans>Password reset verified</Trans>;
+            case 'CUSTOMER_EMAIL_UPDATE_REQUESTED':
+                return <Trans>Email update requested</Trans>;
+            case 'CUSTOMER_EMAIL_UPDATE_VERIFIED':
+                return <Trans>Email update verified</Trans>;
+            default:
+                return <Trans>{entry.type.replace(/_/g, ' ').toLowerCase()}</Trans>;
+        }
+    };
+
+    const getIconColor = (entry: HistoryEntryItem) => {
+        // Check for success states
+        if (
+            entry.type === 'CUSTOMER_VERIFIED' ||
+            entry.type === 'CUSTOMER_EMAIL_UPDATE_VERIFIED' ||
+            entry.type === 'CUSTOMER_PASSWORD_RESET_VERIFIED'
+        ) {
+            return 'bg-success text-success-foreground';
+        }
+
+        // Check for destructive states
+        if (entry.type === 'CUSTOMER_REMOVED_FROM_GROUP' || entry.type === 'CUSTOMER_ADDRESS_DELETED') {
+            return 'bg-destructive text-destructive-foreground';
+        }
+
+        // Registration gets muted style
+        if (entry.type === 'CUSTOMER_REGISTERED') {
+            return 'bg-muted text-muted-foreground';
+        }
+
+        // All other entries use neutral colors
+        return 'bg-muted text-muted-foreground';
+    };
+
+    const getActorName = (entry: HistoryEntryItem) => {
+        if (entry.administrator) {
+            return `${entry.administrator.firstName} ${entry.administrator.lastName}`;
+        } else if (customer) {
+            return `${customer.firstName} ${customer.lastName}`;
+        }
+        return '';
+    };
+
+    const isPrimaryEvent = (entry: HistoryEntryItem) => {
+        switch (entry.type) {
+            case 'CUSTOMER_REGISTERED':
+            case 'CUSTOMER_VERIFIED':
+            case 'CUSTOMER_NOTE':
+            case 'CUSTOMER_EMAIL_UPDATE_VERIFIED':
+            case 'CUSTOMER_PASSWORD_RESET_VERIFIED':
+                return true;
+            default:
+                return false;
+        }
+    };
+
+    return {
+        getTimelineIcon,
+        getTitle,
+        getIconColor,
+        getActorName,
+        isPrimaryEvent,
+    };
+}

+ 91 - 59
packages/dashboard/src/app/routes/_authenticated/_customers/components/customer-history/customer-history.tsx

@@ -1,81 +1,113 @@
-import { HistoryEntry, HistoryEntryItem } from '@/vdb/components/shared/history-timeline/history-entry.js';
+import { HistoryNoteEditor } from '@/vdb/components/shared/history-timeline/history-note-editor.js';
+import { HistoryNoteEntry } from '@/vdb/components/shared/history-timeline/history-note-entry.js';
 import { HistoryNoteInput } from '@/vdb/components/shared/history-timeline/history-note-input.js';
-import { HistoryTimeline } from '@/vdb/components/shared/history-timeline/history-timeline.js';
-import { Badge } from '@/vdb/components/ui/badge.js';
-import { Trans } from '@/vdb/lib/trans.js';
-import { CheckIcon, SquarePen } from 'lucide-react';
+import { HistoryTimelineWithGrouping } from '@/vdb/components/shared/history-timeline/history-timeline-with-grouping.js';
+import { useHistoryNoteEditor } from '@/vdb/components/shared/history-timeline/use-history-note-editor.js';
+import { HistoryEntryItem } from '@/vdb/framework/extension-api/types/history-entries.js';
+import { HistoryEntryProps } from '@/vdb/framework/history-entry/history-entry.js';
+import { CustomerHistoryCustomerDetail } from './customer-history-types.js';
+import { customerHistoryUtils } from './customer-history-utils.js';
+import {
+    CustomerAddRemoveGroupComponent,
+    CustomerAddressCreatedComponent,
+    CustomerAddressDeletedComponent,
+    CustomerAddressUpdatedComponent,
+    CustomerDetailUpdatedComponent,
+    CustomerEmailUpdateComponent,
+    CustomerPasswordResetRequestedComponent,
+    CustomerPasswordResetVerifiedComponent,
+    CustomerPasswordUpdatedComponent,
+    CustomerRegisteredOrVerifiedComponent,
+} from './default-customer-history-components.js';
 
 interface CustomerHistoryProps {
-    customer: {
-        id: string;
-    };
-    historyEntries: Array<HistoryEntryItem>;
+    customer: CustomerHistoryCustomerDetail;
+    historyEntries: HistoryEntryItem[];
     onAddNote: (note: string, isPrivate: boolean) => void;
     onUpdateNote?: (entryId: string, note: string, isPrivate: boolean) => void;
     onDeleteNote?: (entryId: string) => void;
 }
 
 export function CustomerHistory({
+    customer,
     historyEntries,
     onAddNote,
     onUpdateNote,
     onDeleteNote,
-}: CustomerHistoryProps) {
-    const getTimelineIcon = (entry: CustomerHistoryProps['historyEntries'][0]) => {
-        switch (entry.type) {
-            case 'CUSTOMER_NOTE':
-                return <SquarePen className="h-4 w-4" />;
-            default:
-                return <CheckIcon className="h-4 w-4" />;
-        }
+}: Readonly<CustomerHistoryProps>) {
+    const { noteState, noteEditorOpen, handleEditNote, setNoteEditorOpen } = useHistoryNoteEditor();
+
+    const handleDeleteNote = (noteId: string) => {
+        onDeleteNote?.(noteId);
     };
 
-    const getTitle = (entry: CustomerHistoryProps['historyEntries'][0]) => {
-        switch (entry.type) {
-            case 'CUSTOMER_NOTE':
-                return <Trans>Note added</Trans>;
-            default:
-                return <Trans>{entry.type.replace(/_/g, ' ').toLowerCase()}</Trans>;
+    const handleNoteEditorSave = (noteId: string, note: string, isPrivate: boolean) => {
+        onUpdateNote?.(noteId, note, isPrivate);
+    };
+
+    const { getTimelineIcon, getTitle, getIconColor, getActorName, isPrimaryEvent } =
+        customerHistoryUtils(customer);
+
+    const renderEntryContent = (entry: HistoryEntryItem) => {
+        const props: HistoryEntryProps = {
+            entry,
+            title: getTitle(entry),
+            actorName: getActorName(entry),
+            timelineIcon: getTimelineIcon(entry),
+            timelineIconClassName: getIconColor(entry),
+            isPrimary: isPrimaryEvent(entry),
+            children: null,
+        };
+        if (entry.type === 'CUSTOMER_REGISTERED' || entry.type === 'CUSTOMER_VERIFIED') {
+            return <CustomerRegisteredOrVerifiedComponent {...props} />;
+        } else if (entry.type === 'CUSTOMER_DETAIL_UPDATED') {
+            return <CustomerDetailUpdatedComponent {...props} />;
+        } else if (entry.type === 'CUSTOMER_ADDED_TO_GROUP' || entry.type === 'CUSTOMER_REMOVED_FROM_GROUP') {
+            return <CustomerAddRemoveGroupComponent {...props} />;
+        } else if (entry.type === 'CUSTOMER_ADDRESS_CREATED') {
+            return <CustomerAddressCreatedComponent {...props} />;
+        } else if (entry.type === 'CUSTOMER_ADDRESS_UPDATED') {
+            return <CustomerAddressUpdatedComponent {...props} />;
+        } else if (entry.type === 'CUSTOMER_ADDRESS_DELETED') {
+            return <CustomerAddressDeletedComponent {...props} />;
+        } else if (entry.type === 'CUSTOMER_NOTE') {
+            return (
+                <HistoryNoteEntry {...props} onEditNote={handleEditNote} onDeleteNote={handleDeleteNote} />
+            );
+        } else if (entry.type === 'CUSTOMER_PASSWORD_UPDATED') {
+            return <CustomerPasswordUpdatedComponent {...props} />;
+        } else if (entry.type === 'CUSTOMER_PASSWORD_RESET_REQUESTED') {
+            return <CustomerPasswordResetRequestedComponent {...props} />;
+        } else if (entry.type === 'CUSTOMER_PASSWORD_RESET_VERIFIED') {
+            return <CustomerPasswordResetVerifiedComponent {...props} />;
+        } else if (
+            entry.type === 'CUSTOMER_EMAIL_UPDATE_REQUESTED' ||
+            entry.type === 'CUSTOMER_EMAIL_UPDATE_VERIFIED'
+        ) {
+            return <CustomerEmailUpdateComponent {...props} />;
         }
+        return null;
     };
 
     return (
-        <div className="">
-            <div className="mb-4">
+        <>
+            <HistoryTimelineWithGrouping
+                historyEntries={historyEntries}
+                isPrimaryEvent={isPrimaryEvent}
+                renderEntryContent={renderEntryContent}
+                entity={customer}
+            >
                 <HistoryNoteInput onAddNote={onAddNote} />
-            </div>
-            <HistoryTimeline onEditNote={onUpdateNote} onDeleteNote={onDeleteNote}>
-                {historyEntries.map(entry => (
-                    <HistoryEntry
-                        key={entry.id}
-                        entry={entry}
-                        isNoteEntry={entry.type === 'CUSTOMER_NOTE'}
-                        timelineIcon={getTimelineIcon(entry)}
-                        title={getTitle(entry)}
-                    >
-                        {entry.type === 'CUSTOMER_NOTE' && (
-                            <div className="flex items-center space-x-2">
-                                <Badge variant={entry.isPublic ? 'outline' : 'secondary'} className="text-xs">
-                                    {entry.isPublic ? 'Public' : 'Private'}
-                                </Badge>
-                                <span>{entry.data.note}</span>
-                            </div>
-                        )}
-                        <div className="text-sm text-muted-foreground">
-                            {entry.type === 'CUSTOMER_NOTE' && (
-                                <Trans>
-                                    From {entry.data.from} to {entry.data.to}
-                                </Trans>
-                            )}
-                            {entry.type === 'ORDER_PAYMENT_TRANSITION' && (
-                                <Trans>
-                                    Payment #{entry.data.paymentId} transitioned to {entry.data.to}
-                                </Trans>
-                            )}
-                        </div>
-                    </HistoryEntry>
-                ))}
-            </HistoryTimeline>
-        </div>
+            </HistoryTimelineWithGrouping>
+            <HistoryNoteEditor
+                key={noteState.noteId}
+                open={noteEditorOpen}
+                onOpenChange={setNoteEditorOpen}
+                onNoteChange={handleNoteEditorSave}
+                note={noteState.note}
+                isPrivate={noteState.isPrivate}
+                noteId={noteState.noteId}
+            />
+        </>
     );
 }

+ 176 - 0
packages/dashboard/src/app/routes/_authenticated/_customers/components/customer-history/default-customer-history-components.tsx

@@ -0,0 +1,176 @@
+import { HistoryEntry, HistoryEntryProps } from '@/vdb/framework/history-entry/history-entry.js';
+import { Trans } from '@/vdb/lib/trans.js';
+
+export function CustomerRegisteredOrVerifiedComponent(props: Readonly<HistoryEntryProps>) {
+    const { entry } = props;
+    const strategy = entry.data?.strategy || 'native';
+
+    return (
+        <HistoryEntry {...props}>
+            <div className="space-y-1">
+                {strategy === 'native' ? (
+                    <p className="text-xs text-muted-foreground">
+                        <Trans>Using native authentication strategy</Trans>
+                    </p>
+                ) : (
+                    <p className="text-xs text-muted-foreground">
+                        <Trans>Using external authentication strategy: {strategy}</Trans>
+                    </p>
+                )}
+            </div>
+        </HistoryEntry>
+    );
+}
+
+export function CustomerDetailUpdatedComponent(props: Readonly<HistoryEntryProps>) {
+    const { entry } = props;
+    return (
+        <HistoryEntry {...props}>
+            <div className="space-y-2">
+                {entry.data?.input && (
+                    <details className="text-xs">
+                        <summary className="cursor-pointer text-muted-foreground hover:text-foreground">
+                            <Trans>View changes</Trans>
+                        </summary>
+                        <pre className="mt-2 p-2 bg-muted rounded text-xs overflow-auto">
+                            {JSON.stringify(entry.data.input, null, 2)}
+                        </pre>
+                    </details>
+                )}
+            </div>
+        </HistoryEntry>
+    );
+}
+
+export function CustomerAddRemoveGroupComponent(props: Readonly<HistoryEntryProps>) {
+    const { entry } = props;
+    const groupName = entry.data?.groupName || 'Unknown Group';
+
+    return (
+        <HistoryEntry {...props}>
+            <p className="text-xs text-muted-foreground">{groupName}</p>
+        </HistoryEntry>
+    );
+}
+
+export function CustomerAddressCreatedComponent(props: Readonly<HistoryEntryProps>) {
+    const { entry } = props;
+    return (
+        <HistoryEntry {...props}>
+            <div className="space-y-1">
+                <p className="text-xs text-muted-foreground">
+                    <Trans>Address created</Trans>
+                </p>
+                {entry.data?.address && (
+                    <div className="text-xs p-2 bg-muted rounded">{entry.data.address}</div>
+                )}
+            </div>
+        </HistoryEntry>
+    );
+}
+
+export function CustomerAddressUpdatedComponent(props: Readonly<HistoryEntryProps>) {
+    const { entry } = props;
+    return (
+        <HistoryEntry {...props}>
+            <div className="space-y-2">
+                <p className="text-xs text-muted-foreground">
+                    <Trans>Address updated</Trans>
+                </p>
+                {entry.data?.address && (
+                    <div className="text-xs p-2 bg-muted rounded">{entry.data.address}</div>
+                )}
+                {entry.data?.input && (
+                    <details className="text-xs">
+                        <summary className="cursor-pointer text-muted-foreground hover:text-foreground">
+                            <Trans>View changes</Trans>
+                        </summary>
+                        <pre className="mt-2 p-2 bg-muted rounded text-xs overflow-auto">
+                            {JSON.stringify(entry.data.input, null, 2)}
+                        </pre>
+                    </details>
+                )}
+            </div>
+        </HistoryEntry>
+    );
+}
+
+export function CustomerAddressDeletedComponent(props: Readonly<HistoryEntryProps>) {
+    const { entry } = props;
+    return (
+        <HistoryEntry {...props}>
+            <div className="space-y-1">
+                <p className="text-xs text-muted-foreground">
+                    <Trans>Address deleted</Trans>
+                </p>
+                {entry.data?.address && (
+                    <div className="text-xs p-2 bg-muted rounded">{entry.data.address}</div>
+                )}
+            </div>
+        </HistoryEntry>
+    );
+}
+
+export function CustomerPasswordUpdatedComponent(props: Readonly<HistoryEntryProps>) {
+    return (
+        <HistoryEntry {...props}>
+            <p className="text-xs text-muted-foreground">
+                <Trans>Password updated</Trans>
+            </p>
+        </HistoryEntry>
+    );
+}
+
+export function CustomerPasswordResetRequestedComponent(props: Readonly<HistoryEntryProps>) {
+    return (
+        <HistoryEntry {...props}>
+            <p className="text-xs text-muted-foreground">
+                <Trans>Password reset requested</Trans>
+            </p>
+        </HistoryEntry>
+    );
+}
+
+export function CustomerPasswordResetVerifiedComponent(props: Readonly<HistoryEntryProps>) {
+    return (
+        <HistoryEntry {...props}>
+            <p className="text-xs text-muted-foreground">
+                <Trans>Password reset verified</Trans>
+            </p>
+        </HistoryEntry>
+    );
+}
+
+export function CustomerEmailUpdateComponent({ entry }: Readonly<HistoryEntryProps>) {
+    const { oldEmailAddress, newEmailAddress } = entry.data || {};
+
+    return (
+        <div className="space-y-2">
+            {(oldEmailAddress || newEmailAddress) && (
+                <details className="text-xs">
+                    <summary className="cursor-pointer text-muted-foreground hover:text-foreground">
+                        <Trans>View details</Trans>
+                    </summary>
+                    <div className="mt-2 space-y-1">
+                        {oldEmailAddress && (
+                            <div>
+                                <span className="font-medium">
+                                    <Trans>Old email:</Trans>
+                                </span>{' '}
+                                {oldEmailAddress}
+                            </div>
+                        )}
+                        {newEmailAddress && (
+                            <div>
+                                <span className="font-medium">
+                                    <Trans>New email:</Trans>
+                                </span>{' '}
+                                {newEmailAddress}
+                            </div>
+                        )}
+                    </div>
+                </details>
+            )}
+        </div>
+    );
+}

+ 4 - 2
packages/dashboard/src/app/routes/_authenticated/_customers/components/customer-history/index.ts

@@ -1,3 +1,5 @@
-export * from './customer-history.js';
 export * from './customer-history-container.js';
-export * from './use-customer-history.js';
+export * from './customer-history-utils.js';
+export * from './customer-history.js';
+export * from './default-customer-history-components.js';
+export * from './default-customer-history-registry.js';

+ 2 - 0
packages/dashboard/src/app/routes/_authenticated/_customers/customers.graphql.ts

@@ -167,6 +167,8 @@ export const customerHistoryDocument = graphql(`
             id
             createdAt
             updatedAt
+            firstName
+            lastName
             history(options: $options) {
                 totalItems
                 items {

+ 98 - 0
packages/dashboard/src/app/routes/_authenticated/_orders/components/order-history/default-order-history-components.tsx

@@ -0,0 +1,98 @@
+import { HistoryEntry, HistoryEntryProps } from '@/vdb/framework/history-entry/history-entry.js';
+import { Trans } from '@/vdb/lib/trans.js';
+
+export function OrderStateTransitionComponent(props: Readonly<HistoryEntryProps>) {
+    const { entry } = props;
+    if (entry.data.from === 'Created') return null;
+
+    return (
+        <HistoryEntry {...props}>
+            <p className="text-xs text-muted-foreground">
+                <Trans>
+                    From {entry.data.from} to {entry.data.to}
+                </Trans>
+            </p>
+        </HistoryEntry>
+    );
+}
+
+export function OrderPaymentTransitionComponent(props: Readonly<HistoryEntryProps>) {
+    const { entry } = props;
+    return (
+        <HistoryEntry {...props}>
+            <p className="text-xs text-muted-foreground">
+                <Trans>
+                    Payment #{entry.data.paymentId} transitioned to {entry.data.to}
+                </Trans>
+            </p>
+        </HistoryEntry>
+    );
+}
+
+export function OrderRefundTransitionComponent(props: Readonly<HistoryEntryProps>) {
+    const { entry } = props;
+    return (
+        <HistoryEntry {...props}>
+            <p className="text-xs text-muted-foreground">
+                <Trans>
+                    Refund #{entry.data.refundId} transitioned to {entry.data.to}
+                </Trans>
+            </p>
+        </HistoryEntry>
+    );
+}
+
+export function OrderFulfillmentTransitionComponent(props: Readonly<HistoryEntryProps>) {
+    const { entry } = props;
+    return (
+        <HistoryEntry {...props}>
+            <p className="text-xs text-muted-foreground">
+                <Trans>
+                    Fulfillment #{entry.data.fulfillmentId} from {entry.data.from} to {entry.data.to}
+                </Trans>
+            </p>
+        </HistoryEntry>
+    );
+}
+
+export function OrderFulfillmentComponent(props: Readonly<HistoryEntryProps>) {
+    const { entry } = props;
+    return (
+        <HistoryEntry {...props}>
+            <p className="text-xs text-muted-foreground">
+                <Trans>Fulfillment #{entry.data.fulfillmentId} created</Trans>
+            </p>
+        </HistoryEntry>
+    );
+}
+
+export function OrderModifiedComponent(props: Readonly<HistoryEntryProps>) {
+    const { entry } = props;
+    return (
+        <HistoryEntry {...props}>
+            <p className="text-xs text-muted-foreground">
+                <Trans>Order modification #{entry.data.modificationId}</Trans>
+            </p>
+        </HistoryEntry>
+    );
+}
+
+export function OrderCustomerUpdatedComponent(props: Readonly<HistoryEntryProps>) {
+    return (
+        <HistoryEntry {...props}>
+            <p className="text-xs text-muted-foreground">
+                <Trans>Customer information updated</Trans>
+            </p>
+        </HistoryEntry>
+    );
+}
+
+export function OrderCancellationComponent(props: Readonly<HistoryEntryProps>) {
+    return (
+        <HistoryEntry {...props}>
+            <p className="text-xs text-muted-foreground">
+                <Trans>Order cancelled</Trans>
+            </p>
+        </HistoryEntry>
+    );
+}

+ 9 - 7
packages/dashboard/src/app/routes/_authenticated/_orders/components/order-history/order-history-container.tsx

@@ -61,13 +61,15 @@ export function OrderHistoryContainer({ orderId }: Readonly<OrderHistoryContaine
 
     return (
         <>
-            <OrderHistory
-                order={order}
-                historyEntries={historyEntries ?? []}
-                onAddNote={addNote}
-                onUpdateNote={updateNote}
-                onDeleteNote={deleteNote}
-            />
+            {order ? (
+                <OrderHistory
+                    order={order}
+                    historyEntries={historyEntries ?? []}
+                    onAddNote={addNote}
+                    onUpdateNote={updateNote}
+                    onDeleteNote={deleteNote}
+                />
+            ) : null}
             {hasNextPage && (
                 <Button type="button" variant="outline" onClick={() => fetchNextPage()}>
                     <Trans>Load more</Trans>

+ 5 - 0
packages/dashboard/src/app/routes/_authenticated/_orders/components/order-history/order-history-types.ts

@@ -0,0 +1,5 @@
+import { ResultOf } from 'gql.tada';
+
+import { orderHistoryDocument } from '../../orders.graphql.js';
+
+export type OrderHistoryOrderDetail = NonNullable<ResultOf<typeof orderHistoryDocument>>['order'];

+ 173 - 0
packages/dashboard/src/app/routes/_authenticated/_orders/components/order-history/order-history-utils.tsx

@@ -0,0 +1,173 @@
+import { HistoryEntryItem } from '@/vdb/framework/extension-api/types/index.js';
+import { Trans } from '@/vdb/lib/trans.js';
+import {
+    ArrowRightToLine,
+    Ban,
+    CheckIcon,
+    CreditCardIcon,
+    Edit3,
+    SquarePen,
+    Truck,
+    UserX,
+} from 'lucide-react';
+import { OrderHistoryOrderDetail } from './order-history-types.js';
+
+export function orderHistoryUtils(order: OrderHistoryOrderDetail) {
+    const getTimelineIcon = (entry: HistoryEntryItem) => {
+        switch (entry.type) {
+            case 'ORDER_PAYMENT_TRANSITION':
+                return <CreditCardIcon className="h-4 w-4" />;
+            case 'ORDER_REFUND_TRANSITION':
+                return <CreditCardIcon className="h-4 w-4" />;
+            case 'ORDER_NOTE':
+                return <SquarePen className="h-4 w-4" />;
+            case 'ORDER_STATE_TRANSITION':
+                if (entry.data.to === 'Delivered') {
+                    return <CheckIcon className="h-4 w-4" />;
+                }
+                if (entry.data.to === 'Cancelled') {
+                    return <Ban className="h-4 w-4" />;
+                }
+                return <ArrowRightToLine className="h-4 w-4" />;
+            case 'ORDER_FULFILLMENT_TRANSITION':
+                if (entry.data.to === 'Shipped' || entry.data.to === 'Delivered') {
+                    return <Truck className="h-4 w-4" />;
+                }
+                return <ArrowRightToLine className="h-4 w-4" />;
+            case 'ORDER_FULFILLMENT':
+                return <Truck className="h-4 w-4" />;
+            case 'ORDER_MODIFIED':
+                return <Edit3 className="h-4 w-4" />;
+            case 'ORDER_CUSTOMER_UPDATED':
+                return <UserX className="h-4 w-4" />;
+            case 'ORDER_CANCELLATION':
+                return <Ban className="h-4 w-4" />;
+            default:
+                return <CheckIcon className="h-4 w-4" />;
+        }
+    };
+
+    const getTitle = (entry: HistoryEntryItem) => {
+        switch (entry.type) {
+            case 'ORDER_PAYMENT_TRANSITION':
+                if (entry.data.to === 'Settled') {
+                    return <Trans>Payment settled</Trans>;
+                }
+                if (entry.data.to === 'Authorized') {
+                    return <Trans>Payment authorized</Trans>;
+                }
+                if (entry.data.to === 'Declined' || entry.data.to === 'Cancelled') {
+                    return <Trans>Payment failed</Trans>;
+                }
+                return <Trans>Payment transitioned</Trans>;
+            case 'ORDER_REFUND_TRANSITION':
+                if (entry.data.to === 'Settled') {
+                    return <Trans>Refund settled</Trans>;
+                }
+                return <Trans>Refund transitioned</Trans>;
+            case 'ORDER_NOTE':
+                return <Trans>Note added</Trans>;
+            case 'ORDER_STATE_TRANSITION': {
+                if (entry.data.from === 'Created') {
+                    return <Trans>Order placed</Trans>;
+                }
+                if (entry.data.to === 'Delivered') {
+                    return <Trans>Order fulfilled</Trans>;
+                }
+                if (entry.data.to === 'Cancelled') {
+                    return <Trans>Order cancelled</Trans>;
+                }
+                if (entry.data.to === 'Shipped') {
+                    return <Trans>Order shipped</Trans>;
+                }
+                return <Trans>Order transitioned</Trans>;
+            }
+            case 'ORDER_FULFILLMENT_TRANSITION':
+                if (entry.data.to === 'Shipped') {
+                    return <Trans>Order shipped</Trans>;
+                }
+                if (entry.data.to === 'Delivered') {
+                    return <Trans>Order delivered</Trans>;
+                }
+                return <Trans>Fulfillment transitioned</Trans>;
+            case 'ORDER_FULFILLMENT':
+                return <Trans>Fulfillment created</Trans>;
+            case 'ORDER_MODIFIED':
+                return <Trans>Order modified</Trans>;
+            case 'ORDER_CUSTOMER_UPDATED':
+                return <Trans>Customer updated</Trans>;
+            case 'ORDER_CANCELLATION':
+                return <Trans>Order cancelled</Trans>;
+            default:
+                return <Trans>{entry.type.replace(/_/g, ' ').toLowerCase()}</Trans>;
+        }
+    };
+
+    const getIconColor = ({ type, data }: HistoryEntryItem) => {
+        const success = 'bg-success text-success-foreground';
+        const destructive = 'bg-danger text-danger-foreground';
+        const regular = 'bg-muted text-muted-foreground';
+
+        if (type === 'ORDER_PAYMENT_TRANSITION' && data.to === 'Settled') {
+            return success;
+        }
+        if (type === 'ORDER_STATE_TRANSITION' && data.to === 'Delivered') {
+            return success;
+        }
+        if (type === 'ORDER_FULFILLMENT_TRANSITION' && data.to === 'Delivered') {
+            return success;
+        }
+        if (type === 'ORDER_CANCELLATION') {
+            return destructive;
+        }
+        if (type === 'ORDER_STATE_TRANSITION' && data.to === 'Cancelled') {
+            return destructive;
+        }
+        if (type === 'ORDER_PAYMENT_TRANSITION' && (data.to === 'Declined' || data.to === 'Cancelled')) {
+            return destructive;
+        }
+        return regular;
+    };
+
+    const getActorName = (entry: HistoryEntryItem) => {
+        if (entry.administrator) {
+            return `${entry.administrator.firstName} ${entry.administrator.lastName}`;
+        } else if (order?.customer) {
+            return `${order.customer.firstName} ${order.customer.lastName}`;
+        }
+        return '';
+    };
+
+    const isPrimaryEvent = (entry: HistoryEntryItem) => {
+        switch (entry.type) {
+            case 'ORDER_STATE_TRANSITION':
+                return (
+                    entry.data.to === 'Delivered' ||
+                    entry.data.to === 'Cancelled' ||
+                    entry.data.to === 'Settled' ||
+                    entry.data.from === 'Created'
+                );
+            case 'ORDER_REFUND_TRANSITION':
+                return entry.data.to === 'Settled';
+            case 'ORDER_PAYMENT_TRANSITION':
+                return entry.data.to === 'Settled' || entry.data.to === 'Cancelled';
+            case 'ORDER_FULFILLMENT_TRANSITION':
+                return entry.data.to === 'Delivered' || entry.data.to === 'Shipped';
+            case 'ORDER_NOTE':
+            case 'ORDER_MODIFIED':
+            case 'ORDER_CUSTOMER_UPDATED':
+            case 'ORDER_CANCELLATION':
+                return true;
+            default:
+                return false; // All other events are secondary
+        }
+    };
+
+    return {
+        getTimelineIcon,
+        getTitle,
+        getIconColor,
+        getActorName,
+        isPrimaryEvent,
+    };
+}

+ 64 - 408
packages/dashboard/src/app/routes/_authenticated/_orders/components/order-history/order-history.tsx

@@ -1,34 +1,25 @@
-import { HistoryEntry, HistoryEntryItem } from '@/vdb/components/shared/history-timeline/history-entry.js';
 import { HistoryNoteEditor } from '@/vdb/components/shared/history-timeline/history-note-editor.js';
+import { HistoryNoteEntry } from '@/vdb/components/shared/history-timeline/history-note-entry.js';
 import { HistoryNoteInput } from '@/vdb/components/shared/history-timeline/history-note-input.js';
-import { HistoryTimeline } from '@/vdb/components/shared/history-timeline/history-timeline.js';
-import { Badge } from '@/vdb/components/ui/badge.js';
-import { Button } from '@/vdb/components/ui/button.js';
-import { Trans } from '@/vdb/lib/trans.js';
+import { HistoryTimelineWithGrouping } from '@/vdb/components/shared/history-timeline/history-timeline-with-grouping.js';
+import { useHistoryNoteEditor } from '@/vdb/components/shared/history-timeline/use-history-note-editor.js';
+import { HistoryEntryItem } from '@/vdb/framework/extension-api/types/index.js';
+import { HistoryEntryProps } from '@/vdb/framework/history-entry/history-entry.js';
 import {
-    ArrowRightToLine,
-    Ban,
-    CheckIcon,
-    ChevronDown,
-    ChevronUp,
-    CreditCardIcon,
-    Edit3,
-    SquarePen,
-    Truck,
-    UserX,
-} from 'lucide-react';
-import { useState } from 'react';
+    OrderCancellationComponent,
+    OrderCustomerUpdatedComponent,
+    OrderFulfillmentComponent,
+    OrderFulfillmentTransitionComponent,
+    OrderModifiedComponent,
+    OrderPaymentTransitionComponent,
+    OrderRefundTransitionComponent,
+    OrderStateTransitionComponent,
+} from './default-order-history-components.js';
+import { OrderHistoryOrderDetail } from './order-history-types.js';
+import { orderHistoryUtils } from './order-history-utils.js';
 
 interface OrderHistoryProps {
-    order: {
-        id: string;
-        createdAt: string;
-        currencyCode: string;
-        customer?: {
-            firstName: string;
-            lastName: string;
-        } | null;
-    };
+    order: OrderHistoryOrderDetail;
     historyEntries: Array<HistoryEntryItem>;
     onAddNote: (note: string, isPrivate: boolean) => void;
     onUpdateNote?: (entryId: string, note: string, isPrivate: boolean) => void;
@@ -42,22 +33,7 @@ export function OrderHistory({
     onUpdateNote,
     onDeleteNote,
 }: Readonly<OrderHistoryProps>) {
-    const [expandedGroups, setExpandedGroups] = useState<Set<number>>(new Set());
-    const [noteEditorOpen, setNoteEditorOpen] = useState(false);
-    const [noteEditorNote, setNoteEditorNote] = useState<{
-        noteId: string;
-        note: string;
-        isPrivate: boolean;
-    }>({
-        noteId: '',
-        note: '',
-        isPrivate: true,
-    });
-
-    const handleEditNote = (noteId: string, note: string, isPrivate: boolean) => {
-        setNoteEditorNote({ noteId, note, isPrivate });
-        setNoteEditorOpen(true);
-    };
+    const { noteState, noteEditorOpen, handleEditNote, setNoteEditorOpen } = useHistoryNoteEditor();
 
     const handleDeleteNote = (noteId: string) => {
         onDeleteNote?.(noteId);
@@ -67,382 +43,62 @@ export function OrderHistory({
         onUpdateNote?.(noteId, note, isPrivate);
     };
 
-    const isPrimaryEvent = (entry: HistoryEntryItem) => {
-        // Based on Angular component's isFeatured method
-        switch (entry.type) {
-            case 'ORDER_STATE_TRANSITION':
-                return (
-                    entry.data.to === 'Delivered' ||
-                    entry.data.to === 'Cancelled' ||
-                    entry.data.to === 'Settled' ||
-                    entry.data.from === 'Created'
-                );
-            case 'ORDER_REFUND_TRANSITION':
-                return entry.data.to === 'Settled';
-            case 'ORDER_PAYMENT_TRANSITION':
-                return entry.data.to === 'Settled' || entry.data.to === 'Cancelled';
-            case 'ORDER_FULFILLMENT_TRANSITION':
-                return entry.data.to === 'Delivered' || entry.data.to === 'Shipped';
-            case 'ORDER_NOTE':
-            case 'ORDER_MODIFIED':
-            case 'ORDER_CUSTOMER_UPDATED':
-            case 'ORDER_CANCELLATION':
-                return true;
-            default:
-                return false; // All other events are secondary
-        }
-    };
+    const { getTimelineIcon, getTitle, getIconColor, getActorName, isPrimaryEvent } =
+        orderHistoryUtils(order);
 
-    // Group consecutive secondary events
-    const groupedEntries: Array<
-        | { type: 'primary'; entry: HistoryEntryItem; index: number }
-        | {
-              type: 'secondary-group';
-              entries: Array<{ entry: HistoryEntryItem; index: number }>;
-              startIndex: number;
-          }
-    > = [];
-    let currentGroup: Array<{ entry: HistoryEntryItem; index: number }> = [];
-
-    for (let i = 0; i < historyEntries.length; i++) {
-        const entry = historyEntries[i];
-        const isSecondary = !isPrimaryEvent(entry);
-
-        if (isSecondary) {
-            currentGroup.push({ entry, index: i });
-        } else {
-            // If we have accumulated secondary events, add them as a group
-            if (currentGroup.length > 0) {
-                groupedEntries.push({
-                    type: 'secondary-group',
-                    entries: currentGroup,
-                    startIndex: currentGroup[0].index,
-                });
-                currentGroup = [];
-            }
-            // Add the primary event
-            groupedEntries.push({ type: 'primary', entry, index: i });
-        }
-    }
-
-    // Don't forget the last group if it exists
-    if (currentGroup.length > 0) {
-        groupedEntries.push({
-            type: 'secondary-group',
-            entries: currentGroup,
-            startIndex: currentGroup[0].index,
-        });
-    }
-
-    const toggleGroup = (groupIndex: number) => {
-        const newExpanded = new Set(expandedGroups);
-        if (newExpanded.has(groupIndex)) {
-            newExpanded.delete(groupIndex);
-        } else {
-            newExpanded.add(groupIndex);
-        }
-        setExpandedGroups(newExpanded);
-    };
-    const getTimelineIcon = (entry: OrderHistoryProps['historyEntries'][0]) => {
-        switch (entry.type) {
-            case 'ORDER_PAYMENT_TRANSITION':
-                return <CreditCardIcon className="h-4 w-4" />;
-            case 'ORDER_REFUND_TRANSITION':
-                return <CreditCardIcon className="h-4 w-4" />;
-            case 'ORDER_NOTE':
-                return <SquarePen className="h-4 w-4" />;
-            case 'ORDER_STATE_TRANSITION':
-                if (entry.data.to === 'Delivered') {
-                    return <CheckIcon className="h-4 w-4" />;
-                }
-                if (entry.data.to === 'Cancelled') {
-                    return <Ban className="h-4 w-4" />;
-                }
-                return <ArrowRightToLine className="h-4 w-4" />;
-            case 'ORDER_FULFILLMENT_TRANSITION':
-                if (entry.data.to === 'Shipped' || entry.data.to === 'Delivered') {
-                    return <Truck className="h-4 w-4" />;
-                }
-                return <ArrowRightToLine className="h-4 w-4" />;
-            case 'ORDER_FULFILLMENT':
-                return <Truck className="h-4 w-4" />;
-            case 'ORDER_MODIFIED':
-                return <Edit3 className="h-4 w-4" />;
-            case 'ORDER_CUSTOMER_UPDATED':
-                return <UserX className="h-4 w-4" />;
-            case 'ORDER_CANCELLATION':
-                return <Ban className="h-4 w-4" />;
-            default:
-                return <CheckIcon className="h-4 w-4" />;
-        }
-    };
-
-    const getTitle = (entry: OrderHistoryProps['historyEntries'][0]) => {
-        switch (entry.type) {
-            case 'ORDER_PAYMENT_TRANSITION':
-                if (entry.data.to === 'Settled') {
-                    return <Trans>Payment settled</Trans>;
-                }
-                if (entry.data.to === 'Authorized') {
-                    return <Trans>Payment authorized</Trans>;
-                }
-                if (entry.data.to === 'Declined' || entry.data.to === 'Cancelled') {
-                    return <Trans>Payment failed</Trans>;
-                }
-                return <Trans>Payment transitioned</Trans>;
-            case 'ORDER_REFUND_TRANSITION':
-                if (entry.data.to === 'Settled') {
-                    return <Trans>Refund settled</Trans>;
-                }
-                return <Trans>Refund transitioned</Trans>;
-            case 'ORDER_NOTE':
-                return <Trans>Note added</Trans>;
-            case 'ORDER_STATE_TRANSITION': {
-                if (entry.data.from === 'Created') {
-                    return <Trans>Order placed</Trans>;
-                }
-                if (entry.data.to === 'Delivered') {
-                    return <Trans>Order fulfilled</Trans>;
-                }
-                if (entry.data.to === 'Cancelled') {
-                    return <Trans>Order cancelled</Trans>;
-                }
-                if (entry.data.to === 'Shipped') {
-                    return <Trans>Order shipped</Trans>;
-                }
-                return <Trans>Order transitioned</Trans>;
-            }
-            case 'ORDER_FULFILLMENT_TRANSITION':
-                if (entry.data.to === 'Shipped') {
-                    return <Trans>Order shipped</Trans>;
-                }
-                if (entry.data.to === 'Delivered') {
-                    return <Trans>Order delivered</Trans>;
-                }
-                return <Trans>Fulfillment transitioned</Trans>;
-            case 'ORDER_FULFILLMENT':
-                return <Trans>Fulfillment created</Trans>;
-            case 'ORDER_MODIFIED':
-                return <Trans>Order modified</Trans>;
-            case 'ORDER_CUSTOMER_UPDATED':
-                return <Trans>Customer updated</Trans>;
-            case 'ORDER_CANCELLATION':
-                return <Trans>Order cancelled</Trans>;
-            default:
-                return <Trans>{entry.type.replace(/_/g, ' ').toLowerCase()}</Trans>;
+    const renderEntryContent = (entry: HistoryEntryItem) => {
+        const props: HistoryEntryProps = {
+            entry,
+            title: getTitle(entry),
+            actorName: getActorName(entry),
+            timelineIcon: getTimelineIcon(entry),
+            timelineIconClassName: getIconColor(entry),
+            isPrimary: isPrimaryEvent(entry),
+            children: null,
+        };
+        if (entry.type === 'ORDER_NOTE') {
+            return (
+                <HistoryNoteEntry {...props} onEditNote={handleEditNote} onDeleteNote={handleDeleteNote} />
+            );
+        } else if (entry.type === 'ORDER_STATE_TRANSITION') {
+            return <OrderStateTransitionComponent {...props} />;
+        } else if (entry.type === 'ORDER_PAYMENT_TRANSITION') {
+            return <OrderPaymentTransitionComponent {...props} />;
+        } else if (entry.type === 'ORDER_REFUND_TRANSITION') {
+            return <OrderRefundTransitionComponent {...props} />;
+        } else if (entry.type === 'ORDER_FULFILLMENT_TRANSITION') {
+            return <OrderFulfillmentTransitionComponent {...props} />;
+        } else if (entry.type === 'ORDER_FULFILLMENT') {
+            return <OrderFulfillmentComponent {...props} />;
+        } else if (entry.type === 'ORDER_MODIFIED') {
+            return <OrderModifiedComponent {...props} />;
+        } else if (entry.type === 'ORDER_CUSTOMER_UPDATED') {
+            return <OrderCustomerUpdatedComponent {...props} />;
+        } else if (entry.type === 'ORDER_CANCELLATION') {
+            return <OrderCancellationComponent {...props} />;
         }
+        return null;
     };
 
     return (
-        <div className="">
-            <div className="mb-4">
+        <>
+            <HistoryTimelineWithGrouping
+                historyEntries={historyEntries}
+                isPrimaryEvent={isPrimaryEvent}
+                renderEntryContent={renderEntryContent}
+                entity={order}
+            >
                 <HistoryNoteInput onAddNote={onAddNote} />
-            </div>
-            <HistoryTimeline>
-                {groupedEntries.map((group, groupIndex) => {
-                    if (group.type === 'primary') {
-                        const entry = group.entry;
-                        return (
-                            <HistoryEntry
-                                key={entry.id}
-                                entry={entry}
-                                isNoteEntry={entry.type === 'ORDER_NOTE'}
-                                timelineIcon={getTimelineIcon(entry)}
-                                title={getTitle(entry)}
-                                isPrimary={true}
-                                customer={order.customer}
-                                onEditNote={handleEditNote}
-                                onDeleteNote={handleDeleteNote}
-                            >
-                                {entry.type === 'ORDER_NOTE' && (
-                                    <div className="space-y-2">
-                                        <p className="text-sm text-foreground">{entry.data.note}</p>
-                                        <div className="flex items-center gap-2">
-                                            <Badge
-                                                variant={entry.isPublic ? 'outline' : 'secondary'}
-                                                className="text-xs"
-                                            >
-                                                {entry.isPublic ? 'Public' : 'Private'}
-                                            </Badge>
-                                        </div>
-                                    </div>
-                                )}
-                                {entry.type === 'ORDER_STATE_TRANSITION' && entry.data.from !== 'Created' && (
-                                    <p className="text-xs text-muted-foreground">
-                                        <Trans>
-                                            From {entry.data.from} to {entry.data.to}
-                                        </Trans>
-                                    </p>
-                                )}
-                                {entry.type === 'ORDER_PAYMENT_TRANSITION' && (
-                                    <p className="text-xs text-muted-foreground">
-                                        <Trans>
-                                            Payment #{entry.data.paymentId} transitioned to {entry.data.to}
-                                        </Trans>
-                                    </p>
-                                )}
-                                {entry.type === 'ORDER_REFUND_TRANSITION' && (
-                                    <p className="text-xs text-muted-foreground">
-                                        <Trans>
-                                            Refund #{entry.data.refundId} transitioned to {entry.data.to}
-                                        </Trans>
-                                    </p>
-                                )}
-                                {entry.type === 'ORDER_FULFILLMENT_TRANSITION' &&
-                                    entry.data.from !== 'Created' && (
-                                        <p className="text-xs text-muted-foreground">
-                                            <Trans>
-                                                Fulfillment #{entry.data.fulfillmentId} from {entry.data.from}{' '}
-                                                to {entry.data.to}
-                                            </Trans>
-                                        </p>
-                                    )}
-                                {entry.type === 'ORDER_FULFILLMENT' && (
-                                    <p className="text-xs text-muted-foreground">
-                                        <Trans>Fulfillment #{entry.data.fulfillmentId} created</Trans>
-                                    </p>
-                                )}
-                                {entry.type === 'ORDER_MODIFIED' && (
-                                    <p className="text-xs text-muted-foreground">
-                                        <Trans>Order modification #{entry.data.modificationId}</Trans>
-                                    </p>
-                                )}
-                                {entry.type === 'ORDER_CUSTOMER_UPDATED' && (
-                                    <p className="text-xs text-muted-foreground">
-                                        <Trans>Customer information updated</Trans>
-                                    </p>
-                                )}
-                                {entry.type === 'ORDER_CANCELLATION' && (
-                                    <p className="text-xs text-muted-foreground">
-                                        <Trans>Order cancelled</Trans>
-                                    </p>
-                                )}
-                            </HistoryEntry>
-                        );
-                    } else {
-                        // Secondary group
-                        const shouldCollapse = group.entries.length > 2;
-                        const isExpanded = expandedGroups.has(groupIndex);
-                        const visibleEntries =
-                            shouldCollapse && !isExpanded ? group.entries.slice(0, 2) : group.entries;
-
-                        return (
-                            <div key={`group-${groupIndex}`}>
-                                {visibleEntries.map(({ entry }) => (
-                                    <HistoryEntry
-                                        key={entry.id}
-                                        entry={entry}
-                                        isNoteEntry={entry.type === 'ORDER_NOTE'}
-                                        timelineIcon={getTimelineIcon(entry)}
-                                        title={getTitle(entry)}
-                                        isPrimary={false}
-                                        customer={order.customer}
-                                        onEditNote={handleEditNote}
-                                        onDeleteNote={handleDeleteNote}
-                                    >
-                                        {entry.type === 'ORDER_NOTE' && (
-                                            <div className="space-y-1">
-                                                <p className="text-xs text-foreground">{entry.data.note}</p>
-                                                <Badge
-                                                    variant={entry.isPublic ? 'outline' : 'secondary'}
-                                                    className="text-xs"
-                                                >
-                                                    {entry.isPublic ? 'Public' : 'Private'}
-                                                </Badge>
-                                            </div>
-                                        )}
-                                        {entry.type === 'ORDER_STATE_TRANSITION' &&
-                                            entry.data.from !== 'Created' && (
-                                                <p className="text-xs text-muted-foreground">
-                                                    <Trans>
-                                                        From {entry.data.from} to {entry.data.to}
-                                                    </Trans>
-                                                </p>
-                                            )}
-                                        {entry.type === 'ORDER_PAYMENT_TRANSITION' && (
-                                            <p className="text-xs text-muted-foreground">
-                                                <Trans>
-                                                    Payment #{entry.data.paymentId} transitioned to{' '}
-                                                    {entry.data.to}
-                                                </Trans>
-                                            </p>
-                                        )}
-                                        {entry.type === 'ORDER_REFUND_TRANSITION' && (
-                                            <p className="text-xs text-muted-foreground">
-                                                <Trans>
-                                                    Refund #{entry.data.refundId} transitioned to{' '}
-                                                    {entry.data.to}
-                                                </Trans>
-                                            </p>
-                                        )}
-                                        {entry.type === 'ORDER_FULFILLMENT_TRANSITION' &&
-                                            entry.data.from !== 'Created' && (
-                                                <p className="text-xs text-muted-foreground">
-                                                    <Trans>
-                                                        Fulfillment #{entry.data.fulfillmentId} from{' '}
-                                                        {entry.data.from} to {entry.data.to}
-                                                    </Trans>
-                                                </p>
-                                            )}
-                                        {entry.type === 'ORDER_FULFILLMENT' && (
-                                            <p className="text-xs text-muted-foreground">
-                                                <Trans>Fulfillment #{entry.data.fulfillmentId} created</Trans>
-                                            </p>
-                                        )}
-                                        {entry.type === 'ORDER_MODIFIED' && (
-                                            <p className="text-xs text-muted-foreground">
-                                                <Trans>Order modification #{entry.data.modificationId}</Trans>
-                                            </p>
-                                        )}
-                                        {entry.type === 'ORDER_CUSTOMER_UPDATED' && (
-                                            <p className="text-xs text-muted-foreground">
-                                                <Trans>Customer information updated</Trans>
-                                            </p>
-                                        )}
-                                        {entry.type === 'ORDER_CANCELLATION' && (
-                                            <p className="text-xs text-muted-foreground">
-                                                <Trans>Order cancelled</Trans>
-                                            </p>
-                                        )}
-                                    </HistoryEntry>
-                                ))}
-
-                                {shouldCollapse && (
-                                    <div className="flex justify-center py-2">
-                                        <Button
-                                            variant="ghost"
-                                            size="sm"
-                                            onClick={() => toggleGroup(groupIndex)}
-                                            className="text-muted-foreground hover:text-foreground h-6 text-xs"
-                                        >
-                                            {isExpanded ? (
-                                                <>
-                                                    <ChevronUp className="w-3 h-3 mr-1" />
-                                                    <Trans>Show less</Trans>
-                                                </>
-                                            ) : (
-                                                <>
-                                                    <ChevronDown className="w-3 h-3 mr-1" />
-                                                    <Trans>Show all ({group.entries.length})</Trans>
-                                                </>
-                                            )}
-                                        </Button>
-                                    </div>
-                                )}
-                            </div>
-                        );
-                    }
-                })}
-            </HistoryTimeline>
+            </HistoryTimelineWithGrouping>
             <HistoryNoteEditor
-                key={noteEditorNote.noteId}
-                note={noteEditorNote.note}
+                key={noteState.noteId}
+                note={noteState.note}
                 onNoteChange={handleNoteEditorSave}
                 open={noteEditorOpen}
                 onOpenChange={setNoteEditorOpen}
-                noteId={noteEditorNote.noteId}
-                isPrivate={noteEditorNote.isPrivate}
+                noteId={noteState.noteId}
+                isPrivate={noteState.isPrivate}
             />
-        </div>
+        </>
     );
 }

+ 4 - 0
packages/dashboard/src/app/routes/_authenticated/_orders/orders.graphql.ts

@@ -315,6 +315,10 @@ export const orderHistoryDocument = graphql(`
             updatedAt
             code
             currencyCode
+            customer {
+                firstName
+                lastName
+            }
             history(options: $options) {
                 totalItems
                 items {

+ 0 - 188
packages/dashboard/src/lib/components/shared/history-timeline/history-entry.tsx

@@ -1,188 +0,0 @@
-import { Badge } from '@/vdb/components/ui/badge.js';
-import { Button } from '@/vdb/components/ui/button.js';
-import {
-    DropdownMenu,
-    DropdownMenuContent,
-    DropdownMenuItem,
-    DropdownMenuTrigger,
-} from '@/vdb/components/ui/dropdown-menu.js';
-import { Separator } from '@/vdb/components/ui/separator.js';
-import { Trans } from '@/vdb/lib/trans.js';
-import { cn } from '@/vdb/lib/utils.js';
-import { MoreVerticalIcon, PencilIcon, TrashIcon } from 'lucide-react';
-import { HistoryEntryDate } from './history-entry-date.js';
-
-export interface HistoryEntryItem {
-    id: string;
-    type: string;
-    createdAt: string;
-    isPublic: boolean;
-    administrator?: {
-        id: string;
-        firstName: string;
-        lastName: string;
-    } | null;
-    data: any;
-}
-
-interface OrderCustomer {
-    firstName: string;
-    lastName: string;
-}
-
-interface HistoryEntryProps {
-    entry: HistoryEntryItem;
-    isNoteEntry: boolean;
-    timelineIcon: React.ReactNode;
-    title: string | React.ReactNode;
-    children: React.ReactNode;
-    isPrimary?: boolean;
-    customer?: OrderCustomer | null;
-    onEditNote?: (noteId: string, note: string, isPrivate: boolean) => void;
-    onDeleteNote?: (noteId: string) => void;
-}
-
-export function HistoryEntry({
-    entry,
-    isNoteEntry,
-    timelineIcon,
-    title,
-    children,
-    isPrimary = true,
-    customer,
-    onEditNote,
-    onDeleteNote,
-}: Readonly<HistoryEntryProps>) {
-    const getIconColor = (type: string) => {
-        // Check for success states (payment settled, order delivered)
-        if (type === 'ORDER_PAYMENT_TRANSITION' && entry.data.to === 'Settled') {
-            return 'bg-success text-success-foreground';
-        }
-        if (type === 'ORDER_STATE_TRANSITION' && entry.data.to === 'Delivered') {
-            return 'bg-success text-success-foreground';
-        }
-        if (type === 'ORDER_FULFILLMENT_TRANSITION' && entry.data.to === 'Delivered') {
-            return 'bg-success text-success-foreground';
-        }
-        
-        // Check for destructive states (cancellations)
-        if (type === 'ORDER_CANCELLATION') {
-            return 'bg-destructive text-destructive-foreground';
-        }
-        if (type === 'ORDER_STATE_TRANSITION' && entry.data.to === 'Cancelled') {
-            return 'bg-destructive text-destructive-foreground';
-        }
-        if (type === 'ORDER_PAYMENT_TRANSITION' && (entry.data.to === 'Declined' || entry.data.to === 'Cancelled')) {
-            return 'bg-destructive text-destructive-foreground';
-        }
-        
-        // All other entries use neutral colors
-        return 'bg-muted text-muted-foreground';
-    };
-
-    const getActorName = () => {
-        if (entry.administrator) {
-            return `${entry.administrator.firstName} ${entry.administrator.lastName}`;
-        } else if (customer) {
-            return `${customer.firstName} ${customer.lastName}`;
-        }
-        return '';
-    };
-
-    return (
-        <div key={entry.id} className="relative group">
-            <div
-                className={`flex gap-3 p-3 rounded-lg hover:bg-muted/30 transition-colors ${!isPrimary ? 'opacity-75' : ''}`}
-            >
-                <div className={cn(`relative z-10 flex-shrink-0 ${isNoteEntry ? 'ml-2' : ''}`, isPrimary ? '-ml-1' : '')}>
-                    <div
-                        className={`rounded-full flex items-center justify-center ${isPrimary ? 'h-8 w-8' : 'h-6 w-6'} ${getIconColor(entry.type)} border-2 border-background ${isPrimary ? 'shadow-sm' : 'shadow-none'}`}
-                    >
-                        <div className={isPrimary ? 'text-current' : 'text-current scale-75'}>
-                            {timelineIcon}
-                        </div>
-                    </div>
-                </div>
-
-                <div className="flex-1 min-w-0">
-                    <div className="flex items-start justify-between">
-                        <div className="flex-1 min-w-0">
-                            <h4
-                                className={`text-sm ${isPrimary ? 'font-medium text-foreground' : 'font-normal text-muted-foreground'}`}
-                            >
-                                {title}
-                            </h4>
-                            <div className="mt-1">
-                                {entry.type === 'ORDER_NOTE' ? (
-                                    <div className={`space-y-${isPrimary ? '2' : '1'}`}>
-                                        <p className={`${isPrimary ? 'text-sm' : 'text-xs'} text-foreground`}>
-                                            {entry.data.note}
-                                        </p>
-                                        <div className="flex items-center gap-2">
-                                            <Badge
-                                                variant={entry.isPublic ? 'outline' : 'secondary'}
-                                                className="text-xs"
-                                            >
-                                                {entry.isPublic ? 'Public' : 'Private'}
-                                            </Badge>
-                                            {isPrimary && onEditNote && onDeleteNote && (
-                                                <DropdownMenu>
-                                                    <DropdownMenuTrigger asChild>
-                                                        <Button variant="ghost" size="sm" className="h-6 w-6 p-0">
-                                                            <MoreVerticalIcon className="h-3 w-3" />
-                                                        </Button>
-                                                    </DropdownMenuTrigger>
-                                                    <DropdownMenuContent align="end">
-                                                        <DropdownMenuItem
-                                                            onClick={() => {
-                                                                onEditNote(
-                                                                    entry.id,
-                                                                    entry.data.note,
-                                                                    !entry.isPublic,
-                                                                );
-                                                            }}
-                                                            className="cursor-pointer"
-                                                        >
-                                                            <PencilIcon className="mr-2 h-4 w-4" />
-                                                            <Trans>Edit</Trans>
-                                                        </DropdownMenuItem>
-                                                        <Separator className="my-1" />
-                                                        <DropdownMenuItem
-                                                            onClick={() => onDeleteNote(entry.id)}
-                                                            className="cursor-pointer text-red-600 focus:text-red-600"
-                                                        >
-                                                            <TrashIcon className="mr-2 h-4 w-4" />
-                                                            <span>Delete</span>
-                                                        </DropdownMenuItem>
-                                                    </DropdownMenuContent>
-                                                </DropdownMenu>
-                                            )}
-                                        </div>
-                                    </div>
-                                ) : (
-                                    children
-                                )}
-                            </div>
-                        </div>
-
-                        <div className="flex items-center gap-2 ml-4 flex-shrink-0">
-                            <div className="text-right">
-                                <HistoryEntryDate
-                                    date={entry.createdAt}
-                                    className={`text-xs cursor-help ${isPrimary ? 'text-muted-foreground' : 'text-muted-foreground/70'}`}
-                                />
-                                {getActorName() && (
-                                    <div
-                                        className={`text-xs ${isPrimary ? 'text-muted-foreground' : 'text-muted-foreground/70'}`}
-                                    >
-                                        {getActorName()}
-                                    </div>
-                                )}
-                            </div>
-                        </div>
-                    </div>
-                </div>
-            </div>
-        </div>
-    );
-}

+ 65 - 0
packages/dashboard/src/lib/components/shared/history-timeline/history-note-entry.tsx

@@ -0,0 +1,65 @@
+import { Badge } from '@/vdb/components/ui/badge.js';
+import { Button } from '@/vdb/components/ui/button.js';
+import {
+    DropdownMenu,
+    DropdownMenuContent,
+    DropdownMenuItem,
+    DropdownMenuTrigger,
+} from '@/vdb/components/ui/dropdown-menu.js';
+import { Separator } from '@/vdb/components/ui/separator.js';
+import { HistoryEntry, HistoryEntryProps } from '@/vdb/framework/history-entry/history-entry.js';
+import { Trans } from '@/vdb/lib/trans.js';
+import { MoreVerticalIcon, PencilIcon, TrashIcon } from 'lucide-react';
+
+interface HistoryNoteEntryProps extends Readonly<HistoryEntryProps> {
+    onEditNote?: (noteId: string, note: string, isPrivate: boolean) => void;
+    onDeleteNote?: (noteId: string) => void;
+}
+
+export function HistoryNoteEntry(props: HistoryNoteEntryProps) {
+    const { entry, isPrimary, onEditNote, onDeleteNote } = props;
+    return (
+        <HistoryEntry {...props}>
+            <div className={isPrimary ? 'space-y-2' : 'space-y-1'}>
+                <div className="space-y-1">
+                    <p className={`${isPrimary ? 'text-sm' : 'text-xs'} text-foreground`}>
+                        {entry.data.note}
+                    </p>
+                </div>
+                {onEditNote && onDeleteNote && (
+                    <div className="flex items-center gap-2">
+                        <Badge variant={entry.isPublic ? 'outline' : 'secondary'} className="text-xs">
+                            {entry.isPublic ? 'Public' : 'Private'}
+                        </Badge>
+                        <DropdownMenu>
+                            <DropdownMenuTrigger asChild>
+                                <Button variant="ghost" size="sm" className="h-6 w-6 p-0">
+                                    <MoreVerticalIcon className="h-3 w-3" />
+                                </Button>
+                            </DropdownMenuTrigger>
+                            <DropdownMenuContent align="end">
+                                <DropdownMenuItem
+                                    onClick={() => {
+                                        onEditNote(entry.id, entry.data.note, !entry.isPublic);
+                                    }}
+                                    className="cursor-pointer"
+                                >
+                                    <PencilIcon className="mr-2 h-4 w-4" />
+                                    <Trans>Edit</Trans>
+                                </DropdownMenuItem>
+                                <Separator className="my-1" />
+                                <DropdownMenuItem
+                                    onClick={() => onDeleteNote(entry.id)}
+                                    className="cursor-pointer text-red-600 focus:text-red-600"
+                                >
+                                    <TrashIcon className="mr-2 h-4 w-4" />
+                                    <span>Delete</span>
+                                </DropdownMenuItem>
+                            </DropdownMenuContent>
+                        </DropdownMenu>
+                    </div>
+                )}
+            </div>
+        </HistoryEntry>
+    );
+}

+ 141 - 0
packages/dashboard/src/lib/components/shared/history-timeline/history-timeline-with-grouping.tsx

@@ -0,0 +1,141 @@
+import { Button } from '@/vdb/components/ui/button.js';
+import { HistoryEntryItem } from '@/vdb/framework/extension-api/types/history-entries.js';
+import { getCustomHistoryEntryForType } from '@/vdb/framework/history-entry/history-entry-extensions.js';
+import { ChevronDown, ChevronUp } from 'lucide-react';
+import { ReactNode, useState } from 'react';
+import { CustomerHistoryCustomerDetail } from '../../../../app/routes/_authenticated/_customers/components/customer-history/customer-history-types.js';
+import { OrderHistoryOrderDetail } from '../../../../app/routes/_authenticated/_orders/components/order-history/order-history-types.js';
+import { HistoryTimeline } from './history-timeline.js';
+
+interface HistoryTimelineWithGroupingProps {
+    historyEntries: HistoryEntryItem[];
+    entity: OrderHistoryOrderDetail | CustomerHistoryCustomerDetail;
+    isPrimaryEvent: (entry: HistoryEntryItem) => boolean;
+    renderEntryContent: (entry: HistoryEntryItem) => ReactNode;
+    children?: ReactNode;
+}
+
+type EntryWithIndex = {
+    entry: HistoryEntryItem;
+    index: number;
+};
+
+export function HistoryTimelineWithGrouping({
+    historyEntries,
+    entity,
+    isPrimaryEvent,
+    renderEntryContent,
+    children,
+}: Readonly<HistoryTimelineWithGroupingProps>) {
+    const [expandedGroups, setExpandedGroups] = useState<Set<number>>(new Set());
+
+    // Group consecutive secondary events
+    const groupedEntries: Array<
+        | ({ type: 'primary' } & EntryWithIndex)
+        | {
+              type: 'secondary-group';
+              entries: Array<EntryWithIndex>;
+              startIndex: number;
+          }
+    > = [];
+    let currentGroup: Array<EntryWithIndex> = [];
+
+    for (let i = 0; i < historyEntries.length; i++) {
+        const entry = historyEntries[i];
+        const isSecondary = !isPrimaryEvent(entry);
+
+        if (isSecondary) {
+            currentGroup.push({ entry, index: i });
+        } else {
+            // If we have accumulated secondary events, add them as a group
+            if (currentGroup.length > 0) {
+                groupedEntries.push({
+                    type: 'secondary-group',
+                    entries: currentGroup,
+                    startIndex: currentGroup[0].index,
+                });
+                currentGroup = [];
+            }
+            // Add the primary event
+            groupedEntries.push({ type: 'primary', entry, index: i });
+        }
+    }
+
+    // Don't forget the last group if it exists
+    if (currentGroup.length > 0) {
+        groupedEntries.push({
+            type: 'secondary-group',
+            entries: currentGroup,
+            startIndex: currentGroup[0].index,
+        });
+    }
+
+    const renderEntry = (entry: HistoryEntryItem) => {
+        const CustomType = getCustomHistoryEntryForType(entry.type);
+        if (CustomType) {
+            return <CustomType entry={entry} entity={entity} />;
+        } else {
+            return renderEntryContent(entry);
+        }
+    };
+
+    const toggleGroup = (groupIndex: number) => {
+        const newExpanded = new Set(expandedGroups);
+        if (newExpanded.has(groupIndex)) {
+            newExpanded.delete(groupIndex);
+        } else {
+            newExpanded.add(groupIndex);
+        }
+        setExpandedGroups(newExpanded);
+    };
+
+    return (
+        <div className="">
+            {children && <div className="mb-4">{children}</div>}
+            <HistoryTimeline>
+                {groupedEntries.map((group, groupIndex) => {
+                    if (group.type === 'primary') {
+                        const entry = group.entry;
+                        return <div key={entry.id}>{renderEntry(entry)}</div>;
+                    } else {
+                        // Secondary group
+                        const shouldCollapse = group.entries.length > 2;
+                        const isExpanded = expandedGroups.has(groupIndex);
+                        const visibleEntries =
+                            shouldCollapse && !isExpanded ? group.entries.slice(0, 2) : group.entries;
+
+                        return (
+                            <div key={`group-${groupIndex}`}>
+                                {visibleEntries.map(({ entry }) => (
+                                    <div key={entry.id}>{renderEntry(entry)}</div>
+                                ))}
+                                {shouldCollapse && (
+                                    <div className="flex justify-center py-2">
+                                        <Button
+                                            variant="ghost"
+                                            size="sm"
+                                            onClick={() => toggleGroup(groupIndex)}
+                                            className="text-muted-foreground hover:text-foreground h-6 text-xs"
+                                        >
+                                            {isExpanded ? (
+                                                <>
+                                                    <ChevronUp className="w-3 h-3 mr-1" />
+                                                    Show less
+                                                </>
+                                            ) : (
+                                                <>
+                                                    <ChevronDown className="w-3 h-3 mr-1" />
+                                                    Show all ({group.entries.length - 2})
+                                                </>
+                                            )}
+                                        </Button>
+                                    </div>
+                                )}
+                            </div>
+                        );
+                    }
+                })}
+            </HistoryTimeline>
+        </div>
+    );
+}

+ 26 - 0
packages/dashboard/src/lib/components/shared/history-timeline/use-history-note-editor.ts

@@ -0,0 +1,26 @@
+import { useState } from 'react';
+
+export function useHistoryNoteEditor() {
+    const [noteEditorOpen, setNoteEditorOpen] = useState(false);
+    const [noteState, setNoteState] = useState<{
+        noteId: string;
+        note: string;
+        isPrivate: boolean;
+    }>({
+        noteId: '',
+        note: '',
+        isPrivate: true,
+    });
+
+    const handleEditNote = (noteId: string, note: string, isPrivate: boolean) => {
+        setNoteState({ noteId, note, isPrivate });
+        setNoteEditorOpen(true);
+    };
+
+    return {
+        noteEditorOpen,
+        setNoteEditorOpen,
+        noteState,
+        handleEditNote,
+    };
+}

+ 5 - 0
packages/dashboard/src/lib/framework/extension-api/define-dashboard-extension.ts

@@ -6,6 +6,7 @@ import {
     registerDataTableExtensions,
     registerDetailFormExtensions,
     registerFormComponentExtensions,
+    registerHistoryEntryComponents,
     registerLayoutExtensions,
     registerLoginExtensions,
     registerNavigationExtensions,
@@ -40,6 +41,7 @@ export function executeDashboardExtensionCallbacks() {
  * - Data tables
  * - Detail forms
  * - Login
+ * - Custom history entries
  *
  * @example
  * ```tsx
@@ -83,6 +85,9 @@ export function defineDashboardExtension(extension: DashboardExtension) {
         // Register login extensions
         registerLoginExtensions(extension.login);
 
+        // Register custom history entry components
+        registerHistoryEntryComponents(extension.historyEntries);
+
         // Execute extension source change callbacks
         const callbacks = globalRegistry.get('extensionSourceChangeCallbacks');
         if (callbacks.size) {

+ 7 - 0
packages/dashboard/src/lib/framework/extension-api/extension-api-types.ts

@@ -5,6 +5,7 @@ import {
     DashboardCustomFormComponents,
     DashboardDataTableExtensionDefinition,
     DashboardDetailFormExtensionDefinition,
+    DashboardHistoryEntryComponent,
     DashboardLoginExtensions,
     DashboardNavSectionDefinition,
     DashboardPageBlockDefinition,
@@ -82,4 +83,10 @@ export interface DashboardExtension {
      * Allows you to customize the login page with custom components.
      */
     login?: DashboardLoginExtensions;
+    /**
+     * @description
+     * Allows a custom component to be used to render a history entry item
+     * in the Order or Customer history lists.
+     */
+    historyEntries?: DashboardHistoryEntryComponent[];
 }

+ 24 - 0
packages/dashboard/src/lib/framework/extension-api/logic/history-entries.ts

@@ -0,0 +1,24 @@
+import { DashboardHistoryEntryComponent } from '@/vdb/framework/extension-api/types/index.js';
+
+import { globalRegistry } from '../../registry/global-registry.js';
+
+export function registerHistoryEntryComponents(
+    historyEntryComponents: DashboardHistoryEntryComponent[] = [],
+) {
+    if (historyEntryComponents.length === 0) {
+        return;
+    }
+    globalRegistry.set('historyEntries', entryMap => {
+        for (const entry of historyEntryComponents) {
+            const existingEntry = entryMap.get(entry.type);
+            if (existingEntry) {
+                // eslint-disable-next-line no-console
+                console.warn(
+                    `The history entry type ${entry.type} already has a custom component registered (${String(existingEntry)}`,
+                );
+            }
+            entryMap.set(entry.type, entry.component);
+        }
+        return entryMap;
+    });
+}

+ 1 - 0
packages/dashboard/src/lib/framework/extension-api/logic/index.ts

@@ -3,6 +3,7 @@ export * from './alerts.js';
 export * from './data-table.js';
 export * from './detail-forms.js';
 export * from './form-components.js';
+export * from './history-entries.js';
 export * from './layout.js';
 export * from './login.js';
 export * from './navigation.js';

+ 120 - 0
packages/dashboard/src/lib/framework/extension-api/types/history-entries.ts

@@ -0,0 +1,120 @@
+import React from 'react';
+
+import { CustomerHistoryCustomerDetail } from '../../../../app/routes/_authenticated/_customers/components/customer-history/customer-history-types.js';
+import { OrderHistoryOrderDetail } from '../../../../app/routes/_authenticated/_orders/components/order-history/order-history-types.js';
+
+/**
+ * @description
+ * This object contains the information about the history entry.
+ *
+ * @docsCategory extensions-api
+ * @docsPage HistoryEntries
+ * @since 3.4.3
+ */
+export interface HistoryEntryItem {
+    id: string;
+    /**
+     * @description
+     * The `HistoryEntryType`, such as `ORDER_STATE_TRANSITION`.
+     */
+    type: string;
+    createdAt: string;
+    /**
+     * @description
+     * Whether this entry is visible to customers via the Shop API
+     */
+    isPublic: boolean;
+    /**
+     * @description
+     * If an Administrator created this entry, their details will
+     * be available here.
+     */
+    administrator?: {
+        id: string;
+        firstName: string;
+        lastName: string;
+    } | null;
+    /**
+     * @description
+     * The entry payload data. This will be an object, which is different
+     * for each type of history entry.
+     *
+     * For example, the `CUSTOMER_ADDED_TO_GROUP` data looks like this:
+     * ```json
+     * {
+     *   groupName: 'Some Group',
+     * }
+     * ```
+     *
+     * and the `ORDER_STATE_TRANSITION` data looks like this:
+     * ```json
+     * {
+     *   from: 'ArrangingPayment',
+     *   to: 'PaymentSettled',
+     * }
+     * ```
+     */
+    data: any;
+}
+
+/**
+ * @description
+ * A definition of a custom component that will be used to render the given
+ * type of history entry.
+ *
+ * @example
+ * ```tsx
+ * import { defineDashboardExtension, HistoryEntry } from '\@vendure/dashboard';
+ * import { IdCard } from 'lucide-react';
+ *
+ * defineDashboardExtension({
+ *     historyEntries: [
+ *         {
+ *             type: 'CUSTOMER_TAX_ID_APPROVAL',
+ *             component: ({ entry, entity }) => {
+ *                 return (
+ *                     <HistoryEntry
+ *                         entry={entry}
+ *                         title={'Tax ID verified'}
+ *                         timelineIconClassName={'bg-success text-success-foreground'}
+ *                         timelineIcon={<IdCard />}
+ *                     >
+ *                         <div className="text-xs">Approval reference: {entry.data.ref}</div>
+ *                     </HistoryEntry>
+ *                 );
+ *             },
+ *         },
+ *     ],
+ *  });
+ *  ```
+ *
+ * @docsCategory extensions-api
+ * @docsPage HistoryEntries
+ * @since 3.4.3
+ * @docsWeight 0
+ */
+export interface DashboardHistoryEntryComponent {
+    /**
+     * @description
+     * The `type` should correspond to a valid `HistoryEntryType`, such as
+     *
+     * - `CUSTOMER_REGISTERED`
+     * - `ORDER_STATE_TRANSITION`
+     * - some custom type - see the {@link HistoryService} docs for a guide on
+     *   how to define custom history entry types.
+     */
+    type: string;
+    /**
+     * @description
+     * The component which is used to render the timeline entry. It should use the
+     * {@link HistoryEntry} component and pass the appropriate props to configure
+     * how it will be displayed.
+     *
+     * The `entity` prop will be a subset of the Order object for Order history entries,
+     * or a subset of the Customer object for customer history entries.
+     */
+    component: React.ComponentType<{
+        entry: HistoryEntryItem;
+        entity: OrderHistoryOrderDetail | CustomerHistoryCustomerDetail;
+    }>;
+}

+ 1 - 0
packages/dashboard/src/lib/framework/extension-api/types/index.ts

@@ -3,6 +3,7 @@ export * from './alerts.js';
 export * from './data-table.js';
 export * from './detail-forms.js';
 export * from './form-components.js';
+export * from './history-entries.js';
 export * from './layout.js';
 export * from './login.js';
 export * from './navigation.js';

+ 11 - 0
packages/dashboard/src/lib/framework/history-entry/history-entry-extensions.ts

@@ -0,0 +1,11 @@
+import { DashboardHistoryEntryComponent } from '@/vdb/framework/extension-api/types/index.js';
+
+import { globalRegistry } from '../registry/global-registry.js';
+
+globalRegistry.register('historyEntries', new Map<string, DashboardHistoryEntryComponent['component']>());
+
+export function getCustomHistoryEntryForType(
+    type: string,
+): DashboardHistoryEntryComponent['component'] | undefined {
+    return globalRegistry.get('historyEntries').get(type);
+}

+ 129 - 0
packages/dashboard/src/lib/framework/history-entry/history-entry.tsx

@@ -0,0 +1,129 @@
+import { HistoryEntryItem } from '@/vdb/framework/extension-api/types/index.js';
+import { cn } from '@/vdb/lib/utils.js';
+import React from 'react';
+import { HistoryEntryDate } from '../../components/shared/history-timeline/history-entry-date.js';
+
+/**
+ * @description
+ * The props for the {@link HistoryEntry} component.
+ *
+ * @docsCategory extensions-api
+ * @docsPage HistoryEntries
+ * @since 3.4.3
+ */
+export interface HistoryEntryProps {
+    /**
+     * @description
+     * The entry itself, which will get passed down to your custom component
+     */
+    entry: HistoryEntryItem;
+    /**
+     * @description
+     * The title of the entry
+     */
+    title: string | React.ReactNode;
+    /**
+     * @description
+     * An icon which is used to represent the entry. Note that this will only
+     * display if `isPrimary` is `true`.
+     */
+    timelineIcon?: React.ReactNode;
+    /**
+     * @description
+     * Optional tailwind classes to apply to the icon. For instance
+     *
+     * ```ts
+     * const success = 'bg-success text-success-foreground';
+     * const destructive = 'bg-danger text-danger-foreground';
+     * ```
+     */
+    timelineIconClassName?: string;
+    /**
+     * @description
+     * The name to display of "who did the action". For instance:
+     *
+     * ```ts
+     * const getActorName = (entry: HistoryEntryItem) => {
+     *     if (entry.administrator) {
+     *         return `${entry.administrator.firstName} ${entry.administrator.lastName}`;
+     *     } else if (entity?.customer) {
+     *         return `${entity.customer.firstName} ${entity.customer.lastName}`;
+     *     }
+     *     return '';
+     * };
+     * ```
+     */
+    actorName?: string;
+    children: React.ReactNode;
+    /**
+     * @description
+     * When set to `true`, the timeline entry will feature the specified icon and will not
+     * be collapsible.
+     */
+    isPrimary?: boolean;
+}
+
+/**
+ * @description
+ * A component which is used to display a history entry in the order/customer history timeline.
+ *
+ * @docsCategory extensions-api
+ * @docsPage HistoryEntries
+ * @since 3.4.3
+ */
+export function HistoryEntry({
+    entry,
+    timelineIcon,
+    timelineIconClassName,
+    actorName,
+    title,
+    children,
+    isPrimary = true,
+}: Readonly<HistoryEntryProps>) {
+    return (
+        <div key={entry.id} className="relative group">
+            <div
+                className={`flex gap-3 p-3 rounded-lg hover:bg-muted/30 transition-colors ${!isPrimary ? 'opacity-90' : ''}`}
+            >
+                <div className={cn(`relative z-10 flex-shrink-0`, isPrimary ? 'ml-0' : 'ml-2 mt-1')}>
+                    <div
+                        className={`rounded-full flex items-center justify-center ${isPrimary ? 'h-6 w-6' : 'h-2 w-2 border'} ${timelineIconClassName ?? ''}  ${isPrimary ? 'shadow-sm' : 'shadow-none'}`}
+                    >
+                        <div className={isPrimary ? 'text-current scale-80' : 'text-current scale-0'}>
+                            {timelineIcon ?? ''}
+                        </div>
+                    </div>
+                </div>
+
+                <div className="flex-1 min-w-0">
+                    <div className="flex items-start justify-between">
+                        <div className="flex-1 min-w-0">
+                            <h4
+                                className={`text-sm ${isPrimary ? 'font-medium text-foreground' : 'font-normal text-muted-foreground'}`}
+                            >
+                                {title}
+                            </h4>
+                            <div className="mt-1">{children}</div>
+                        </div>
+
+                        <div className="flex items-center gap-2 ml-4 flex-shrink-0">
+                            <div className="text-right">
+                                <HistoryEntryDate
+                                    date={entry.createdAt}
+                                    className={`text-xs cursor-help ${isPrimary ? 'text-muted-foreground' : 'text-muted-foreground/70'}`}
+                                />
+                                {actorName && (
+                                    <div
+                                        className={`text-xs ${isPrimary ? 'text-muted-foreground' : 'text-muted-foreground/70'}`}
+                                    >
+                                        {actorName}
+                                    </div>
+                                )}
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    );
+}

+ 2 - 0
packages/dashboard/src/lib/framework/registry/registry-types.ts

@@ -1,6 +1,7 @@
 import {
     BulkAction,
     DashboardActionBarItem,
+    DashboardHistoryEntryComponent,
     DashboardLoginExtensions,
     DashboardPageBlockDefinition,
     DashboardWidgetDefinition,
@@ -26,4 +27,5 @@ export interface GlobalRegistryContents {
     listQueryDocumentRegistry: Map<string, DocumentNode[]>;
     detailQueryDocumentRegistry: Map<string, DocumentNode[]>;
     loginExtensions: DashboardLoginExtensions;
+    historyEntries: Map<string, DashboardHistoryEntryComponent['component']>;
 }

+ 5 - 1
packages/dashboard/src/lib/index.ts

@@ -96,10 +96,10 @@ export * from './components/shared/facet-value-chip.js';
 export * from './components/shared/facet-value-selector.js';
 export * from './components/shared/form-field-wrapper.js';
 export * from './components/shared/history-timeline/history-entry-date.js';
-export * from './components/shared/history-timeline/history-entry.js';
 export * from './components/shared/history-timeline/history-note-checkbox.js';
 export * from './components/shared/history-timeline/history-note-editor.js';
 export * from './components/shared/history-timeline/history-note-input.js';
+export * from './components/shared/history-timeline/history-timeline-with-grouping.js';
 export * from './components/shared/history-timeline/history-timeline.js';
 export * from './components/shared/icon-mark.js';
 export * from './components/shared/language-selector.js';
@@ -198,6 +198,7 @@ export * from './framework/extension-api/logic/alerts.js';
 export * from './framework/extension-api/logic/data-table.js';
 export * from './framework/extension-api/logic/detail-forms.js';
 export * from './framework/extension-api/logic/form-components.js';
+export * from './framework/extension-api/logic/history-entries.js';
 export * from './framework/extension-api/logic/layout.js';
 export * from './framework/extension-api/logic/login.js';
 export * from './framework/extension-api/logic/navigation.js';
@@ -206,6 +207,7 @@ export * from './framework/extension-api/types/alerts.js';
 export * from './framework/extension-api/types/data-table.js';
 export * from './framework/extension-api/types/detail-forms.js';
 export * from './framework/extension-api/types/form-components.js';
+export * from './framework/extension-api/types/history-entries.js';
 export * from './framework/extension-api/types/layout.js';
 export * from './framework/extension-api/types/login.js';
 export * from './framework/extension-api/types/navigation.js';
@@ -222,6 +224,8 @@ export * from './framework/form-engine/overridden-form-component.js';
 export * from './framework/form-engine/use-generated-form.js';
 export * from './framework/form-engine/utils.js';
 export * from './framework/form-engine/value-transformers.js';
+export * from './framework/history-entry/history-entry-extensions.js';
+export * from './framework/history-entry/history-entry.js';
 export * from './framework/layout-engine/dev-mode-button.js';
 export * from './framework/layout-engine/layout-extensions.js';
 export * from './framework/layout-engine/location-wrapper.js';