@vendure/dashboard 3.4.3-master-202509200226 → 3.4.3-master-202509240228

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/package.json +4 -4
  2. package/src/app/routes/_authenticated/_countries/countries.graphql.ts +2 -0
  3. package/src/app/routes/_authenticated/_customers/components/customer-history/customer-history-container.tsx +2 -2
  4. package/src/app/routes/_authenticated/_customers/components/customer-history/customer-history-types.ts +5 -0
  5. package/src/app/routes/_authenticated/_customers/components/customer-history/customer-history-utils.tsx +124 -0
  6. package/src/app/routes/_authenticated/_customers/components/customer-history/customer-history.tsx +91 -59
  7. package/src/app/routes/_authenticated/_customers/components/customer-history/default-customer-history-components.tsx +176 -0
  8. package/src/app/routes/_authenticated/_customers/components/customer-history/index.ts +4 -2
  9. package/src/app/routes/_authenticated/_customers/customers.graphql.ts +2 -0
  10. package/src/app/routes/_authenticated/_orders/components/order-detail-shared.tsx +302 -0
  11. package/src/app/routes/_authenticated/_orders/components/order-history/default-order-history-components.tsx +98 -0
  12. package/src/app/routes/_authenticated/_orders/components/order-history/order-history-container.tsx +9 -7
  13. package/src/app/routes/_authenticated/_orders/components/order-history/order-history-types.ts +5 -0
  14. package/src/app/routes/_authenticated/_orders/components/order-history/order-history-utils.tsx +173 -0
  15. package/src/app/routes/_authenticated/_orders/components/order-history/order-history.tsx +64 -408
  16. package/src/app/routes/_authenticated/_orders/components/order-table-totals.tsx +16 -0
  17. package/src/app/routes/_authenticated/_orders/components/seller-orders-card.tsx +61 -0
  18. package/src/app/routes/_authenticated/_orders/components/use-transition-order-to-state.tsx +17 -10
  19. package/src/app/routes/_authenticated/_orders/orders.graphql.ts +35 -0
  20. package/src/app/routes/_authenticated/_orders/orders_.$aggregateOrderId_.seller-orders.$sellerOrderId.tsx +50 -0
  21. package/src/app/routes/_authenticated/_orders/orders_.$id.tsx +17 -290
  22. package/src/app/routes/_authenticated/_orders/orders_.$id_.modify.tsx +7 -39
  23. package/src/app/routes/_authenticated/_orders/orders_.draft.$id.tsx +4 -26
  24. package/src/app/routes/_authenticated/_orders/utils/order-detail-loaders.tsx +129 -0
  25. package/src/app/routes/_authenticated/_orders/utils/order-utils.ts +8 -0
  26. package/src/app/routes/_authenticated/_roles/components/permissions-table-grid.tsx +251 -0
  27. package/src/app/routes/_authenticated/_roles/roles_.$id.tsx +5 -3
  28. package/src/lib/components/shared/detail-page-button.tsx +6 -4
  29. package/src/lib/components/shared/history-timeline/history-note-entry.tsx +65 -0
  30. package/src/lib/components/shared/history-timeline/history-timeline-with-grouping.tsx +141 -0
  31. package/src/lib/components/shared/history-timeline/use-history-note-editor.ts +26 -0
  32. package/src/lib/framework/extension-api/define-dashboard-extension.ts +5 -0
  33. package/src/lib/framework/extension-api/extension-api-types.ts +7 -0
  34. package/src/lib/framework/extension-api/logic/history-entries.ts +24 -0
  35. package/src/lib/framework/extension-api/logic/index.ts +1 -0
  36. package/src/lib/framework/extension-api/types/history-entries.ts +120 -0
  37. package/src/lib/framework/extension-api/types/index.ts +1 -0
  38. package/src/lib/framework/history-entry/history-entry-extensions.ts +11 -0
  39. package/src/lib/framework/history-entry/history-entry.tsx +129 -0
  40. package/src/lib/framework/layout-engine/page-layout.tsx +96 -9
  41. package/src/lib/framework/registry/registry-types.ts +2 -0
  42. package/src/lib/index.ts +12 -1
  43. package/src/app/routes/_authenticated/_roles/components/permissions-grid.tsx +0 -120
  44. package/src/lib/components/shared/history-timeline/history-entry.tsx +0 -188
