@vendure/dashboard 3.4.3-master-202509190229 → 3.4.3-master-202509230228
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.
- package/dist/vite/vite-plugin-config.js +1 -0
- package/package.json +4 -4
- package/src/app/routes/_authenticated/_administrators/administrators.tsx +1 -2
- package/src/app/routes/_authenticated/_assets/assets.graphql.ts +39 -0
- package/src/app/routes/_authenticated/_assets/assets_.$id.tsx +18 -7
- package/src/app/routes/_authenticated/_assets/components/asset-tag-filter.tsx +206 -0
- package/src/app/routes/_authenticated/_assets/components/asset-tags-editor.tsx +226 -0
- package/src/app/routes/_authenticated/_assets/components/manage-tags-dialog.tsx +217 -0
- package/src/app/routes/_authenticated/_channels/channels.tsx +1 -2
- package/src/app/routes/_authenticated/_collections/collections.tsx +2 -16
- package/src/app/routes/_authenticated/_countries/countries.graphql.ts +2 -0
- package/src/app/routes/_authenticated/_countries/countries.tsx +1 -2
- package/src/app/routes/_authenticated/_customer-groups/customer-groups.tsx +1 -2
- package/src/app/routes/_authenticated/_customers/customers.tsx +1 -2
- package/src/app/routes/_authenticated/_facets/facets.tsx +0 -1
- package/src/app/routes/_authenticated/_orders/components/order-detail-shared.tsx +302 -0
- package/src/app/routes/_authenticated/_orders/components/order-table-totals.tsx +16 -0
- package/src/app/routes/_authenticated/_orders/components/seller-orders-card.tsx +61 -0
- package/src/app/routes/_authenticated/_orders/components/use-transition-order-to-state.tsx +17 -10
- package/src/app/routes/_authenticated/_orders/orders.graphql.ts +31 -0
- package/src/app/routes/_authenticated/_orders/orders_.$aggregateOrderId_.seller-orders.$sellerOrderId.tsx +50 -0
- package/src/app/routes/_authenticated/_orders/orders_.$id.tsx +17 -290
- package/src/app/routes/_authenticated/_orders/orders_.$id_.modify.tsx +7 -39
- package/src/app/routes/_authenticated/_orders/orders_.draft.$id.tsx +4 -26
- package/src/app/routes/_authenticated/_orders/utils/order-detail-loaders.tsx +129 -0
- package/src/app/routes/_authenticated/_orders/utils/order-utils.ts +8 -0
- package/src/app/routes/_authenticated/_payment-methods/payment-methods.tsx +1 -2
- package/src/app/routes/_authenticated/_product-variants/product-variants.tsx +1 -2
- package/src/app/routes/_authenticated/_products/products.tsx +1 -2
- package/src/app/routes/_authenticated/_promotions/promotions.tsx +1 -2
- package/src/app/routes/_authenticated/_roles/components/permissions-table-grid.tsx +251 -0
- package/src/app/routes/_authenticated/_roles/roles.tsx +1 -2
- package/src/app/routes/_authenticated/_roles/roles_.$id.tsx +5 -3
- package/src/app/routes/_authenticated/_sellers/sellers.tsx +1 -2
- package/src/app/routes/_authenticated/_shipping-methods/shipping-methods.tsx +1 -2
- package/src/app/routes/_authenticated/_stock-locations/stock-locations.tsx +1 -2
- package/src/app/routes/_authenticated/_tax-categories/tax-categories.tsx +1 -2
- package/src/app/routes/_authenticated/_tax-rates/tax-rates.tsx +1 -2
- package/src/app/routes/_authenticated/_zones/zones.tsx +1 -2
- package/src/lib/components/data-table/data-table-bulk-actions.tsx +5 -14
- package/src/lib/components/data-table/use-all-bulk-actions.ts +19 -0
- package/src/lib/components/data-table/use-generated-columns.tsx +12 -3
- package/src/lib/components/layout/nav-main.tsx +50 -25
- package/src/lib/components/shared/asset/asset-focal-point-editor.tsx +1 -1
- package/src/lib/components/shared/asset/asset-gallery.tsx +83 -50
- package/src/lib/components/shared/detail-page-button.tsx +6 -4
- package/src/lib/components/shared/paginated-list-data-table.tsx +1 -0
- package/src/lib/components/shared/vendure-image.tsx +9 -1
- package/src/lib/framework/defaults.ts +24 -0
- package/src/lib/framework/extension-api/types/navigation.ts +8 -0
- package/src/lib/framework/layout-engine/page-layout.tsx +96 -9
- package/src/lib/framework/nav-menu/nav-menu-extensions.ts +26 -0
- package/src/lib/framework/page/list-page.tsx +7 -0
- package/src/lib/hooks/use-custom-field-config.ts +19 -2
- package/src/lib/index.ts +7 -1
- package/src/lib/providers/channel-provider.tsx +22 -6
- package/src/lib/providers/server-config.tsx +1 -0
- package/src/app/routes/_authenticated/_collections/components/move-single-collection.tsx +0 -33
- package/src/app/routes/_authenticated/_roles/components/permissions-grid.tsx +0 -120
- package/src/lib/components/shared/asset/focal-point-control.tsx +0 -57
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { Button } from '@/vdb/components/ui/button.js';
|
|
2
|
+
import {
|
|
3
|
+
Dialog,
|
|
4
|
+
DialogContent,
|
|
5
|
+
DialogDescription,
|
|
6
|
+
DialogFooter,
|
|
7
|
+
DialogHeader,
|
|
8
|
+
DialogTitle,
|
|
9
|
+
} from '@/vdb/components/ui/dialog.js';
|
|
10
|
+
import { Input } from '@/vdb/components/ui/input.js';
|
|
11
|
+
import { api } from '@/vdb/graphql/api.js';
|
|
12
|
+
import { Trans } from '@/vdb/lib/trans.js';
|
|
13
|
+
import { cn } from '@/vdb/lib/utils.js';
|
|
14
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
15
|
+
import { Trash2 } from 'lucide-react';
|
|
16
|
+
import { useState } from 'react';
|
|
17
|
+
import { toast } from 'sonner';
|
|
18
|
+
import { deleteTagDocument, tagListDocument, updateTagDocument } from '../assets.graphql.js';
|
|
19
|
+
|
|
20
|
+
interface ManageTagsDialogProps {
|
|
21
|
+
open: boolean;
|
|
22
|
+
onOpenChange: (open: boolean) => void;
|
|
23
|
+
onTagsUpdated?: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function ManageTagsDialog({ open, onOpenChange, onTagsUpdated }: Readonly<ManageTagsDialogProps>) {
|
|
27
|
+
const queryClient = useQueryClient();
|
|
28
|
+
const [toDelete, setToDelete] = useState<string[]>([]);
|
|
29
|
+
const [toUpdate, setToUpdate] = useState<Array<{ id: string; value: string }>>([]);
|
|
30
|
+
const [isSaving, setIsSaving] = useState(false);
|
|
31
|
+
|
|
32
|
+
// Fetch all tags
|
|
33
|
+
const { data: tagsData, isLoading } = useQuery({
|
|
34
|
+
queryKey: ['tags'],
|
|
35
|
+
queryFn: () => api.query(tagListDocument, { options: { take: 100 } }),
|
|
36
|
+
staleTime: 1000 * 60 * 5,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Update tag mutation
|
|
40
|
+
const updateTagMutation = useMutation({
|
|
41
|
+
mutationFn: ({ id, value }: { id: string; value: string }) =>
|
|
42
|
+
api.mutate(updateTagDocument, { input: { id, value } }),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Delete tag mutation
|
|
46
|
+
const deleteTagMutation = useMutation({
|
|
47
|
+
mutationFn: (id: string) => api.mutate(deleteTagDocument, { id }),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const allTags = tagsData?.tags.items || [];
|
|
51
|
+
|
|
52
|
+
const toggleDelete = (id: string) => {
|
|
53
|
+
if (toDelete.includes(id)) {
|
|
54
|
+
setToDelete(toDelete.filter(_id => _id !== id));
|
|
55
|
+
} else {
|
|
56
|
+
setToDelete([...toDelete, id]);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const markedAsDeleted = (id: string) => {
|
|
61
|
+
return toDelete.includes(id);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const updateTagValue = (id: string, value: string) => {
|
|
65
|
+
const exists = toUpdate.find(i => i.id === id);
|
|
66
|
+
if (exists) {
|
|
67
|
+
if (value === allTags.find(tag => tag.id === id)?.value) {
|
|
68
|
+
// If value is reverted to original, remove from update list
|
|
69
|
+
setToUpdate(toUpdate.filter(i => i.id !== id));
|
|
70
|
+
} else {
|
|
71
|
+
exists.value = value;
|
|
72
|
+
setToUpdate([...toUpdate]);
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
setToUpdate([...toUpdate, { id, value }]);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const getDisplayValue = (id: string) => {
|
|
80
|
+
const updateItem = toUpdate.find(i => i.id === id);
|
|
81
|
+
if (updateItem) {
|
|
82
|
+
return updateItem.value;
|
|
83
|
+
}
|
|
84
|
+
return allTags.find(tag => tag.id === id)?.value || '';
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const renderTagsList = () => {
|
|
88
|
+
if (isLoading) {
|
|
89
|
+
return (
|
|
90
|
+
<div className="text-sm text-muted-foreground">
|
|
91
|
+
<Trans>Loading tags...</Trans>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (allTags.length === 0) {
|
|
97
|
+
return (
|
|
98
|
+
<div className="text-sm text-muted-foreground">
|
|
99
|
+
<Trans>No tags found</Trans>
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return allTags.map(tag => {
|
|
105
|
+
const isDeleted = markedAsDeleted(tag.id);
|
|
106
|
+
const isModified = toUpdate.some(i => i.id === tag.id);
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div
|
|
110
|
+
key={tag.id}
|
|
111
|
+
className={cn(
|
|
112
|
+
'flex items-center gap-2 p-2 rounded-md',
|
|
113
|
+
isDeleted && 'opacity-50',
|
|
114
|
+
)}
|
|
115
|
+
>
|
|
116
|
+
<Input
|
|
117
|
+
value={getDisplayValue(tag.id)}
|
|
118
|
+
onChange={e => updateTagValue(tag.id, e.target.value)}
|
|
119
|
+
disabled={isDeleted || isSaving}
|
|
120
|
+
className={cn('flex-1', isModified && !isDeleted && 'border-primary')}
|
|
121
|
+
/>
|
|
122
|
+
<Button
|
|
123
|
+
variant={isDeleted ? 'default' : 'ghost'}
|
|
124
|
+
size="icon"
|
|
125
|
+
onClick={() => toggleDelete(tag.id)}
|
|
126
|
+
disabled={isSaving}
|
|
127
|
+
className={cn(isDeleted && 'bg-destructive hover:bg-destructive/90')}
|
|
128
|
+
>
|
|
129
|
+
<Trash2 className="h-4 w-4" />
|
|
130
|
+
</Button>
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const hasChanges = toDelete.length > 0 || toUpdate.length > 0;
|
|
137
|
+
|
|
138
|
+
const handleCancel = () => {
|
|
139
|
+
setToDelete([]);
|
|
140
|
+
setToUpdate([]);
|
|
141
|
+
onOpenChange(false);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const handleSave = async () => {
|
|
145
|
+
setIsSaving(true);
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const operations = [];
|
|
149
|
+
|
|
150
|
+
// Delete operations
|
|
151
|
+
for (const id of toDelete) {
|
|
152
|
+
operations.push(deleteTagMutation.mutateAsync(id));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Update operations (skip if marked for deletion)
|
|
156
|
+
for (const item of toUpdate) {
|
|
157
|
+
if (!toDelete.includes(item.id)) {
|
|
158
|
+
operations.push(updateTagMutation.mutateAsync(item));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
await Promise.all(operations);
|
|
163
|
+
|
|
164
|
+
// Invalidate tags query to refresh the list
|
|
165
|
+
await queryClient.invalidateQueries({ queryKey: ['tags'] });
|
|
166
|
+
|
|
167
|
+
// Also invalidate asset queries to refresh any assets using these tags
|
|
168
|
+
await queryClient.invalidateQueries({ queryKey: ['asset'] });
|
|
169
|
+
|
|
170
|
+
toast.success('Tags updated successfully');
|
|
171
|
+
|
|
172
|
+
// Call callback to notify parent component
|
|
173
|
+
if (onTagsUpdated) {
|
|
174
|
+
onTagsUpdated();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Reset state
|
|
178
|
+
setToDelete([]);
|
|
179
|
+
setToUpdate([]);
|
|
180
|
+
onOpenChange(false);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
toast.error('Failed to update tags', {
|
|
183
|
+
description: error instanceof Error ? error.message : 'Unknown error',
|
|
184
|
+
});
|
|
185
|
+
} finally {
|
|
186
|
+
setIsSaving(false);
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
192
|
+
<DialogContent className="max-w-md">
|
|
193
|
+
<DialogHeader>
|
|
194
|
+
<DialogTitle>
|
|
195
|
+
<Trans>Manage Tags</Trans>
|
|
196
|
+
</DialogTitle>
|
|
197
|
+
<DialogDescription>
|
|
198
|
+
<Trans>Edit or delete existing tags</Trans>
|
|
199
|
+
</DialogDescription>
|
|
200
|
+
</DialogHeader>
|
|
201
|
+
|
|
202
|
+
<div className="max-h-[400px] overflow-y-auto space-y-2 py-4">
|
|
203
|
+
{renderTagsList()}
|
|
204
|
+
</div>
|
|
205
|
+
|
|
206
|
+
<DialogFooter>
|
|
207
|
+
<Button variant="outline" onClick={handleCancel} disabled={isSaving}>
|
|
208
|
+
<Trans>Cancel</Trans>
|
|
209
|
+
</Button>
|
|
210
|
+
<Button onClick={handleSave} disabled={!hasChanges || isSaving}>
|
|
211
|
+
{isSaving ? <Trans>Saving...</Trans> : <Trans>Save Changes</Trans>}
|
|
212
|
+
</Button>
|
|
213
|
+
</DialogFooter>
|
|
214
|
+
</DialogContent>
|
|
215
|
+
</Dialog>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
@@ -8,7 +8,7 @@ import { useLocalFormat } from '@/vdb/hooks/use-local-format.js';
|
|
|
8
8
|
import { Trans } from '@/vdb/lib/trans.js';
|
|
9
9
|
import { createFileRoute, Link } from '@tanstack/react-router';
|
|
10
10
|
import { PlusIcon } from 'lucide-react';
|
|
11
|
-
import { channelListQuery
|
|
11
|
+
import { channelListQuery } from './channels.graphql.js';
|
|
12
12
|
import { DeleteChannelsBulkAction } from './components/channel-bulk-actions.js';
|
|
13
13
|
|
|
14
14
|
export const Route = createFileRoute('/_authenticated/_channels/channels')({
|
|
@@ -23,7 +23,6 @@ function ChannelListPage() {
|
|
|
23
23
|
pageId="channel-list"
|
|
24
24
|
title="Channels"
|
|
25
25
|
listQuery={channelListQuery}
|
|
26
|
-
deleteMutation={deleteChannelDocument}
|
|
27
26
|
route={Route}
|
|
28
27
|
defaultVisibility={{
|
|
29
28
|
code: true,
|
|
@@ -10,11 +10,11 @@ import { createFileRoute, Link } from '@tanstack/react-router';
|
|
|
10
10
|
import { ExpandedState, getExpandedRowModel } from '@tanstack/react-table';
|
|
11
11
|
import { TableOptions } from '@tanstack/table-core';
|
|
12
12
|
import { ResultOf } from 'gql.tada';
|
|
13
|
-
import { Folder, FolderOpen,
|
|
13
|
+
import { Folder, FolderOpen, PlusIcon } from 'lucide-react';
|
|
14
14
|
import { useState } from 'react';
|
|
15
15
|
|
|
16
16
|
import { Badge } from '@/vdb/components/ui/badge.js';
|
|
17
|
-
import { collectionListDocument
|
|
17
|
+
import { collectionListDocument } from './collections.graphql.js';
|
|
18
18
|
import {
|
|
19
19
|
AssignCollectionsToChannelBulkAction,
|
|
20
20
|
DeleteCollectionsBulkAction,
|
|
@@ -23,7 +23,6 @@ import {
|
|
|
23
23
|
RemoveCollectionsFromChannelBulkAction,
|
|
24
24
|
} from './components/collection-bulk-actions.js';
|
|
25
25
|
import { CollectionContentsSheet } from './components/collection-contents-sheet.js';
|
|
26
|
-
import { useMoveSingleCollection } from './components/move-single-collection.js';
|
|
27
26
|
|
|
28
27
|
export const Route = createFileRoute('/_authenticated/_collections/collections')({
|
|
29
28
|
component: CollectionListPage,
|
|
@@ -34,7 +33,6 @@ type Collection = ResultOf<typeof collectionListDocument>['collections']['items'
|
|
|
34
33
|
|
|
35
34
|
function CollectionListPage() {
|
|
36
35
|
const [expanded, setExpanded] = useState<ExpandedState>({});
|
|
37
|
-
const { handleMoveClick, MoveDialog } = useMoveSingleCollection();
|
|
38
36
|
const childrenQueries = useQueries({
|
|
39
37
|
queries: Object.entries(expanded).map(([collectionId, isExpanded]) => {
|
|
40
38
|
return {
|
|
@@ -96,7 +94,6 @@ function CollectionListPage() {
|
|
|
96
94
|
},
|
|
97
95
|
};
|
|
98
96
|
}}
|
|
99
|
-
deleteMutation={deleteCollectionDocument}
|
|
100
97
|
customizeColumns={{
|
|
101
98
|
name: {
|
|
102
99
|
header: 'Collection Name',
|
|
@@ -210,16 +207,6 @@ function CollectionListPage() {
|
|
|
210
207
|
};
|
|
211
208
|
}}
|
|
212
209
|
route={Route}
|
|
213
|
-
rowActions={[
|
|
214
|
-
{
|
|
215
|
-
label: (
|
|
216
|
-
<div className="flex items-center gap-2">
|
|
217
|
-
<FolderTreeIcon className="w-4 h-4" /> <Trans>Move</Trans>
|
|
218
|
-
</div>
|
|
219
|
-
),
|
|
220
|
-
onClick: row => handleMoveClick(row.original),
|
|
221
|
-
},
|
|
222
|
-
]}
|
|
223
210
|
bulkActions={[
|
|
224
211
|
{
|
|
225
212
|
component: AssignCollectionsToChannelBulkAction,
|
|
@@ -254,7 +241,6 @@ function CollectionListPage() {
|
|
|
254
241
|
</PermissionGuard>
|
|
255
242
|
</PageActionBarRight>
|
|
256
243
|
</ListPage>
|
|
257
|
-
<MoveDialog />
|
|
258
244
|
</>
|
|
259
245
|
);
|
|
260
246
|
}
|
|
@@ -7,7 +7,7 @@ import { Trans } from '@/vdb/lib/trans.js';
|
|
|
7
7
|
import { createFileRoute, Link } from '@tanstack/react-router';
|
|
8
8
|
import { PlusIcon } from 'lucide-react';
|
|
9
9
|
import { DeleteCountriesBulkAction } from './components/country-bulk-actions.js';
|
|
10
|
-
import { countriesListQuery
|
|
10
|
+
import { countriesListQuery } from './countries.graphql.js';
|
|
11
11
|
|
|
12
12
|
export const Route = createFileRoute('/_authenticated/_countries/countries')({
|
|
13
13
|
component: CountryListPage,
|
|
@@ -19,7 +19,6 @@ function CountryListPage() {
|
|
|
19
19
|
<ListPage
|
|
20
20
|
pageId="country-list"
|
|
21
21
|
listQuery={countriesListQuery}
|
|
22
|
-
deleteMutation={deleteCountryDocument}
|
|
23
22
|
route={Route}
|
|
24
23
|
title="Countries"
|
|
25
24
|
defaultVisibility={{
|
|
@@ -8,7 +8,7 @@ import { createFileRoute, Link } from '@tanstack/react-router';
|
|
|
8
8
|
import { PlusIcon } from 'lucide-react';
|
|
9
9
|
import { DeleteCustomerGroupsBulkAction } from './components/customer-group-bulk-actions.js';
|
|
10
10
|
import { CustomerGroupMembersSheet } from './components/customer-group-members-sheet.js';
|
|
11
|
-
import { customerGroupListDocument
|
|
11
|
+
import { customerGroupListDocument } from './customer-groups.graphql.js';
|
|
12
12
|
|
|
13
13
|
export const Route = createFileRoute('/_authenticated/_customer-groups/customer-groups')({
|
|
14
14
|
component: CustomerGroupListPage,
|
|
@@ -21,7 +21,6 @@ function CustomerGroupListPage() {
|
|
|
21
21
|
pageId="customer-group-list"
|
|
22
22
|
title="Customer Groups"
|
|
23
23
|
listQuery={customerGroupListDocument}
|
|
24
|
-
deleteMutation={deleteCustomerGroupDocument}
|
|
25
24
|
route={Route}
|
|
26
25
|
customizeColumns={{
|
|
27
26
|
name: {
|
|
@@ -8,7 +8,7 @@ import { createFileRoute, Link } from '@tanstack/react-router';
|
|
|
8
8
|
import { PlusIcon } from 'lucide-react';
|
|
9
9
|
import { DeleteCustomersBulkAction } from './components/customer-bulk-actions.js';
|
|
10
10
|
import { CustomerStatusBadge } from './components/customer-status-badge.js';
|
|
11
|
-
import { customerListDocument
|
|
11
|
+
import { customerListDocument } from './customers.graphql.js';
|
|
12
12
|
|
|
13
13
|
export const Route = createFileRoute('/_authenticated/_customers/customers')({
|
|
14
14
|
component: CustomerListPage,
|
|
@@ -21,7 +21,6 @@ function CustomerListPage() {
|
|
|
21
21
|
title="Customers"
|
|
22
22
|
pageId="customer-list"
|
|
23
23
|
listQuery={customerListDocument}
|
|
24
|
-
deleteMutation={deleteCustomerDocument}
|
|
25
24
|
onSearchTermChange={searchTerm => {
|
|
26
25
|
return {
|
|
27
26
|
lastName: {
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { CustomFieldsForm } from '@/vdb/components/shared/custom-fields-form.js';
|
|
2
|
+
import { PermissionGuard } from '@/vdb/components/shared/permission-guard.js';
|
|
3
|
+
import { Button } from '@/vdb/components/ui/button.js';
|
|
4
|
+
import { DropdownMenuItem } from '@/vdb/components/ui/dropdown-menu.js';
|
|
5
|
+
import {
|
|
6
|
+
Page,
|
|
7
|
+
PageActionBar,
|
|
8
|
+
PageActionBarRight,
|
|
9
|
+
PageBlock,
|
|
10
|
+
PageLayout,
|
|
11
|
+
PageTitle,
|
|
12
|
+
} from '@/vdb/framework/layout-engine/page-layout.js';
|
|
13
|
+
import { getDetailQueryOptions, useDetailPage } from '@/vdb/framework/page/use-detail-page.js';
|
|
14
|
+
import { api } from '@/vdb/graphql/api.js';
|
|
15
|
+
import { useCustomFieldConfig } from '@/vdb/hooks/use-custom-field-config.js';
|
|
16
|
+
import { Trans, useLingui } from '@/vdb/lib/trans.js';
|
|
17
|
+
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
18
|
+
import { Link, useNavigate } from '@tanstack/react-router';
|
|
19
|
+
import { ResultOf } from 'gql.tada';
|
|
20
|
+
import { Pencil, User } from 'lucide-react';
|
|
21
|
+
import { useMemo } from 'react';
|
|
22
|
+
import { toast } from 'sonner';
|
|
23
|
+
import {
|
|
24
|
+
orderDetailDocument,
|
|
25
|
+
setOrderCustomFieldsDocument,
|
|
26
|
+
transitionOrderToStateDocument,
|
|
27
|
+
} from '../orders.graphql.js';
|
|
28
|
+
import { canAddFulfillment, shouldShowAddManualPaymentButton } from '../utils/order-utils.js';
|
|
29
|
+
import { AddManualPaymentDialog } from './add-manual-payment-dialog.js';
|
|
30
|
+
import { FulfillOrderDialog } from './fulfill-order-dialog.js';
|
|
31
|
+
import { FulfillmentDetails } from './fulfillment-details.js';
|
|
32
|
+
import { OrderAddress } from './order-address.js';
|
|
33
|
+
import { OrderHistoryContainer } from './order-history/order-history-container.js';
|
|
34
|
+
import { orderHistoryQueryKey } from './order-history/use-order-history.js';
|
|
35
|
+
import { OrderTable } from './order-table.js';
|
|
36
|
+
import { OrderTaxSummary } from './order-tax-summary.js';
|
|
37
|
+
import { PaymentDetails } from './payment-details.js';
|
|
38
|
+
import { getTypeForState, StateTransitionControl } from './state-transition-control.js';
|
|
39
|
+
import { useTransitionOrderToState } from './use-transition-order-to-state.js';
|
|
40
|
+
|
|
41
|
+
export type OrderDetail = NonNullable<ResultOf<typeof orderDetailDocument>['order']>;
|
|
42
|
+
|
|
43
|
+
export interface OrderDetailSharedProps {
|
|
44
|
+
// Required props
|
|
45
|
+
pageId: string;
|
|
46
|
+
orderId: string;
|
|
47
|
+
// Title customization
|
|
48
|
+
titleSlot?: (order: OrderDetail) => React.ReactNode;
|
|
49
|
+
// Optional content slots
|
|
50
|
+
beforeOrderTable?: (order: OrderDetail) => React.ReactNode;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function DefaultOrderTitle({ entity }: { entity: any }) {
|
|
54
|
+
return <>{entity?.code ?? ''}</>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @description
|
|
59
|
+
* Shared functionality between the order and seller order detail pages.
|
|
60
|
+
*/
|
|
61
|
+
export function OrderDetailShared({
|
|
62
|
+
pageId,
|
|
63
|
+
orderId,
|
|
64
|
+
titleSlot,
|
|
65
|
+
beforeOrderTable,
|
|
66
|
+
}: Readonly<OrderDetailSharedProps>) {
|
|
67
|
+
const { i18n } = useLingui();
|
|
68
|
+
const navigate = useNavigate();
|
|
69
|
+
const queryClient = useQueryClient();
|
|
70
|
+
|
|
71
|
+
const { form, submitHandler, entity, refreshEntity } = useDetailPage({
|
|
72
|
+
pageId,
|
|
73
|
+
queryDocument: orderDetailDocument,
|
|
74
|
+
updateDocument: setOrderCustomFieldsDocument,
|
|
75
|
+
setValuesForUpdate: (entity: any) => {
|
|
76
|
+
return {
|
|
77
|
+
id: entity.id,
|
|
78
|
+
customFields: entity.customFields,
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
params: { id: orderId },
|
|
82
|
+
onSuccess: async () => {
|
|
83
|
+
toast(i18n.t('Successfully updated order'));
|
|
84
|
+
form.reset(form.getValues());
|
|
85
|
+
},
|
|
86
|
+
onError: err => {
|
|
87
|
+
toast(i18n.t('Failed to update order'), {
|
|
88
|
+
description: err instanceof Error ? err.message : 'Unknown error',
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const { transitionToState } = useTransitionOrderToState(entity?.id);
|
|
94
|
+
const transitionOrderToStateMutation = useMutation({
|
|
95
|
+
mutationFn: api.mutate(transitionOrderToStateDocument),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const customFieldConfig = useCustomFieldConfig('Order');
|
|
99
|
+
|
|
100
|
+
const stateTransitionActions = useMemo(() => {
|
|
101
|
+
if (!entity) {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
return entity.nextStates.map((state: string) => ({
|
|
105
|
+
label: `Transition to ${state}`,
|
|
106
|
+
type: getTypeForState(state),
|
|
107
|
+
onClick: async () => {
|
|
108
|
+
const transitionError = await transitionToState(state);
|
|
109
|
+
if (transitionError) {
|
|
110
|
+
toast(i18n.t('Failed to transition order to state'), {
|
|
111
|
+
description: transitionError,
|
|
112
|
+
});
|
|
113
|
+
} else {
|
|
114
|
+
refreshOrderAndHistory();
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
}));
|
|
118
|
+
}, [entity, transitionToState, i18n]);
|
|
119
|
+
|
|
120
|
+
if (!entity) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const handleModifyClick = async () => {
|
|
125
|
+
try {
|
|
126
|
+
await transitionOrderToStateMutation.mutateAsync({
|
|
127
|
+
id: entity.id,
|
|
128
|
+
state: 'Modifying',
|
|
129
|
+
});
|
|
130
|
+
const queryKey = getDetailQueryOptions(orderDetailDocument, { id: entity.id }).queryKey;
|
|
131
|
+
await queryClient.invalidateQueries({ queryKey });
|
|
132
|
+
await navigate({ to: `/orders/$id/modify`, params: { id: entity.id } });
|
|
133
|
+
} catch (error) {
|
|
134
|
+
toast(i18n.t('Failed to modify order'), {
|
|
135
|
+
description: error instanceof Error ? error.message : 'Unknown error',
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const nextStates = entity.nextStates;
|
|
141
|
+
const showAddPaymentButton = shouldShowAddManualPaymentButton(entity);
|
|
142
|
+
const showFulfillButton = canAddFulfillment(entity);
|
|
143
|
+
|
|
144
|
+
async function refreshOrderAndHistory() {
|
|
145
|
+
if (entity) {
|
|
146
|
+
const queryKey = getDetailQueryOptions(orderDetailDocument, { id: entity.id }).queryKey;
|
|
147
|
+
await queryClient.invalidateQueries({ queryKey });
|
|
148
|
+
queryClient.refetchQueries({ queryKey: orderHistoryQueryKey(entity.id) });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<Page pageId={pageId} form={form} submitHandler={submitHandler} entity={entity}>
|
|
154
|
+
<PageTitle>{titleSlot?.(entity) || <DefaultOrderTitle entity={entity} />}</PageTitle>
|
|
155
|
+
<PageActionBar>
|
|
156
|
+
<PageActionBarRight
|
|
157
|
+
dropdownMenuItems={[
|
|
158
|
+
...(nextStates.includes('Modifying')
|
|
159
|
+
? [
|
|
160
|
+
{
|
|
161
|
+
component: () => (
|
|
162
|
+
<DropdownMenuItem onClick={handleModifyClick}>
|
|
163
|
+
<Pencil className="w-4 h-4" />
|
|
164
|
+
<Trans>Modify</Trans>
|
|
165
|
+
</DropdownMenuItem>
|
|
166
|
+
),
|
|
167
|
+
},
|
|
168
|
+
]
|
|
169
|
+
: []),
|
|
170
|
+
]}
|
|
171
|
+
>
|
|
172
|
+
{showAddPaymentButton && (
|
|
173
|
+
<PermissionGuard requires={['UpdateOrder']}>
|
|
174
|
+
<AddManualPaymentDialog
|
|
175
|
+
order={entity}
|
|
176
|
+
onSuccess={() => {
|
|
177
|
+
refreshEntity();
|
|
178
|
+
}}
|
|
179
|
+
/>
|
|
180
|
+
</PermissionGuard>
|
|
181
|
+
)}
|
|
182
|
+
{showFulfillButton && (
|
|
183
|
+
<PermissionGuard requires={['UpdateOrder']}>
|
|
184
|
+
<FulfillOrderDialog
|
|
185
|
+
order={entity}
|
|
186
|
+
onSuccess={() => {
|
|
187
|
+
refreshOrderAndHistory();
|
|
188
|
+
}}
|
|
189
|
+
/>
|
|
190
|
+
</PermissionGuard>
|
|
191
|
+
)}
|
|
192
|
+
</PageActionBarRight>
|
|
193
|
+
</PageActionBar>
|
|
194
|
+
<PageLayout>
|
|
195
|
+
{/* Main Column Blocks */}
|
|
196
|
+
{beforeOrderTable?.(entity)}
|
|
197
|
+
<PageBlock column="main" blockId="order-table">
|
|
198
|
+
<OrderTable order={entity} pageId={pageId} />
|
|
199
|
+
</PageBlock>
|
|
200
|
+
<PageBlock column="main" blockId="tax-summary" title={<Trans>Tax summary</Trans>}>
|
|
201
|
+
<OrderTaxSummary order={entity} />
|
|
202
|
+
</PageBlock>
|
|
203
|
+
{customFieldConfig?.length ? (
|
|
204
|
+
<PageBlock column="main" blockId="custom-fields">
|
|
205
|
+
<CustomFieldsForm entityType="Order" control={form.control} />
|
|
206
|
+
<div className="flex justify-end">
|
|
207
|
+
<Button
|
|
208
|
+
type="submit"
|
|
209
|
+
disabled={!form.formState.isDirty || !form.formState.isValid}
|
|
210
|
+
>
|
|
211
|
+
<Trans>Save</Trans>
|
|
212
|
+
</Button>
|
|
213
|
+
</div>
|
|
214
|
+
</PageBlock>
|
|
215
|
+
) : null}
|
|
216
|
+
<PageBlock column="main" blockId="payment-details" title={<Trans>Payment details</Trans>}>
|
|
217
|
+
<div className="grid lg:grid-cols-2 gap-4">
|
|
218
|
+
{entity?.payments?.map((payment: any) => (
|
|
219
|
+
<PaymentDetails
|
|
220
|
+
key={payment.id}
|
|
221
|
+
payment={payment}
|
|
222
|
+
currencyCode={entity.currencyCode}
|
|
223
|
+
onSuccess={refreshOrderAndHistory}
|
|
224
|
+
/>
|
|
225
|
+
))}
|
|
226
|
+
</div>
|
|
227
|
+
</PageBlock>
|
|
228
|
+
<PageBlock column="main" blockId="order-history" title={<Trans>Order history</Trans>}>
|
|
229
|
+
<OrderHistoryContainer orderId={orderId} />
|
|
230
|
+
</PageBlock>
|
|
231
|
+
|
|
232
|
+
{/* Side Column Blocks */}
|
|
233
|
+
<PageBlock column="side" blockId="state">
|
|
234
|
+
<StateTransitionControl
|
|
235
|
+
currentState={entity?.state}
|
|
236
|
+
actions={stateTransitionActions}
|
|
237
|
+
isLoading={transitionOrderToStateMutation.isPending}
|
|
238
|
+
/>
|
|
239
|
+
</PageBlock>
|
|
240
|
+
<PageBlock column="side" blockId="customer" title={<Trans>Customer</Trans>}>
|
|
241
|
+
{entity?.customer ? (
|
|
242
|
+
<Button variant="ghost" asChild>
|
|
243
|
+
<Link to={`/customers/${entity.customer.id}`}>
|
|
244
|
+
<User className="w-4 h-4" />
|
|
245
|
+
{entity.customer.firstName} {entity.customer.lastName}
|
|
246
|
+
</Link>
|
|
247
|
+
</Button>
|
|
248
|
+
) : (
|
|
249
|
+
<div className="text-muted-foreground text-xs font-medium p-3 border rounded-md">
|
|
250
|
+
<Trans>No customer</Trans>
|
|
251
|
+
</div>
|
|
252
|
+
)}
|
|
253
|
+
<div className="mt-4 divide-y">
|
|
254
|
+
{entity?.shippingAddress && (
|
|
255
|
+
<div className="pb-6">
|
|
256
|
+
<div className="font-medium">
|
|
257
|
+
<Trans>Shipping address</Trans>
|
|
258
|
+
</div>
|
|
259
|
+
<OrderAddress address={entity.shippingAddress} />
|
|
260
|
+
</div>
|
|
261
|
+
)}
|
|
262
|
+
{entity?.billingAddress && (
|
|
263
|
+
<div className="pt-4">
|
|
264
|
+
<div className="font-medium">
|
|
265
|
+
<Trans>Billing address</Trans>
|
|
266
|
+
</div>
|
|
267
|
+
<OrderAddress address={entity.billingAddress} />
|
|
268
|
+
</div>
|
|
269
|
+
)}
|
|
270
|
+
</div>
|
|
271
|
+
</PageBlock>
|
|
272
|
+
<PageBlock
|
|
273
|
+
column="side"
|
|
274
|
+
blockId="fulfillment-details"
|
|
275
|
+
title={<Trans>Fulfillment details</Trans>}
|
|
276
|
+
>
|
|
277
|
+
{entity?.fulfillments?.length && entity.fulfillments.length > 0 ? (
|
|
278
|
+
<div className="space-y-2">
|
|
279
|
+
{entity?.fulfillments?.map((fulfillment: any) => (
|
|
280
|
+
<FulfillmentDetails
|
|
281
|
+
key={fulfillment.id}
|
|
282
|
+
order={entity}
|
|
283
|
+
fulfillment={fulfillment}
|
|
284
|
+
onSuccess={() => {
|
|
285
|
+
refreshEntity();
|
|
286
|
+
queryClient.refetchQueries({
|
|
287
|
+
queryKey: orderHistoryQueryKey(entity.id),
|
|
288
|
+
});
|
|
289
|
+
}}
|
|
290
|
+
/>
|
|
291
|
+
))}
|
|
292
|
+
</div>
|
|
293
|
+
) : (
|
|
294
|
+
<div className="text-muted-foreground text-xs font-medium p-3 border rounded-md">
|
|
295
|
+
<Trans>No fulfillments</Trans>
|
|
296
|
+
</div>
|
|
297
|
+
)}
|
|
298
|
+
</PageBlock>
|
|
299
|
+
</PageLayout>
|
|
300
|
+
</Page>
|
|
301
|
+
);
|
|
302
|
+
}
|