@@ -1,3 +1,5 @@
1
+ import { DEFAULT_CHANNEL_CODE } from '@/vdb/constants.js';
2
+
1
3
  import { Fulfillment, Order, Payment } from './order-types.js';
2
4
 
3
5
  /**
@@ -76,3 +78,9 @@ export function canAddFulfillment(order: Order): boolean {
76
78
  isFulfillableState
77
79
  );
78
80
  }
81
+
82
+ export function getSeller<T>(order: { channels: Array<{ code: string; seller: T }> }) {
83
+ // Find the seller channel (non-default channel)
84
+ const sellerChannel = order.channels.find(channel => channel.code !== DEFAULT_CHANNEL_CODE);
85
+ return sellerChannel?.seller;
86
+ }
@@ -0,0 +1,251 @@
1
+ import { Button } from '@/vdb/components/ui/button.js';
2
+ import { Switch } from '@/vdb/components/ui/switch.js';
3
+ import { Table, TableBody, TableCell, TableRow } from '@/vdb/components/ui/table.js';
4
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/vdb/components/ui/tooltip.js';
5
+ import { useGroupedPermissions } from '@/vdb/hooks/use-grouped-permissions.js';
6
+ import { Trans, useLingui } from '@/vdb/lib/trans.js';
7
+ import { ServerConfig } from '@/vdb/providers/server-config.js';
8
+ import { InfoIcon } from 'lucide-react';
9
+
10
+ interface PermissionsTableGridProps {
11
+ value: string[];
12
+ onChange: (permissions: string[]) => void;
13
+ readonly?: boolean;
14
+ }
15
+
16
+ export function PermissionsTableGrid({
17
+ value,
18
+ onChange,
19
+ readonly = false,
20
+ }: Readonly<PermissionsTableGridProps>) {
21
+ const { i18n } = useLingui();
22
+ const groupedPermissions = useGroupedPermissions();
23
+
24
+ const setPermission = (permission: string, checked: boolean) => {
25
+ if (readonly) return;
26
+
27
+ const newPermissions = checked ? [...value, permission] : value.filter(p => p !== permission);
28
+ onChange(newPermissions);
29
+ };
30
+
31
+ const toggleAll = (defs: ServerConfig['permissions']) => {
32
+ if (readonly) return;
33
+
34
+ const shouldEnable = defs.some(d => !value.includes(d.name));
35
+ const newPermissions = shouldEnable
36
+ ? [...new Set([...value, ...defs.map(d => d.name)])]
37
+ : value.filter(p => !defs.some(d => d.name === p));
38
+ onChange(newPermissions);
39
+ };
40
+
41
+ // Extract CRUD operation from permission name (e.g., "CreateAdministrator" -> "Create")
42
+ const getPermissionLabel = (permission: ServerConfig['permissions'][0], groupLabel: string) => {
43
+ const name = permission.name;
44
+ const crudPrefixes = ['Create', 'Read', 'Update', 'Delete'];
45
+
46
+ for (const prefix of crudPrefixes) {
47
+ if (name.startsWith(prefix)) {
48
+ // Check if the rest matches the group name (singular form)
49
+ const remainder = name.substring(prefix.length);
50
+ const groupSingular = groupLabel.replace(/s$/, ''); // Simple singularization
51
+ if (remainder.toLowerCase() === groupSingular.toLowerCase().replace(/\s/g, '')) {
52
+ return prefix;
53
+ }
54
+ }
55
+ }
56
+
57
+ // Fallback to full name if not a CRUD operation
58
+ return i18n.t(name);
59
+ };
60
+
61
+ return (
62
+ <div className="w-full">
63
+ {/* Desktop Table View */}
64
+ <div className="hidden md:block border rounded-lg">
65
+ <Table>
66
+ <TableBody>
67
+ {groupedPermissions.map((section, index) => (
68
+ <TableRow key={index} className="hover:bg-transparent">
69
+ <TableCell className="bg-muted/50 p-3 align-top w-[150px] min-w-[150px] border-r">
70
+ <div className="space-y-2">
71
+ <div className="flex items-center gap-2">
72
+ <span className="font-semibold text-sm">
73
+ {i18n.t(section.label)}
74
+ </span>
75
+ <TooltipProvider>
76
+ <Tooltip>
77
+ <TooltipTrigger asChild>
78
+ <InfoIcon className="h-3 w-3 text-muted-foreground" />
79
+ </TooltipTrigger>
80
+ <TooltipContent side="right" className="max-w-[250px]">
81
+ <p className="text-xs">
82
+ {i18n.t(section.description)}
83
+ </p>
84
+ </TooltipContent>
85
+ </Tooltip>
86
+ </TooltipProvider>
87
+ </div>
88
+ {section.permissions.length > 1 && !readonly && (
89
+ <Button
90
+ variant="outline"
91
+ size="sm"
92
+ onClick={() => toggleAll(section.permissions)}
93
+ className="h-6 px-2 text-xs"
94
+ >
95
+ <Trans>Toggle all</Trans>
96
+ </Button>
97
+ )}
98
+ </div>
99
+ </TableCell>
100
+ {sortPermissions(section.permissions).map((permission, permIndex) => (
101
+ <TableCell
102
+ key={permission.name}
103
+ className="p-2 text-center align-top min-w-[80px]"
104
+ colSpan={section.permissions.length === 1 ? 4 : 1}
105
+ >
106
+ <TooltipProvider>
107
+ <Tooltip>
108
+ <TooltipTrigger asChild>
109
+ <div className="flex flex-col items-center space-y-1.5">
110
+ <Switch
111
+ id={`${section.id}-${permission.name}`}
112
+ checked={value.includes(permission.name)}
113
+ onCheckedChange={checked =>
114
+ setPermission(permission.name, checked)
115
+ }
116
+ disabled={readonly}
117
+ className="scale-90"
118
+ />
119
+ <label
120
+ htmlFor={`${section.id}-${permission.name}`}
121
+ className="text-xs text-center cursor-pointer leading-tight"
122
+ >
123
+ {getPermissionLabel(permission, section.label)}
124
+ </label>
125
+ </div>
126
+ </TooltipTrigger>
127
+ <TooltipContent side="top" className="max-w-[250px]">
128
+ <div className="text-xs">
129
+ <div className="font-medium">
130
+ {i18n.t(permission.name)}
131
+ </div>
132
+ <div className="text-accent-foreground/70 mt-1">
133
+ {i18n.t(permission.description)}
134
+ </div>
135
+ </div>
136
+ </TooltipContent>
137
+ </Tooltip>
138
+ </TooltipProvider>
139
+ </TableCell>
140
+ ))}
141
+ {/* Fill remaining columns if less than 4 permissions */}
142
+ {section.permissions.length < 4 &&
143
+ section.permissions.length > 1 &&
144
+ Array.from({ length: 4 - section.permissions.length }).map(
145
+ (_, fillIndex) => (
146
+ <TableCell key={`fill-${fillIndex}`} className="p-3" />
147
+ ),
148
+ )}
149
+ </TableRow>
150
+ ))}
151
+ </TableBody>
152
+ </Table>
153
+ </div>
154
+
155
+ {/* Mobile Card View */}
156
+ <div className="md:hidden space-y-4">
157
+ {groupedPermissions.map((section, index) => (
158
+ <div key={index} className="border rounded-lg p-4 bg-card">
159
+ <div className="mb-3">
160
+ <div className="flex items-center gap-2 mb-2">
161
+ <span className="font-semibold text-sm">{i18n.t(section.label)}</span>
162
+ <TooltipProvider>
163
+ <Tooltip>
164
+ <TooltipTrigger asChild>
165
+ <InfoIcon className="h-3 w-3 text-muted-foreground" />
166
+ </TooltipTrigger>
167
+ <TooltipContent side="right" className="max-w-[250px]">
168
+ <p className="text-xs">{i18n.t(section.description)}</p>
169
+ </TooltipContent>
170
+ </Tooltip>
171
+ </TooltipProvider>
172
+ </div>
173
+ {section.permissions.length > 1 && !readonly && (
174
+ <Button
175
+ variant="outline"
176
+ size="sm"
177
+ onClick={() => toggleAll(section.permissions)}
178
+ className="h-6 px-2 text-xs"
179
+ >
180
+ <Trans>Toggle all</Trans>
181
+ </Button>
182
+ )}
183
+ </div>
184
+ <div className="grid grid-cols-2 gap-3">
185
+ {sortPermissions(section.permissions).map(permission => (
186
+ <div
187
+ key={permission.name}
188
+ className="flex items-center space-x-3 p-2 rounded border"
189
+ >
190
+ <Switch
191
+ id={`mobile-${section.id}-${permission.name}`}
192
+ checked={value.includes(permission.name)}
193
+ onCheckedChange={checked => setPermission(permission.name, checked)}
194
+ disabled={readonly}
195
+ />
196
+ <div className="flex-1 min-w-0">
197
+ <TooltipProvider>
198
+ <Tooltip>
199
+ <TooltipTrigger asChild>
200
+ <label
201
+ htmlFor={`mobile-${section.id}-${permission.name}`}
202
+ className="text-xs cursor-pointer block truncate"
203
+ >
204
+ {getPermissionLabel(permission, section.label)}
205
+ </label>
206
+ </TooltipTrigger>
207
+ <TooltipContent side="top" className="max-w-[250px]">
208
+ <div className="text-xs">
209
+ <div className="font-medium">
210
+ {i18n.t(permission.name)}
211
+ </div>
212
+ <div className="text-muted-foreground mt-1">
213
+ {i18n.t(permission.description)}
214
+ </div>
215
+ </div>
216
+ </TooltipContent>
217
+ </Tooltip>
218
+ </TooltipProvider>
219
+ </div>
220
+ </div>
221
+ ))}
222
+ </div>
223
+ </div>
224
+ ))}
225
+ </div>
226
+ </div>
227
+ );
228
+ }
229
+
230
+ // Sort permissions in CRUD order
231
+ const sortPermissions = (permissions: ServerConfig['permissions']) => {
232
+ const crudOrder = ['Create', 'Read', 'Update', 'Delete'];
233
+
234
+ return [...permissions].sort((a, b) => {
235
+ // Find the CRUD prefix for each permission
236
+ const aPrefix = crudOrder.find(prefix => a.name.startsWith(prefix));
237
+ const bPrefix = crudOrder.find(prefix => b.name.startsWith(prefix));
238
+
239
+ // If both have CRUD prefixes, sort by CRUD order
240
+ if (aPrefix && bPrefix) {
241
+ return crudOrder.indexOf(aPrefix) - crudOrder.indexOf(bPrefix);
242
+ }
243
+
244
+ // If only one has CRUD prefix, put it first
245
+ if (aPrefix && !bPrefix) return -1;
246
+ if (!aPrefix && bPrefix) return 1;
247
+
248
+ // Otherwise, keep original order
249
+ return 0;
250
+ });
251
+ };
@@ -19,7 +19,7 @@ import { useDetailPage } from '@/vdb/framework/page/use-detail-page.js';
19
19
  import { Trans, useLingui } from '@/vdb/lib/trans.js';
20
20
  import { createFileRoute, useNavigate } from '@tanstack/react-router';
21
21
  import { toast } from 'sonner';
22
- import { PermissionsGrid } from './components/permissions-grid.js';
22
+ import { PermissionsTableGrid } from './components/permissions-table-grid.js';
23
23
  import { createRoleDocument, roleDetailDocument, updateRoleDocument } from './roles.graphql.js';
24
24
 
25
25
  const pageId = 'role-detail';
@@ -61,7 +61,9 @@ function RoleDetailPage() {
61
61
  },
62
62
  params: { id: params.id },
63
63
  onSuccess: async data => {
64
- toast.success(i18n.t(creatingNewEntity ? 'Successfully created role' : 'Successfully updated role'));
64
+ toast.success(
65
+ i18n.t(creatingNewEntity ? 'Successfully created role' : 'Successfully updated role'),
66
+ );
65
67
  resetForm();
66
68
  if (creatingNewEntity) {
67
69
  await navigate({ to: `../$id`, params: { id: data.id } });
@@ -132,7 +134,7 @@ function RoleDetailPage() {
132
134
  name="permissions"
133
135
  label={<Trans>Permissions</Trans>}
134
136
  render={({ field }) => (
135
- <PermissionsGrid
137
+ <PermissionsTableGrid
136
138
  value={field.value ?? []}
137
139
  onChange={value => field.onChange(value)}
138
140
  />
@@ -13,7 +13,7 @@ import { Button } from '../ui/button.js';
13
13
  * ```tsx
14
14
  * // Basic usage with ID (relative navigation)
15
15
  * <DetailPageButton id="123" label="Product Name" />
16
- *
16
+ *
17
17
  *
18
18
  * @example
19
19
  * ```tsx
@@ -36,18 +36,20 @@ export function DetailPageButton({
36
36
  label,
37
37
  disabled,
38
38
  search,
39
- }: {
39
+ className,
40
+ }: Readonly<{
40
41
  label: string | React.ReactNode;
41
42
  id?: string;
42
43
  href?: string;
43
44
  disabled?: boolean;
44
45
  search?: Record<string, string>;
45
- }) {
46
+ className?: string;
47
+ }>) {
46
48
  if (!id && !href) {
47
49
  return <span>{label}</span>;
48
50
  }
49
51
  return (
50
- <Button asChild variant="ghost" disabled={disabled}>
52
+ <Button asChild variant="ghost" disabled={disabled} className={className}>
51
53
  <Link to={href ?? `./${id}`} search={search ?? {}} preload={false}>
52
54
  {label}
53
55
  {!disabled && <ChevronRight className="h-3 w-3 text-muted-foreground" />}
@@ -0,0 +1,65 @@
1
+ import { Badge } from '@/vdb/components/ui/badge.js';
2
+ import { Button } from '@/vdb/components/ui/button.js';
3
+ import {
4
+ DropdownMenu,
5
+ DropdownMenuContent,
6
+ DropdownMenuItem,
7
+ DropdownMenuTrigger,
8
+ } from '@/vdb/components/ui/dropdown-menu.js';
9
+ import { Separator } from '@/vdb/components/ui/separator.js';
10
+ import { HistoryEntry, HistoryEntryProps } from '@/vdb/framework/history-entry/history-entry.js';
11
+ import { Trans } from '@/vdb/lib/trans.js';
12
+ import { MoreVerticalIcon, PencilIcon, TrashIcon } from 'lucide-react';
13
+
14
+ interface HistoryNoteEntryProps extends Readonly<HistoryEntryProps> {
15
+ onEditNote?: (noteId: string, note: string, isPrivate: boolean) => void;
16
+ onDeleteNote?: (noteId: string) => void;
17
+ }
18
+
19
+ export function HistoryNoteEntry(props: HistoryNoteEntryProps) {
20
+ const { entry, isPrimary, onEditNote, onDeleteNote } = props;
21
+ return (
22
+ <HistoryEntry {...props}>
23
+ <div className={isPrimary ? 'space-y-2' : 'space-y-1'}>
24
+ <div className="space-y-1">
25
+ <p className={`${isPrimary ? 'text-sm' : 'text-xs'} text-foreground`}>
26
+ {entry.data.note}
27
+ </p>
28
+ </div>
29
+ {onEditNote && onDeleteNote && (
30
+ <div className="flex items-center gap-2">
31
+ <Badge variant={entry.isPublic ? 'outline' : 'secondary'} className="text-xs">
32
+ {entry.isPublic ? 'Public' : 'Private'}
33
+ </Badge>
34
+ <DropdownMenu>
35
+ <DropdownMenuTrigger asChild>
36
+ <Button variant="ghost" size="sm" className="h-6 w-6 p-0">
37
+ <MoreVerticalIcon className="h-3 w-3" />
38
+ </Button>
39
+ </DropdownMenuTrigger>
40
+ <DropdownMenuContent align="end">
41
+ <DropdownMenuItem
42
+ onClick={() => {
43
+ onEditNote(entry.id, entry.data.note, !entry.isPublic);
44
+ }}
45
+ className="cursor-pointer"
46
+ >
47
+ <PencilIcon className="mr-2 h-4 w-4" />
48
+ <Trans>Edit</Trans>
49
+ </DropdownMenuItem>
50
+ <Separator className="my-1" />
51
+ <DropdownMenuItem
52
+ onClick={() => onDeleteNote(entry.id)}
53
+ className="cursor-pointer text-red-600 focus:text-red-600"
54
+ >
55
+ <TrashIcon className="mr-2 h-4 w-4" />
56
+ <span>Delete</span>
57
+ </DropdownMenuItem>
58
+ </DropdownMenuContent>
59
+ </DropdownMenu>
60
+ </div>
61
+ )}
62
+ </div>
63
+ </HistoryEntry>
64
+ );
65
+ }
@@ -0,0 +1,141 @@
1
+ import { Button } from '@/vdb/components/ui/button.js';
2
+ import { HistoryEntryItem } from '@/vdb/framework/extension-api/types/history-entries.js';
3
+ import { getCustomHistoryEntryForType } from '@/vdb/framework/history-entry/history-entry-extensions.js';
4
+ import { ChevronDown, ChevronUp } from 'lucide-react';
5
+ import { ReactNode, useState } from 'react';
6
+ import { CustomerHistoryCustomerDetail } from '../../../../app/routes/_authenticated/_customers/components/customer-history/customer-history-types.js';
7
+ import { OrderHistoryOrderDetail } from '../../../../app/routes/_authenticated/_orders/components/order-history/order-history-types.js';
8
+ import { HistoryTimeline } from './history-timeline.js';
9
+
10
+ interface HistoryTimelineWithGroupingProps {
11
+ historyEntries: HistoryEntryItem[];
12
+ entity: OrderHistoryOrderDetail | CustomerHistoryCustomerDetail;
13
+ isPrimaryEvent: (entry: HistoryEntryItem) => boolean;
14
+ renderEntryContent: (entry: HistoryEntryItem) => ReactNode;
15
+ children?: ReactNode;
16
+ }
17
+
18
+ type EntryWithIndex = {
19
+ entry: HistoryEntryItem;
20
+ index: number;
21
+ };
22
+
23
+ export function HistoryTimelineWithGrouping({
24
+ historyEntries,
25
+ entity,
26
+ isPrimaryEvent,
27
+ renderEntryContent,
28
+ children,
29
+ }: Readonly<HistoryTimelineWithGroupingProps>) {
30
+ const [expandedGroups, setExpandedGroups] = useState<Set<number>>(new Set());
31
+
32
+ // Group consecutive secondary events
33
+ const groupedEntries: Array<
34
+ | ({ type: 'primary' } & EntryWithIndex)
35
+ | {
36
+ type: 'secondary-group';
37
+ entries: Array<EntryWithIndex>;
38
+ startIndex: number;
39
+ }
40
+ > = [];
41
+ let currentGroup: Array<EntryWithIndex> = [];
42
+
43
+ for (let i = 0; i < historyEntries.length; i++) {
44
+ const entry = historyEntries[i];
45
+ const isSecondary = !isPrimaryEvent(entry);
46
+
47
+ if (isSecondary) {
48
+ currentGroup.push({ entry, index: i });
49
+ } else {
50
+ // If we have accumulated secondary events, add them as a group
51
+ if (currentGroup.length > 0) {
52
+ groupedEntries.push({
53
+ type: 'secondary-group',
54
+ entries: currentGroup,
55
+ startIndex: currentGroup[0].index,
56
+ });
57
+ currentGroup = [];
58
+ }
59
+ // Add the primary event
60
+ groupedEntries.push({ type: 'primary', entry, index: i });
61
+ }
62
+ }
63
+
64
+ // Don't forget the last group if it exists
65
+ if (currentGroup.length > 0) {
66
+ groupedEntries.push({
67
+ type: 'secondary-group',
68
+ entries: currentGroup,
69
+ startIndex: currentGroup[0].index,
70
+ });
71
+ }
72
+
73
+ const renderEntry = (entry: HistoryEntryItem) => {
74
+ const CustomType = getCustomHistoryEntryForType(entry.type);
75
+ if (CustomType) {
76
+ return <CustomType entry={entry} entity={entity} />;
77
+ } else {
78
+ return renderEntryContent(entry);
79
+ }
80
+ };
81
+
82
+ const toggleGroup = (groupIndex: number) => {
83
+ const newExpanded = new Set(expandedGroups);
84
+ if (newExpanded.has(groupIndex)) {
85
+ newExpanded.delete(groupIndex);
86
+ } else {
87
+ newExpanded.add(groupIndex);
88
+ }
89
+ setExpandedGroups(newExpanded);
90
+ };
91
+
92
+ return (
93
+ <div className="">
94
+ {children && <div className="mb-4">{children}</div>}
95
+ <HistoryTimeline>
96
+ {groupedEntries.map((group, groupIndex) => {
97
+ if (group.type === 'primary') {
98
+ const entry = group.entry;
99
+ return <div key={entry.id}>{renderEntry(entry)}</div>;
100
+ } else {
101
+ // Secondary group
102
+ const shouldCollapse = group.entries.length > 2;
103
+ const isExpanded = expandedGroups.has(groupIndex);
104
+ const visibleEntries =
105
+ shouldCollapse && !isExpanded ? group.entries.slice(0, 2) : group.entries;
106
+
107
+ return (
108
+ <div key={`group-${groupIndex}`}>
109
+ {visibleEntries.map(({ entry }) => (
110
+ <div key={entry.id}>{renderEntry(entry)}</div>
111
+ ))}
112
+ {shouldCollapse && (
113
+ <div className="flex justify-center py-2">
114
+ <Button
115
+ variant="ghost"
116
+ size="sm"
117
+ onClick={() => toggleGroup(groupIndex)}
118
+ className="text-muted-foreground hover:text-foreground h-6 text-xs"
119
+ >
120
+ {isExpanded ? (
121
+ <>
122
+ <ChevronUp className="w-3 h-3 mr-1" />
123
+ Show less
124
+ </>
125
+ ) : (
126
+ <>
127
+ <ChevronDown className="w-3 h-3 mr-1" />
128
+ Show all ({group.entries.length - 2})
129
+ </>
130
+ )}
131
+ </Button>
132
+ </div>
133
+ )}
134
+ </div>
135
+ );
136
+ }
137
+ })}
138
+ </HistoryTimeline>
139
+ </div>
140
+ );
141
+ }
@@ -0,0 +1,26 @@
1
+ import { useState } from 'react';
2
+
3
+ export function useHistoryNoteEditor() {
4
+ const [noteEditorOpen, setNoteEditorOpen] = useState(false);
5
+ const [noteState, setNoteState] = useState<{
6
+ noteId: string;
7
+ note: string;
8
+ isPrivate: boolean;
9
+ }>({
10
+ noteId: '',
11
+ note: '',
12
+ isPrivate: true,
13
+ });
14
+
15
+ const handleEditNote = (noteId: string, note: string, isPrivate: boolean) => {
16
+ setNoteState({ noteId, note, isPrivate });
17
+ setNoteEditorOpen(true);
18
+ };
19
+
20
+ return {
21
+ noteEditorOpen,
22
+ setNoteEditorOpen,
23
+ noteState,
24
+ handleEditNote,
25
+ };
26
+ }
@@ -6,6 +6,7 @@ import {
6
6
  registerDataTableExtensions,
7
7
  registerDetailFormExtensions,
8
8
  registerFormComponentExtensions,
9
+ registerHistoryEntryComponents,
9
10
  registerLayoutExtensions,
10
11
  registerLoginExtensions,
11
12
  registerNavigationExtensions,
@@ -40,6 +41,7 @@ export function executeDashboardExtensionCallbacks() {
40
41
  * - Data tables
41
42
  * - Detail forms
42
43
  * - Login
44
+ * - Custom history entries
43
45
  *
44
46
  * @example
45
47
  * ```tsx
@@ -83,6 +85,9 @@ export function defineDashboardExtension(extension: DashboardExtension) {
83
85
  // Register login extensions
84
86
  registerLoginExtensions(extension.login);
85
87
 
88
+ // Register custom history entry components
89
+ registerHistoryEntryComponents(extension.historyEntries);
90
+
86
91
  // Execute extension source change callbacks
87
92
  const callbacks = globalRegistry.get('extensionSourceChangeCallbacks');
88
93
  if (callbacks.size) {
@@ -5,6 +5,7 @@ import {
5
5
  DashboardCustomFormComponents,
6
6
  DashboardDataTableExtensionDefinition,
7
7
  DashboardDetailFormExtensionDefinition,
8
+ DashboardHistoryEntryComponent,
8
9
  DashboardLoginExtensions,
9
10
  DashboardNavSectionDefinition,
10
11
  DashboardPageBlockDefinition,
@@ -82,4 +83,10 @@ export interface DashboardExtension {
82
83
  * Allows you to customize the login page with custom components.
83
84
  */
84
85
  login?: DashboardLoginExtensions;
86
+ /**
87
+ * @description
88
+ * Allows a custom component to be used to render a history entry item
89
+ * in the Order or Customer history lists.
90
+ */
91
+ historyEntries?: DashboardHistoryEntryComponent[];
85
92
  }
@@ -0,0 +1,24 @@
1
+ import { DashboardHistoryEntryComponent } from '@/vdb/framework/extension-api/types/index.js';
2
+
3
+ import { globalRegistry } from '../../registry/global-registry.js';
4
+
5
+ export function registerHistoryEntryComponents(
6
+ historyEntryComponents: DashboardHistoryEntryComponent[] = [],
7
+ ) {
8
+ if (historyEntryComponents.length === 0) {
9
+ return;
10
+ }
11
+ globalRegistry.set('historyEntries', entryMap => {
12
+ for (const entry of historyEntryComponents) {
13
+ const existingEntry = entryMap.get(entry.type);
14
+ if (existingEntry) {
15
+ // eslint-disable-next-line no-console
16
+ console.warn(
17
+ `The history entry type ${entry.type} already has a custom component registered (${String(existingEntry)}`,
18
+ );
19
+ }
20
+ entryMap.set(entry.type, entry.component);
21
+ }
22
+ return entryMap;
23
+ });
24
+ }
@@ -3,6 +3,7 @@ export * from './alerts.js';
3
3
  export * from './data-table.js';
4
4
  export * from './detail-forms.js';
5
5
  export * from './form-components.js';
6
+ export * from './history-entries.js';
6
7
  export * from './layout.js';
7
8
  export * from './login.js';
8
9
  export * from './navigation.js';