@vendure/dashboard 3.3.6-master-202507031258 → 3.3.6-master-202507040234
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/plugin/vite-plugin-vendure-dashboard.js +1 -1
- package/package.json +4 -4
- package/src/app/routes/_authenticated/_orders/components/add-manual-payment-dialog.tsx +191 -0
- package/src/app/routes/_authenticated/_orders/components/edit-order-table.tsx +2 -2
- package/src/app/routes/_authenticated/_orders/components/fulfill-order-dialog.tsx +320 -0
- package/src/app/routes/_authenticated/_orders/components/fulfillment-details.tsx +173 -0
- package/src/app/routes/_authenticated/_orders/components/order-address.tsx +2 -2
- package/src/app/routes/_authenticated/_orders/components/order-history/use-order-history.ts +6 -2
- package/src/app/routes/_authenticated/_orders/components/order-table-totals.tsx +2 -5
- package/src/app/routes/_authenticated/_orders/components/order-tax-summary.tsx +2 -3
- package/src/app/routes/_authenticated/_orders/components/payment-details.tsx +14 -29
- package/src/app/routes/_authenticated/_orders/orders.graphql.ts +100 -2
- package/src/app/routes/_authenticated/_orders/orders_.$id.tsx +58 -2
- package/src/app/routes/_authenticated/_orders/utils/order-types.ts +7 -0
- package/src/app/routes/_authenticated/_orders/utils/order-utils.ts +77 -0
- package/src/lib/components/data-input/relation-input.tsx +0 -3
- package/src/lib/components/labeled-data.tsx +21 -0
- package/vite/vite-plugin-vendure-dashboard.ts +1 -1
|
@@ -32,7 +32,7 @@ export function vendureDashboardPlugin(options) {
|
|
|
32
32
|
: [
|
|
33
33
|
TanStackRouterVite({
|
|
34
34
|
autoCodeSplitting: true,
|
|
35
|
-
routeFileIgnorePattern: '.graphql.ts|components|hooks',
|
|
35
|
+
routeFileIgnorePattern: '.graphql.ts|components|hooks|utils',
|
|
36
36
|
routesDirectory: path.join(packageRoot, 'src/app/routes'),
|
|
37
37
|
generatedRouteTree: path.join(packageRoot, 'src/app/routeTree.gen.ts'),
|
|
38
38
|
}),
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vendure/dashboard",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "3.3.6-master-
|
|
4
|
+
"version": "3.3.6-master-202507040234",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -86,8 +86,8 @@
|
|
|
86
86
|
"@types/react-dom": "^19.0.4",
|
|
87
87
|
"@types/react-grid-layout": "^1.3.5",
|
|
88
88
|
"@uidotdev/usehooks": "^2.4.1",
|
|
89
|
-
"@vendure/common": "^3.3.6-master-
|
|
90
|
-
"@vendure/core": "^3.3.6-master-
|
|
89
|
+
"@vendure/common": "^3.3.6-master-202507040234",
|
|
90
|
+
"@vendure/core": "^3.3.6-master-202507040234",
|
|
91
91
|
"@vitejs/plugin-react": "^4.3.4",
|
|
92
92
|
"awesome-graphql-client": "^2.1.0",
|
|
93
93
|
"class-variance-authority": "^0.7.1",
|
|
@@ -130,5 +130,5 @@
|
|
|
130
130
|
"lightningcss-linux-arm64-musl": "^1.29.3",
|
|
131
131
|
"lightningcss-linux-x64-musl": "^1.29.1"
|
|
132
132
|
},
|
|
133
|
-
"gitHead": "
|
|
133
|
+
"gitHead": "6634367c8f5aeb5c25502c51c15686d1d4fa807c"
|
|
134
134
|
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import {
|
|
2
|
+
RelationSelector,
|
|
3
|
+
createRelationSelectorConfig,
|
|
4
|
+
} from '@/vdb/components/data-input/relation-selector.js';
|
|
5
|
+
import { FormFieldWrapper } from '@/vdb/components/shared/form-field-wrapper.js';
|
|
6
|
+
import { Button } from '@/vdb/components/ui/button.js';
|
|
7
|
+
import {
|
|
8
|
+
Dialog,
|
|
9
|
+
DialogContent,
|
|
10
|
+
DialogDescription,
|
|
11
|
+
DialogFooter,
|
|
12
|
+
DialogHeader,
|
|
13
|
+
DialogTitle,
|
|
14
|
+
} from '@/vdb/components/ui/dialog.js';
|
|
15
|
+
import { Form } from '@/vdb/components/ui/form.js';
|
|
16
|
+
import { Input } from '@/vdb/components/ui/input.js';
|
|
17
|
+
import { api } from '@/vdb/graphql/api.js';
|
|
18
|
+
import { useLocalFormat } from '@/vdb/hooks/use-local-format.js';
|
|
19
|
+
import { Trans, useLingui } from '@/vdb/lib/trans.js';
|
|
20
|
+
import { useMutation } from '@tanstack/react-query';
|
|
21
|
+
import { useState } from 'react';
|
|
22
|
+
import { useForm } from 'react-hook-form';
|
|
23
|
+
import { toast } from 'sonner';
|
|
24
|
+
import {
|
|
25
|
+
addManualPaymentToOrderDocument,
|
|
26
|
+
paymentMethodsDocument
|
|
27
|
+
} from '../orders.graphql.js';
|
|
28
|
+
import { Order } from '../utils/order-types.js';
|
|
29
|
+
import { calculateOutstandingPaymentAmount } from '../utils/order-utils.js';
|
|
30
|
+
|
|
31
|
+
interface AddManualPaymentDialogProps {
|
|
32
|
+
order: Order;
|
|
33
|
+
onSuccess?: () => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface FormData {
|
|
37
|
+
method: string;
|
|
38
|
+
transactionId: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function AddManualPaymentDialog({ order, onSuccess }: Readonly<AddManualPaymentDialogProps>) {
|
|
42
|
+
const { i18n } = useLingui();
|
|
43
|
+
const { formatCurrency } = useLocalFormat();
|
|
44
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
45
|
+
const [open, setOpen] = useState(false);
|
|
46
|
+
|
|
47
|
+
const addManualPaymentMutation = useMutation({
|
|
48
|
+
mutationFn: api.mutate(addManualPaymentToOrderDocument),
|
|
49
|
+
onSuccess: (result: any) => {
|
|
50
|
+
const { addManualPaymentToOrder } = result;
|
|
51
|
+
if (addManualPaymentToOrder.__typename === 'Order') {
|
|
52
|
+
toast(i18n.t('Successfully added payment to order'));
|
|
53
|
+
onSuccess?.();
|
|
54
|
+
} else {
|
|
55
|
+
toast(i18n.t('Failed to add payment'), {
|
|
56
|
+
description: addManualPaymentToOrder.message,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
onError: error => {
|
|
61
|
+
toast(i18n.t('Failed to add payment'), {
|
|
62
|
+
description: error instanceof Error ? error.message : 'Unknown error',
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const form = useForm<FormData>({
|
|
68
|
+
defaultValues: {
|
|
69
|
+
method: '',
|
|
70
|
+
transactionId: '',
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
const method = form.watch('method');
|
|
74
|
+
|
|
75
|
+
const handleSubmit = async (data: FormData) => {
|
|
76
|
+
setIsSubmitting(true);
|
|
77
|
+
try {
|
|
78
|
+
addManualPaymentMutation.mutate({
|
|
79
|
+
input: {
|
|
80
|
+
orderId: order.id,
|
|
81
|
+
method: data.method,
|
|
82
|
+
transactionId: data.transactionId,
|
|
83
|
+
metadata: {},
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
setOpen(false);
|
|
87
|
+
form.reset();
|
|
88
|
+
} catch (error) {
|
|
89
|
+
toast(i18n.t('Failed to add payment'), {
|
|
90
|
+
description: error instanceof Error ? error.message : 'Unknown error',
|
|
91
|
+
});
|
|
92
|
+
} finally {
|
|
93
|
+
setIsSubmitting(false);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const handleCancel = () => {
|
|
98
|
+
form.reset();
|
|
99
|
+
setOpen(false);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const outstandingAmount = calculateOutstandingPaymentAmount(order);
|
|
103
|
+
const currencyCode = order.currencyCode;
|
|
104
|
+
|
|
105
|
+
// Create relation selector config for payment methods
|
|
106
|
+
const paymentMethodSelectorConfig = createRelationSelectorConfig({
|
|
107
|
+
listQuery: paymentMethodsDocument,
|
|
108
|
+
idKey: 'code',
|
|
109
|
+
labelKey: 'name',
|
|
110
|
+
placeholder: i18n.t('Search payment methods...'),
|
|
111
|
+
multiple: false,
|
|
112
|
+
label: (method: any) => `${method.name} (${method.code})`,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<>
|
|
117
|
+
<Button
|
|
118
|
+
onClick={e => {
|
|
119
|
+
e.stopPropagation();
|
|
120
|
+
setOpen(true);
|
|
121
|
+
}}
|
|
122
|
+
className="mr-2"
|
|
123
|
+
>
|
|
124
|
+
<Trans>Add payment to order ({formatCurrency(outstandingAmount, currencyCode)})</Trans>
|
|
125
|
+
</Button>
|
|
126
|
+
<Dialog open={open}>
|
|
127
|
+
<DialogContent className="sm:max-w-[500px]">
|
|
128
|
+
<DialogHeader>
|
|
129
|
+
<DialogTitle>
|
|
130
|
+
<Trans>Add payment to order</Trans>
|
|
131
|
+
</DialogTitle>
|
|
132
|
+
<DialogDescription>
|
|
133
|
+
<Trans>
|
|
134
|
+
Add a manual payment of {formatCurrency(outstandingAmount, currencyCode)}
|
|
135
|
+
</Trans>
|
|
136
|
+
</DialogDescription>
|
|
137
|
+
</DialogHeader>
|
|
138
|
+
<Form {...form}>
|
|
139
|
+
<form onSubmit={e => {
|
|
140
|
+
e.stopPropagation();
|
|
141
|
+
form.handleSubmit(handleSubmit)(e);
|
|
142
|
+
}} className="space-y-4">
|
|
143
|
+
<FormFieldWrapper
|
|
144
|
+
control={form.control}
|
|
145
|
+
name="method"
|
|
146
|
+
label={<Trans>Payment method</Trans>}
|
|
147
|
+
rules={{ required: i18n.t('Payment method is required') }}
|
|
148
|
+
render={({ field }) => (
|
|
149
|
+
<RelationSelector
|
|
150
|
+
config={paymentMethodSelectorConfig}
|
|
151
|
+
value={field.value}
|
|
152
|
+
onChange={field.onChange}
|
|
153
|
+
disabled={isSubmitting}
|
|
154
|
+
/>
|
|
155
|
+
)}
|
|
156
|
+
/>
|
|
157
|
+
<FormFieldWrapper
|
|
158
|
+
control={form.control}
|
|
159
|
+
name="transactionId"
|
|
160
|
+
label={<Trans>Transaction ID</Trans>}
|
|
161
|
+
rules={{ required: i18n.t('Transaction ID is required') }}
|
|
162
|
+
render={({ field }) => (
|
|
163
|
+
<Input {...field} placeholder={i18n.t('Enter transaction ID')} />
|
|
164
|
+
)}
|
|
165
|
+
/>
|
|
166
|
+
<DialogFooter>
|
|
167
|
+
<Button type="button" variant="outline" onClick={handleCancel}>
|
|
168
|
+
<Trans>Cancel</Trans>
|
|
169
|
+
</Button>
|
|
170
|
+
<Button
|
|
171
|
+
type="submit"
|
|
172
|
+
disabled={
|
|
173
|
+
!form.formState.isValid || isSubmitting || !method
|
|
174
|
+
}
|
|
175
|
+
>
|
|
176
|
+
{isSubmitting ? (
|
|
177
|
+
<Trans>Adding...</Trans>
|
|
178
|
+
) : (
|
|
179
|
+
<Trans>
|
|
180
|
+
Add payment ({formatCurrency(outstandingAmount, currencyCode)})
|
|
181
|
+
</Trans>
|
|
182
|
+
)}
|
|
183
|
+
</Button>
|
|
184
|
+
</DialogFooter>
|
|
185
|
+
</form>
|
|
186
|
+
</Form>
|
|
187
|
+
</DialogContent>
|
|
188
|
+
</Dialog>
|
|
189
|
+
</>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
@@ -54,7 +54,7 @@ export function EditOrderTable({
|
|
|
54
54
|
onApplyCouponCode,
|
|
55
55
|
onRemoveCouponCode,
|
|
56
56
|
orderLineForm,
|
|
57
|
-
}: OrderTableProps) {
|
|
57
|
+
}: Readonly<OrderTableProps>) {
|
|
58
58
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
|
59
59
|
const [couponCode, setCouponCode] = useState('');
|
|
60
60
|
|
|
@@ -130,7 +130,7 @@ export function EditOrderTable({
|
|
|
130
130
|
{
|
|
131
131
|
header: 'Total',
|
|
132
132
|
accessorKey: 'linePriceWithTax',
|
|
133
|
-
cell: ({
|
|
133
|
+
cell: ({ row }) => {
|
|
134
134
|
const value = row.original.linePriceWithTax;
|
|
135
135
|
const netValue = row.original.linePrice;
|
|
136
136
|
return <MoneyGrossNet priceWithTax={value} price={netValue} currencyCode={currencyCode} />;
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { ConfigurableOperationInput } from '@/vdb/components/shared/configurable-operation-input.js';
|
|
2
|
+
import { FormFieldWrapper } from '@/vdb/components/shared/form-field-wrapper.js';
|
|
3
|
+
import { Button } from '@/vdb/components/ui/button.js';
|
|
4
|
+
import {
|
|
5
|
+
Dialog,
|
|
6
|
+
DialogContent,
|
|
7
|
+
DialogDescription,
|
|
8
|
+
DialogFooter,
|
|
9
|
+
DialogHeader,
|
|
10
|
+
DialogTitle,
|
|
11
|
+
} from '@/vdb/components/ui/dialog.js';
|
|
12
|
+
import { Form } from '@/vdb/components/ui/form.js';
|
|
13
|
+
import { Input } from '@/vdb/components/ui/input.js';
|
|
14
|
+
import { Label } from '@/vdb/components/ui/label.js';
|
|
15
|
+
import { api } from '@/vdb/graphql/api.js';
|
|
16
|
+
import { graphql } from '@/vdb/graphql/graphql.js';
|
|
17
|
+
import { Trans, useLingui } from '@/vdb/lib/trans.js';
|
|
18
|
+
import { useMutation, useQuery } from '@tanstack/react-query';
|
|
19
|
+
import { ConfigurableOperationInput as ConfigurableOperationInputType } from '@vendure/common/lib/generated-types';
|
|
20
|
+
import { useState } from 'react';
|
|
21
|
+
import { useForm } from 'react-hook-form';
|
|
22
|
+
import { toast } from 'sonner';
|
|
23
|
+
import { fulfillmentHandlersDocument, fulfillOrderDocument } from '../orders.graphql.js';
|
|
24
|
+
import { Order } from '../utils/order-types.js';
|
|
25
|
+
|
|
26
|
+
interface FulfillOrderDialogProps {
|
|
27
|
+
order: Order;
|
|
28
|
+
onSuccess?: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface FormData {
|
|
32
|
+
handler: ConfigurableOperationInputType;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface FulfillmentQuantity {
|
|
36
|
+
fulfillCount: number;
|
|
37
|
+
max: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function FulfillOrderDialog({ order, onSuccess }: Readonly<FulfillOrderDialogProps>) {
|
|
41
|
+
const { i18n } = useLingui();
|
|
42
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
43
|
+
const [open, setOpen] = useState(false);
|
|
44
|
+
const [fulfillmentQuantities, setFulfillmentQuantities] = useState<{
|
|
45
|
+
[lineId: string]: FulfillmentQuantity;
|
|
46
|
+
}>({});
|
|
47
|
+
|
|
48
|
+
// Get fulfillment handlers
|
|
49
|
+
const { data: fulfillmentHandlersData } = useQuery({
|
|
50
|
+
queryKey: ['fulfillmentHandlers'],
|
|
51
|
+
queryFn: () => api.query(fulfillmentHandlersDocument),
|
|
52
|
+
staleTime: 1000 * 60 * 60 * 5,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Get global settings for inventory tracking
|
|
56
|
+
const { data: globalSettingsData } = useQuery({
|
|
57
|
+
queryKey: ['globalSettings'],
|
|
58
|
+
queryFn: () =>
|
|
59
|
+
api.query(
|
|
60
|
+
graphql(`
|
|
61
|
+
query GetGlobalSettings {
|
|
62
|
+
globalSettings {
|
|
63
|
+
trackInventory
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
`),
|
|
67
|
+
),
|
|
68
|
+
staleTime: 1000 * 60 * 60 * 5,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const fulfillOrderMutation = useMutation({
|
|
72
|
+
mutationFn: api.mutate(fulfillOrderDocument),
|
|
73
|
+
onSuccess: (result: any) => {
|
|
74
|
+
const { addFulfillmentToOrder } = result;
|
|
75
|
+
if (addFulfillmentToOrder.__typename === 'Fulfillment') {
|
|
76
|
+
toast(i18n.t('Successfully fulfilled order'));
|
|
77
|
+
onSuccess?.();
|
|
78
|
+
} else {
|
|
79
|
+
toast(i18n.t('Failed to fulfill order'), {
|
|
80
|
+
description: addFulfillmentToOrder.message,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
onError: error => {
|
|
85
|
+
toast(i18n.t('Failed to fulfill order'), {
|
|
86
|
+
description: error instanceof Error ? error.message : 'Unknown error',
|
|
87
|
+
});
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const form = useForm<FormData>({
|
|
92
|
+
defaultValues: {
|
|
93
|
+
handler: {
|
|
94
|
+
code: '',
|
|
95
|
+
arguments: [],
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Initialize fulfillment quantities when dialog opens
|
|
101
|
+
const initializeFulfillmentQuantities = () => {
|
|
102
|
+
if (!globalSettingsData?.globalSettings) return;
|
|
103
|
+
|
|
104
|
+
const quantities: { [lineId: string]: FulfillmentQuantity } = {};
|
|
105
|
+
order.lines.forEach(line => {
|
|
106
|
+
const fulfillCount = getFulfillableCount(line, globalSettingsData.globalSettings.trackInventory);
|
|
107
|
+
quantities[line.id] = { fulfillCount, max: fulfillCount };
|
|
108
|
+
});
|
|
109
|
+
setFulfillmentQuantities(quantities);
|
|
110
|
+
|
|
111
|
+
// Set default fulfillment handler
|
|
112
|
+
const defaultHandler =
|
|
113
|
+
fulfillmentHandlersData?.fulfillmentHandlers.find(
|
|
114
|
+
h => h.code === order.shippingLines[0]?.shippingMethod?.fulfillmentHandlerCode,
|
|
115
|
+
) ?? fulfillmentHandlersData?.fulfillmentHandlers[0];
|
|
116
|
+
|
|
117
|
+
if (defaultHandler) {
|
|
118
|
+
form.setValue('handler', {
|
|
119
|
+
code: defaultHandler.code,
|
|
120
|
+
arguments: defaultHandler.args.map(arg => ({
|
|
121
|
+
name: arg.name,
|
|
122
|
+
value: arg.defaultValue ?? '',
|
|
123
|
+
})),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const getFulfillableCount = (line: Order['lines'][number], globalTrackInventory: boolean): number => {
|
|
129
|
+
const { trackInventory, stockOnHand } = line.productVariant;
|
|
130
|
+
const effectiveTrackInventory =
|
|
131
|
+
trackInventory === 'INHERIT' ? globalTrackInventory : trackInventory === 'TRUE';
|
|
132
|
+
|
|
133
|
+
const unfulfilledCount = getUnfulfilledCount(line);
|
|
134
|
+
return effectiveTrackInventory ? Math.min(unfulfilledCount, stockOnHand) : unfulfilledCount;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const getUnfulfilledCount = (line: Order['lines'][number]): number => {
|
|
138
|
+
const fulfilled =
|
|
139
|
+
order.fulfillments
|
|
140
|
+
?.filter(f => f.state !== 'Cancelled')
|
|
141
|
+
.map(f => f.lines)
|
|
142
|
+
.flat()
|
|
143
|
+
.filter(row => row.orderLineId === line.id)
|
|
144
|
+
.reduce((sum, row) => sum + row.quantity, 0) ?? 0;
|
|
145
|
+
return line.quantity - fulfilled;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const updateFulfillmentQuantity = (lineId: string, fulfillCount: number) => {
|
|
149
|
+
setFulfillmentQuantities(prev => ({
|
|
150
|
+
...prev,
|
|
151
|
+
[lineId]: { ...prev[lineId], fulfillCount },
|
|
152
|
+
}));
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const canSubmit = (): boolean => {
|
|
156
|
+
const totalCount = Object.values(fulfillmentQuantities).reduce(
|
|
157
|
+
(total, { fulfillCount }) => total + fulfillCount,
|
|
158
|
+
0,
|
|
159
|
+
);
|
|
160
|
+
const fulfillmentQuantityIsValid = Object.values(fulfillmentQuantities).every(
|
|
161
|
+
({ fulfillCount, max }) => fulfillCount <= max && fulfillCount >= 0,
|
|
162
|
+
);
|
|
163
|
+
const formIsValid = form.formState.isValid;
|
|
164
|
+
return formIsValid && totalCount > 0 && fulfillmentQuantityIsValid;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const handleSubmit = async (data: FormData) => {
|
|
168
|
+
setIsSubmitting(true);
|
|
169
|
+
try {
|
|
170
|
+
const lines = Object.entries(fulfillmentQuantities)
|
|
171
|
+
.filter(([, { fulfillCount }]) => fulfillCount > 0)
|
|
172
|
+
.map(([orderLineId, { fulfillCount }]) => ({
|
|
173
|
+
orderLineId,
|
|
174
|
+
quantity: fulfillCount,
|
|
175
|
+
}));
|
|
176
|
+
|
|
177
|
+
fulfillOrderMutation.mutate({
|
|
178
|
+
input: {
|
|
179
|
+
lines,
|
|
180
|
+
handler: data.handler,
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
setOpen(false);
|
|
184
|
+
form.reset();
|
|
185
|
+
setFulfillmentQuantities({});
|
|
186
|
+
} catch (error) {
|
|
187
|
+
toast(i18n.t('Failed to fulfill order'), {
|
|
188
|
+
description: error instanceof Error ? error.message : 'Unknown error',
|
|
189
|
+
});
|
|
190
|
+
} finally {
|
|
191
|
+
setIsSubmitting(false);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const handleCancel = () => {
|
|
196
|
+
form.reset();
|
|
197
|
+
setFulfillmentQuantities({});
|
|
198
|
+
setOpen(false);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const handleOpen = () => {
|
|
202
|
+
setOpen(true);
|
|
203
|
+
// Initialize quantities after a short delay to ensure data is loaded
|
|
204
|
+
setTimeout(initializeFulfillmentQuantities, 100);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const fulfillmentHandlers = fulfillmentHandlersData?.fulfillmentHandlers;
|
|
208
|
+
const selectedHandler = fulfillmentHandlers?.find(h => h.code === form.watch('handler.code'));
|
|
209
|
+
|
|
210
|
+
return (
|
|
211
|
+
<>
|
|
212
|
+
<Button
|
|
213
|
+
onClick={e => {
|
|
214
|
+
e.stopPropagation();
|
|
215
|
+
handleOpen();
|
|
216
|
+
}}
|
|
217
|
+
className="mr-2"
|
|
218
|
+
>
|
|
219
|
+
<Trans>Fulfill order</Trans>
|
|
220
|
+
</Button>
|
|
221
|
+
<Dialog open={open}>
|
|
222
|
+
<DialogContent className="sm:max-w-[600px]">
|
|
223
|
+
<DialogHeader>
|
|
224
|
+
<DialogTitle>
|
|
225
|
+
<Trans>Fulfill order</Trans>
|
|
226
|
+
</DialogTitle>
|
|
227
|
+
<DialogDescription>
|
|
228
|
+
<Trans>Select quantities to fulfill and configure the fulfillment handler</Trans>
|
|
229
|
+
</DialogDescription>
|
|
230
|
+
</DialogHeader>
|
|
231
|
+
<Form {...form}>
|
|
232
|
+
<form
|
|
233
|
+
onSubmit={e => {
|
|
234
|
+
e.stopPropagation();
|
|
235
|
+
form.handleSubmit(handleSubmit)(e);
|
|
236
|
+
}}
|
|
237
|
+
className="space-y-4"
|
|
238
|
+
>
|
|
239
|
+
<div className="space-y-4">
|
|
240
|
+
<div className="font-medium">
|
|
241
|
+
<Trans>Order lines</Trans>
|
|
242
|
+
</div>
|
|
243
|
+
{order.lines.map(line => {
|
|
244
|
+
const quantity = fulfillmentQuantities[line.id];
|
|
245
|
+
if (!quantity || quantity.max <= 0) return null;
|
|
246
|
+
|
|
247
|
+
return (
|
|
248
|
+
<div
|
|
249
|
+
key={line.id}
|
|
250
|
+
className="flex items-center justify-between p-3 border rounded-md"
|
|
251
|
+
>
|
|
252
|
+
<div className="flex-1">
|
|
253
|
+
<div className="font-medium">{line.productVariant.name}</div>
|
|
254
|
+
<div className="text-sm text-muted-foreground">
|
|
255
|
+
SKU: {line.productVariant.sku}
|
|
256
|
+
</div>
|
|
257
|
+
<div className="text-sm text-muted-foreground">
|
|
258
|
+
<Trans>
|
|
259
|
+
{quantity.max} of {line.quantity} available to fulfill
|
|
260
|
+
</Trans>
|
|
261
|
+
</div>
|
|
262
|
+
</div>
|
|
263
|
+
<div className="flex items-center space-x-2">
|
|
264
|
+
<Label htmlFor={`quantity-${line.id}`}>
|
|
265
|
+
<Trans>Quantity</Trans>
|
|
266
|
+
</Label>
|
|
267
|
+
<Input
|
|
268
|
+
id={`quantity-${line.id}`}
|
|
269
|
+
type="number"
|
|
270
|
+
min="0"
|
|
271
|
+
max={quantity.max}
|
|
272
|
+
value={quantity.fulfillCount}
|
|
273
|
+
onChange={e => {
|
|
274
|
+
const value = parseInt(e.target.value) || 0;
|
|
275
|
+
updateFulfillmentQuantity(line.id, value);
|
|
276
|
+
}}
|
|
277
|
+
className="w-20"
|
|
278
|
+
/>
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
);
|
|
282
|
+
})}
|
|
283
|
+
</div>
|
|
284
|
+
|
|
285
|
+
{selectedHandler && (
|
|
286
|
+
<FormFieldWrapper
|
|
287
|
+
control={form.control}
|
|
288
|
+
name="handler"
|
|
289
|
+
label={<Trans>Fulfillment handler</Trans>}
|
|
290
|
+
render={({ field }) => (
|
|
291
|
+
<ConfigurableOperationInput
|
|
292
|
+
operationDefinition={selectedHandler}
|
|
293
|
+
value={field.value}
|
|
294
|
+
onChange={field.onChange}
|
|
295
|
+
readonly={false}
|
|
296
|
+
removable={false}
|
|
297
|
+
/>
|
|
298
|
+
)}
|
|
299
|
+
/>
|
|
300
|
+
)}
|
|
301
|
+
|
|
302
|
+
<DialogFooter>
|
|
303
|
+
<Button type="button" variant="outline" onClick={handleCancel}>
|
|
304
|
+
<Trans>Cancel</Trans>
|
|
305
|
+
</Button>
|
|
306
|
+
<Button type="submit" disabled={!canSubmit() || isSubmitting}>
|
|
307
|
+
{isSubmitting ? (
|
|
308
|
+
<Trans>Fulfilling...</Trans>
|
|
309
|
+
) : (
|
|
310
|
+
<Trans>Fulfill order</Trans>
|
|
311
|
+
)}
|
|
312
|
+
</Button>
|
|
313
|
+
</DialogFooter>
|
|
314
|
+
</form>
|
|
315
|
+
</Form>
|
|
316
|
+
</DialogContent>
|
|
317
|
+
</Dialog>
|
|
318
|
+
</>
|
|
319
|
+
);
|
|
320
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { LabeledData } from '@/vdb/components/labeled-data.js';
|
|
2
|
+
import { Button } from '@/vdb/components/ui/button.js';
|
|
3
|
+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/vdb/components/ui/collapsible.js';
|
|
4
|
+
import {
|
|
5
|
+
DropdownMenu,
|
|
6
|
+
DropdownMenuContent,
|
|
7
|
+
DropdownMenuItem,
|
|
8
|
+
DropdownMenuTrigger,
|
|
9
|
+
} from '@/vdb/components/ui/dropdown-menu.js';
|
|
10
|
+
import { api } from '@/vdb/graphql/api.js';
|
|
11
|
+
import { ResultOf } from '@/vdb/graphql/graphql.js';
|
|
12
|
+
import { useLocalFormat } from '@/vdb/hooks/use-local-format.js';
|
|
13
|
+
import { Trans, useLingui } from '@/vdb/lib/trans.js';
|
|
14
|
+
import { useMutation } from '@tanstack/react-query';
|
|
15
|
+
import { ChevronDown } from 'lucide-react';
|
|
16
|
+
import { toast } from 'sonner';
|
|
17
|
+
import {
|
|
18
|
+
fulfillmentFragment,
|
|
19
|
+
orderDetailFragment,
|
|
20
|
+
transitionFulfillmentToStateDocument,
|
|
21
|
+
} from '../orders.graphql.js';
|
|
22
|
+
|
|
23
|
+
type Order = NonNullable<ResultOf<typeof orderDetailFragment>>;
|
|
24
|
+
|
|
25
|
+
type FulfillmentDetailsProps = {
|
|
26
|
+
order: Order;
|
|
27
|
+
fulfillment: ResultOf<typeof fulfillmentFragment>;
|
|
28
|
+
onSuccess?: () => void;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function FulfillmentDetails({ order, fulfillment, onSuccess }: Readonly<FulfillmentDetailsProps>) {
|
|
32
|
+
const { formatDate } = useLocalFormat();
|
|
33
|
+
const { i18n } = useLingui();
|
|
34
|
+
|
|
35
|
+
// Create a map of order lines by ID for quick lookup
|
|
36
|
+
const orderLinesMap = new Map(order.lines.map(line => [line.id, line]));
|
|
37
|
+
|
|
38
|
+
const transitionFulfillmentMutation = useMutation({
|
|
39
|
+
mutationFn: api.mutate(transitionFulfillmentToStateDocument),
|
|
40
|
+
onSuccess: (result: ResultOf<typeof transitionFulfillmentToStateDocument>) => {
|
|
41
|
+
const fulfillment = result.transitionFulfillmentToState;
|
|
42
|
+
if (fulfillment.__typename === 'Fulfillment') {
|
|
43
|
+
toast.success(i18n.t('Fulfillment state updated successfully'));
|
|
44
|
+
onSuccess?.();
|
|
45
|
+
} else {
|
|
46
|
+
toast.error(fulfillment.message ?? i18n.t('Failed to update fulfillment state'));
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
onError: error => {
|
|
50
|
+
toast.error(i18n.t('Failed to update fulfillment state'));
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const nextSuggestedState = (): string | undefined => {
|
|
55
|
+
const { nextStates } = fulfillment;
|
|
56
|
+
const namedStateOrDefault = (targetState: string) =>
|
|
57
|
+
nextStates.includes(targetState) ? targetState : nextStates[0];
|
|
58
|
+
|
|
59
|
+
switch (fulfillment.state) {
|
|
60
|
+
case 'Pending':
|
|
61
|
+
return namedStateOrDefault('Shipped');
|
|
62
|
+
case 'Shipped':
|
|
63
|
+
return namedStateOrDefault('Delivered');
|
|
64
|
+
default:
|
|
65
|
+
return nextStates.find(s => s !== 'Cancelled');
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const nextOtherStates = (): string[] => {
|
|
70
|
+
const suggested = nextSuggestedState();
|
|
71
|
+
return fulfillment.nextStates.filter(s => s !== suggested);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const handleStateTransition = (state: string) => {
|
|
75
|
+
transitionFulfillmentMutation.mutate({
|
|
76
|
+
id: fulfillment.id,
|
|
77
|
+
state,
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div className="space-y-1 p-3 border rounded-md">
|
|
83
|
+
<div className="space-y-1">
|
|
84
|
+
<LabeledData label={<Trans>Fulfillment ID</Trans>} value={fulfillment.id.slice(-8)} />
|
|
85
|
+
<LabeledData label={<Trans>Method</Trans>} value={fulfillment.method} />
|
|
86
|
+
<LabeledData label={<Trans>State</Trans>} value={fulfillment.state} />
|
|
87
|
+
{fulfillment.trackingCode && (
|
|
88
|
+
<LabeledData label={<Trans>Tracking code</Trans>} value={fulfillment.trackingCode} />
|
|
89
|
+
)}
|
|
90
|
+
<LabeledData label={<Trans>Created</Trans>} value={formatDate(fulfillment.createdAt)} />
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
{fulfillment.lines.length > 0 && (
|
|
94
|
+
<div className="mt-3 pt-3 border-t">
|
|
95
|
+
<Collapsible>
|
|
96
|
+
<CollapsibleTrigger className="flex items-center justify-between w-full text-sm hover:underline text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-md p-1 -m-1">
|
|
97
|
+
<Trans>
|
|
98
|
+
Fulfilled items (
|
|
99
|
+
{fulfillment.lines.reduce((acc, line) => acc + line.quantity, 0)})
|
|
100
|
+
</Trans>
|
|
101
|
+
<ChevronDown className="h-4 w-4 transition-transform duration-200 data-[state=open]:rotate-180" />
|
|
102
|
+
</CollapsibleTrigger>
|
|
103
|
+
<CollapsibleContent className="mt-2 space-y-1">
|
|
104
|
+
{fulfillment.lines.map((line) => {
|
|
105
|
+
const orderLine = orderLinesMap.get(line.orderLineId);
|
|
106
|
+
const productName = orderLine?.productVariant?.name ?? 'Unknown product';
|
|
107
|
+
const sku = orderLine?.productVariant?.sku;
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<div key={line.orderLineId} className="text-sm text-muted-foreground">
|
|
111
|
+
<div className="font-medium text-foreground text-xs">
|
|
112
|
+
{productName}
|
|
113
|
+
</div>
|
|
114
|
+
<div className="flex items-center gap-2 text-xs">
|
|
115
|
+
<span>Qty: {line.quantity}</span>
|
|
116
|
+
{sku && <span>SKU: {sku}</span>}
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
})}
|
|
121
|
+
</CollapsibleContent>
|
|
122
|
+
</Collapsible>
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
125
|
+
|
|
126
|
+
{fulfillment.nextStates.length > 0 && (
|
|
127
|
+
<div className="mt-3 pt-3 border-t">
|
|
128
|
+
<div className="flex">
|
|
129
|
+
<Button
|
|
130
|
+
variant="outline"
|
|
131
|
+
size="sm"
|
|
132
|
+
disabled={transitionFulfillmentMutation.isPending}
|
|
133
|
+
className="rounded-r-none flex-1 justify-start shadow-none"
|
|
134
|
+
>
|
|
135
|
+
<Trans>State: {fulfillment.state}</Trans>
|
|
136
|
+
</Button>
|
|
137
|
+
<DropdownMenu>
|
|
138
|
+
<DropdownMenuTrigger asChild>
|
|
139
|
+
<Button
|
|
140
|
+
variant="outline"
|
|
141
|
+
size="sm"
|
|
142
|
+
disabled={transitionFulfillmentMutation.isPending}
|
|
143
|
+
className="rounded-l-none border-l-0 shadow-none"
|
|
144
|
+
>
|
|
145
|
+
<ChevronDown className="h-4 w-4" />
|
|
146
|
+
</Button>
|
|
147
|
+
</DropdownMenuTrigger>
|
|
148
|
+
<DropdownMenuContent align="end">
|
|
149
|
+
{nextSuggestedState() && (
|
|
150
|
+
<DropdownMenuItem
|
|
151
|
+
onClick={() => handleStateTransition(nextSuggestedState()!)}
|
|
152
|
+
disabled={transitionFulfillmentMutation.isPending}
|
|
153
|
+
>
|
|
154
|
+
<Trans>Transition to {nextSuggestedState()}</Trans>
|
|
155
|
+
</DropdownMenuItem>
|
|
156
|
+
)}
|
|
157
|
+
{nextOtherStates().map(state => (
|
|
158
|
+
<DropdownMenuItem
|
|
159
|
+
key={state}
|
|
160
|
+
onClick={() => handleStateTransition(state)}
|
|
161
|
+
disabled={transitionFulfillmentMutation.isPending}
|
|
162
|
+
>
|
|
163
|
+
<Trans>Transition to {state}</Trans>
|
|
164
|
+
</DropdownMenuItem>
|
|
165
|
+
))}
|
|
166
|
+
</DropdownMenuContent>
|
|
167
|
+
</DropdownMenu>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
)}
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
@@ -24,11 +24,11 @@ export function OrderAddress({ address }: Readonly<{ address?: OrderAddress }>)
|
|
|
24
24
|
} = address;
|
|
25
25
|
|
|
26
26
|
return (
|
|
27
|
-
<div className="space-y-
|
|
27
|
+
<div className="space-y-1 text-sm">
|
|
28
28
|
{fullName && <p className="font-medium">{fullName}</p>}
|
|
29
29
|
{company && <p className="text-sm text-muted-foreground">{company}</p>}
|
|
30
30
|
|
|
31
|
-
<div
|
|
31
|
+
<div>
|
|
32
32
|
{streetLine1 && <p>{streetLine1}</p>}
|
|
33
33
|
{streetLine2 && <p>{streetLine2}</p>}
|
|
34
34
|
<p>{[city, province].filter(Boolean).join(', ')}</p>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { api } from '@/vdb/graphql/api.js';
|
|
2
2
|
import { graphql, ResultOf } from '@/vdb/graphql/graphql.js';
|
|
3
3
|
import { useLingui } from '@/vdb/lib/trans.js';
|
|
4
|
-
import { useInfiniteQuery, useMutation } from '@tanstack/react-query';
|
|
4
|
+
import { QueryKey, useInfiniteQuery, useMutation } from '@tanstack/react-query';
|
|
5
5
|
import { useState } from 'react';
|
|
6
6
|
import { toast } from 'sonner';
|
|
7
7
|
|
|
@@ -55,6 +55,10 @@ export interface UseOrderHistoryResult {
|
|
|
55
55
|
hasNextPage: boolean;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
export function orderHistoryQueryKey(orderId: string): QueryKey {
|
|
59
|
+
return ['OrderHistory', orderId];
|
|
60
|
+
}
|
|
61
|
+
|
|
58
62
|
export function useOrderHistory({
|
|
59
63
|
orderId,
|
|
60
64
|
pageSize = 10,
|
|
@@ -83,7 +87,7 @@ export function useOrderHistory({
|
|
|
83
87
|
take: pageSize,
|
|
84
88
|
},
|
|
85
89
|
}),
|
|
86
|
-
queryKey:
|
|
90
|
+
queryKey: orderHistoryQueryKey(orderId),
|
|
87
91
|
initialPageParam: 0,
|
|
88
92
|
getNextPageParam: (lastPage, _pages, lastPageParam) => {
|
|
89
93
|
const totalItems = lastPage.order?.history?.totalItems ?? 0;
|
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
import { TableCell, TableRow } from '@/vdb/components/ui/table.js';
|
|
2
|
-
import { ResultOf } from '@/vdb/graphql/graphql.js';
|
|
3
2
|
import { Trans } from '@/vdb/lib/trans.js';
|
|
4
|
-
import {
|
|
3
|
+
import { Order } from '../utils/order-types.js';
|
|
5
4
|
import { MoneyGrossNet } from './money-gross-net.js';
|
|
6
5
|
|
|
7
|
-
type OrderFragment = NonNullable<ResultOf<typeof orderDetailDocument>['order']>;
|
|
8
|
-
|
|
9
6
|
export interface OrderTableTotalsProps {
|
|
10
|
-
order:
|
|
7
|
+
order: Order;
|
|
11
8
|
columnCount: number;
|
|
12
9
|
}
|
|
13
10
|
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/vdb/components/ui/table.js';
|
|
2
2
|
import { useLocalFormat } from '@/vdb/hooks/use-local-format.js';
|
|
3
3
|
import { Trans } from '@/vdb/lib/trans.js';
|
|
4
|
-
import {
|
|
5
|
-
import { orderDetailFragment } from '../orders.graphql.js';
|
|
4
|
+
import { Order } from '../utils/order-types.js';
|
|
6
5
|
|
|
7
|
-
export function OrderTaxSummary({ order }: Readonly<{ order:
|
|
6
|
+
export function OrderTaxSummary({ order }: Readonly<{ order: Order }>) {
|
|
8
7
|
const { formatCurrency } = useLocalFormat();
|
|
9
8
|
return (
|
|
10
9
|
<div>
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
import { LabeledData } from '@/vdb/components/labeled-data.js';
|
|
1
2
|
import { ResultOf } from '@/vdb/graphql/graphql.js';
|
|
2
3
|
import { useLocalFormat } from '@/vdb/hooks/use-local-format.js';
|
|
4
|
+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/vdb/components/ui/collapsible.js';
|
|
5
|
+
import { ChevronDown } from 'lucide-react';
|
|
3
6
|
import { Trans } from '@/vdb/lib/trans.js';
|
|
4
7
|
import { paymentWithRefundsFragment } from '../orders.graphql.js';
|
|
8
|
+
import { JsonEditor } from 'json-edit-react';
|
|
5
9
|
|
|
6
10
|
type PaymentDetailsProps = {
|
|
7
11
|
payment: ResultOf<typeof paymentWithRefundsFragment>;
|
|
@@ -13,17 +17,13 @@ export function PaymentDetails({ payment, currencyCode }: Readonly<PaymentDetail
|
|
|
13
17
|
const t = (key: string) => key;
|
|
14
18
|
|
|
15
19
|
return (
|
|
16
|
-
<div className="space-y-
|
|
20
|
+
<div className="space-y-1 p-3 border rounded-md">
|
|
17
21
|
<LabeledData label={<Trans>Payment method</Trans>} value={payment.method} />
|
|
18
|
-
|
|
19
22
|
<LabeledData label={<Trans>Amount</Trans>} value={formatCurrency(payment.amount, currencyCode)} />
|
|
20
|
-
|
|
21
23
|
<LabeledData label={<Trans>Created at</Trans>} value={formatDate(payment.createdAt)} />
|
|
22
|
-
|
|
23
24
|
{payment.transactionId && (
|
|
24
25
|
<LabeledData label={<Trans>Transaction ID</Trans>} value={payment.transactionId} />
|
|
25
26
|
)}
|
|
26
|
-
|
|
27
27
|
{/* We need to check if there is errorMessage field in the Payment type */}
|
|
28
28
|
{payment.errorMessage && (
|
|
29
29
|
<LabeledData
|
|
@@ -32,30 +32,15 @@ export function PaymentDetails({ payment, currencyCode }: Readonly<PaymentDetail
|
|
|
32
32
|
className="text-destructive"
|
|
33
33
|
/>
|
|
34
34
|
)}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
</div>
|
|
45
|
-
);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
type LabeledDataProps = {
|
|
49
|
-
label: string | React.ReactNode;
|
|
50
|
-
value: React.ReactNode;
|
|
51
|
-
className?: string;
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
function LabeledData({ label, value, className }: LabeledDataProps) {
|
|
55
|
-
return (
|
|
56
|
-
<div className="">
|
|
57
|
-
<span className="font-medium text-muted-foreground text-sm">{label}</span>
|
|
58
|
-
<div className={`col-span-2 ${className}`}>{value}</div>
|
|
35
|
+
<Collapsible className="mt-2 border-t pt-2">
|
|
36
|
+
<CollapsibleTrigger className="flex items-center justify-between w-full text-sm hover:underline text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-md p-1 -m-1">
|
|
37
|
+
<Trans>Payment metadata</Trans>
|
|
38
|
+
<ChevronDown className="h-4 w-4 transition-transform duration-200 data-[state=open]:rotate-180" />
|
|
39
|
+
</CollapsibleTrigger>
|
|
40
|
+
<CollapsibleContent className="mt-2">
|
|
41
|
+
<JsonEditor viewOnly rootFontSize={12} minWidth={100} rootName='' data={payment.metadata} collapse />
|
|
42
|
+
</CollapsibleContent>
|
|
43
|
+
</Collapsible>
|
|
59
44
|
</div>
|
|
60
45
|
);
|
|
61
46
|
}
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
assetFragment,
|
|
3
|
+
configurableOperationDefFragment,
|
|
4
|
+
errorResultFragment,
|
|
5
|
+
} from '@/vdb/graphql/fragments.js';
|
|
2
6
|
import { graphql } from '@/vdb/graphql/graphql.js';
|
|
3
7
|
|
|
4
8
|
export const orderListDocument = graphql(`
|
|
@@ -20,7 +24,6 @@ export const orderListDocument = graphql(`
|
|
|
20
24
|
total
|
|
21
25
|
totalWithTax
|
|
22
26
|
currencyCode
|
|
23
|
-
|
|
24
27
|
shippingLines {
|
|
25
28
|
shippingMethod {
|
|
26
29
|
name
|
|
@@ -508,3 +511,98 @@ export const transitionOrderToStateDocument = graphql(
|
|
|
508
511
|
`,
|
|
509
512
|
[errorResultFragment],
|
|
510
513
|
);
|
|
514
|
+
|
|
515
|
+
export const paymentMethodsDocument = graphql(`
|
|
516
|
+
query GetPaymentMethods($options: PaymentMethodListOptions!) {
|
|
517
|
+
paymentMethods(options: $options) {
|
|
518
|
+
items {
|
|
519
|
+
id
|
|
520
|
+
createdAt
|
|
521
|
+
updatedAt
|
|
522
|
+
name
|
|
523
|
+
code
|
|
524
|
+
description
|
|
525
|
+
enabled
|
|
526
|
+
}
|
|
527
|
+
totalItems
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
`);
|
|
531
|
+
|
|
532
|
+
export const addManualPaymentToOrderDocument = graphql(
|
|
533
|
+
`
|
|
534
|
+
mutation AddManualPaymentToOrder($input: ManualPaymentInput!) {
|
|
535
|
+
addManualPaymentToOrder(input: $input) {
|
|
536
|
+
__typename
|
|
537
|
+
... on Order {
|
|
538
|
+
id
|
|
539
|
+
state
|
|
540
|
+
payments {
|
|
541
|
+
id
|
|
542
|
+
amount
|
|
543
|
+
method
|
|
544
|
+
state
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
...ErrorResult
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
`,
|
|
551
|
+
[errorResultFragment],
|
|
552
|
+
);
|
|
553
|
+
|
|
554
|
+
export const fulfillmentHandlersDocument = graphql(
|
|
555
|
+
`
|
|
556
|
+
query GetFulfillmentHandlers {
|
|
557
|
+
fulfillmentHandlers {
|
|
558
|
+
...ConfigurableOperationDef
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
`,
|
|
562
|
+
[configurableOperationDefFragment],
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
export const fulfillOrderDocument = graphql(
|
|
566
|
+
`
|
|
567
|
+
mutation FulfillOrder($input: FulfillOrderInput!) {
|
|
568
|
+
addFulfillmentToOrder(input: $input) {
|
|
569
|
+
__typename
|
|
570
|
+
... on Fulfillment {
|
|
571
|
+
id
|
|
572
|
+
state
|
|
573
|
+
method
|
|
574
|
+
trackingCode
|
|
575
|
+
lines {
|
|
576
|
+
orderLineId
|
|
577
|
+
quantity
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
...ErrorResult
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
`,
|
|
584
|
+
[errorResultFragment],
|
|
585
|
+
);
|
|
586
|
+
|
|
587
|
+
export const transitionFulfillmentToStateDocument = graphql(
|
|
588
|
+
`
|
|
589
|
+
mutation TransitionFulfillmentToState($id: ID!, $state: String!) {
|
|
590
|
+
transitionFulfillmentToState(id: $id, state: $state) {
|
|
591
|
+
__typename
|
|
592
|
+
... on Fulfillment {
|
|
593
|
+
id
|
|
594
|
+
state
|
|
595
|
+
nextStates
|
|
596
|
+
method
|
|
597
|
+
trackingCode
|
|
598
|
+
lines {
|
|
599
|
+
orderLineId
|
|
600
|
+
quantity
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
...ErrorResult
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
`,
|
|
607
|
+
[errorResultFragment],
|
|
608
|
+
);
|
|
@@ -18,12 +18,18 @@ import { Trans, useLingui } from '@/vdb/lib/trans.js';
|
|
|
18
18
|
import { Link, createFileRoute, redirect } from '@tanstack/react-router';
|
|
19
19
|
import { User } from 'lucide-react';
|
|
20
20
|
import { toast } from 'sonner';
|
|
21
|
+
import { AddManualPaymentDialog } from './components/add-manual-payment-dialog.js';
|
|
22
|
+
import { FulfillOrderDialog } from './components/fulfill-order-dialog.js';
|
|
23
|
+
import { FulfillmentDetails } from './components/fulfillment-details.js';
|
|
21
24
|
import { OrderAddress } from './components/order-address.js';
|
|
22
25
|
import { OrderHistoryContainer } from './components/order-history/order-history-container.js';
|
|
23
26
|
import { OrderTable } from './components/order-table.js';
|
|
24
27
|
import { OrderTaxSummary } from './components/order-tax-summary.js';
|
|
25
28
|
import { PaymentDetails } from './components/payment-details.js';
|
|
26
29
|
import { orderDetailDocument } from './orders.graphql.js';
|
|
30
|
+
import { canAddFulfillment, shouldShowAddManualPaymentButton } from './utils/order-utils.js';
|
|
31
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
32
|
+
import { orderHistoryQueryKey } from './components/order-history/use-order-history.js';
|
|
27
33
|
|
|
28
34
|
const pageId = 'order-detail';
|
|
29
35
|
|
|
@@ -59,8 +65,8 @@ export const Route = createFileRoute('/_authenticated/_orders/orders_/$id')({
|
|
|
59
65
|
function OrderDetailPage() {
|
|
60
66
|
const params = Route.useParams();
|
|
61
67
|
const { i18n } = useLingui();
|
|
62
|
-
|
|
63
|
-
const { form, submitHandler, entity, isPending,
|
|
68
|
+
const queryClient = useQueryClient();
|
|
69
|
+
const { form, submitHandler, entity, isPending, refreshEntity } = useDetailPage({
|
|
64
70
|
pageId,
|
|
65
71
|
queryDocument: orderDetailDocument,
|
|
66
72
|
setValuesForUpdate: entity => {
|
|
@@ -85,11 +91,35 @@ function OrderDetailPage() {
|
|
|
85
91
|
return null;
|
|
86
92
|
}
|
|
87
93
|
|
|
94
|
+
const showAddPaymentButton = shouldShowAddManualPaymentButton(entity);
|
|
95
|
+
const showFulfillButton = canAddFulfillment(entity);
|
|
96
|
+
|
|
88
97
|
return (
|
|
89
98
|
<Page pageId={pageId} form={form} submitHandler={submitHandler} entity={entity}>
|
|
90
99
|
<PageTitle>{entity?.code ?? ''}</PageTitle>
|
|
91
100
|
<PageActionBar>
|
|
92
101
|
<PageActionBarRight>
|
|
102
|
+
{showAddPaymentButton && (
|
|
103
|
+
<PermissionGuard requires={['UpdateOrder']}>
|
|
104
|
+
<AddManualPaymentDialog
|
|
105
|
+
order={entity}
|
|
106
|
+
onSuccess={() => {
|
|
107
|
+
refreshEntity();
|
|
108
|
+
}}
|
|
109
|
+
/>
|
|
110
|
+
</PermissionGuard>
|
|
111
|
+
)}
|
|
112
|
+
{showFulfillButton && (
|
|
113
|
+
<PermissionGuard requires={['UpdateOrder']}>
|
|
114
|
+
<FulfillOrderDialog
|
|
115
|
+
order={entity}
|
|
116
|
+
onSuccess={() => {
|
|
117
|
+
refreshEntity();
|
|
118
|
+
queryClient.refetchQueries({ queryKey: orderHistoryQueryKey(entity.id) });
|
|
119
|
+
}}
|
|
120
|
+
/>
|
|
121
|
+
</PermissionGuard>
|
|
122
|
+
)}
|
|
93
123
|
<PermissionGuard requires={['UpdateProduct', 'UpdateCatalog']}>
|
|
94
124
|
<Button
|
|
95
125
|
type="submit"
|
|
@@ -149,6 +179,32 @@ function OrderDetailPage() {
|
|
|
149
179
|
/>
|
|
150
180
|
))}
|
|
151
181
|
</PageBlock>
|
|
182
|
+
|
|
183
|
+
<PageBlock
|
|
184
|
+
column="side"
|
|
185
|
+
blockId="fulfillment-details"
|
|
186
|
+
title={<Trans>Fulfillment details</Trans>}
|
|
187
|
+
>
|
|
188
|
+
{entity?.fulfillments?.length && entity.fulfillments.length > 0 ? (
|
|
189
|
+
<div className="space-y-2">
|
|
190
|
+
{entity?.fulfillments?.map(fulfillment => (
|
|
191
|
+
<FulfillmentDetails
|
|
192
|
+
key={fulfillment.id}
|
|
193
|
+
order={entity}
|
|
194
|
+
fulfillment={fulfillment}
|
|
195
|
+
onSuccess={() => {
|
|
196
|
+
refreshEntity();
|
|
197
|
+
queryClient.refetchQueries({ queryKey: orderHistoryQueryKey(entity.id) });
|
|
198
|
+
}}
|
|
199
|
+
/>
|
|
200
|
+
))}
|
|
201
|
+
</div>
|
|
202
|
+
) : (
|
|
203
|
+
<div className="text-muted-foreground text-xs font-medium p-3 border rounded-md">
|
|
204
|
+
<Trans>No fulfillments</Trans>
|
|
205
|
+
</div>
|
|
206
|
+
)}
|
|
207
|
+
</PageBlock>
|
|
152
208
|
</PageLayout>
|
|
153
209
|
</Page>
|
|
154
210
|
);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { ResultOf } from '@/vdb/graphql/graphql.js';
|
|
2
|
+
|
|
3
|
+
import { orderDetailDocument } from '../orders.graphql.js';
|
|
4
|
+
|
|
5
|
+
export type Order = NonNullable<ResultOf<typeof orderDetailDocument>['order']>;
|
|
6
|
+
export type Payment = NonNullable<NonNullable<Order>['payments']>[number];
|
|
7
|
+
export type Fulfillment = NonNullable<NonNullable<Order>['fulfillments']>[number];
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Fulfillment, Order, Payment } from './order-types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Calculates the outstanding payment amount for an order
|
|
5
|
+
*/
|
|
6
|
+
export function calculateOutstandingPaymentAmount(order: Order): number {
|
|
7
|
+
if (!order) return 0;
|
|
8
|
+
|
|
9
|
+
const paymentIsValid = (p: Payment): boolean =>
|
|
10
|
+
p.state !== 'Cancelled' && p.state !== 'Declined' && p.state !== 'Error';
|
|
11
|
+
|
|
12
|
+
let amountCovered = 0;
|
|
13
|
+
for (const payment of order.payments?.filter(paymentIsValid) ?? []) {
|
|
14
|
+
const refunds = payment.refunds.filter(r => r.state !== 'Failed') ?? [];
|
|
15
|
+
const refundsTotal = refunds.reduce((sum, refund) => sum + (refund.total || 0), 0);
|
|
16
|
+
amountCovered += payment.amount - refundsTotal;
|
|
17
|
+
}
|
|
18
|
+
return order.totalWithTax - amountCovered;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Checks if an order has unsettled modifications
|
|
23
|
+
*/
|
|
24
|
+
export function hasUnsettledModifications(order: Order): boolean {
|
|
25
|
+
if (!order) return false;
|
|
26
|
+
return order.modifications.some(m => !m.isSettled);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Determines if the add manual payment button should be displayed
|
|
31
|
+
*/
|
|
32
|
+
export function shouldShowAddManualPaymentButton(order: Order): boolean {
|
|
33
|
+
if (!order) return false;
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
order.type !== 'Aggregate' &&
|
|
37
|
+
(order.state === 'ArrangingPayment' || order.state === 'ArrangingAdditionalPayment') &&
|
|
38
|
+
(hasUnsettledModifications(order) || calculateOutstandingPaymentAmount(order) > 0)
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Determines if we can add a fulfillment to an order
|
|
44
|
+
*/
|
|
45
|
+
export function canAddFulfillment(order: Order): boolean {
|
|
46
|
+
if (!order) return false;
|
|
47
|
+
|
|
48
|
+
// Get all fulfillment lines from non-cancelled fulfillments
|
|
49
|
+
const allFulfillmentLines: Fulfillment['lines'] = (order.fulfillments ?? [])
|
|
50
|
+
.filter(fulfillment => fulfillment.state !== 'Cancelled')
|
|
51
|
+
.reduce((all, fulfillment) => [...all, ...fulfillment.lines], [] as Fulfillment['lines']);
|
|
52
|
+
|
|
53
|
+
// Check if all items are already fulfilled
|
|
54
|
+
let allItemsFulfilled = true;
|
|
55
|
+
for (const line of order.lines) {
|
|
56
|
+
const totalFulfilledCount = allFulfillmentLines
|
|
57
|
+
.filter(row => row.orderLineId === line.id)
|
|
58
|
+
.reduce((sum, row) => sum + row.quantity, 0);
|
|
59
|
+
if (totalFulfilledCount < line.quantity) {
|
|
60
|
+
allItemsFulfilled = false;
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Check if order is in a fulfillable state
|
|
66
|
+
const isFulfillableState =
|
|
67
|
+
order.nextStates.includes('Shipped') ||
|
|
68
|
+
order.nextStates.includes('PartiallyShipped') ||
|
|
69
|
+
order.nextStates.includes('Delivered');
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
!allItemsFulfilled &&
|
|
73
|
+
!hasUnsettledModifications(order) &&
|
|
74
|
+
calculateOutstandingPaymentAmount(order) === 0 &&
|
|
75
|
+
isFulfillableState
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { graphql } from '@/vdb/graphql/graphql.js';
|
|
2
2
|
import { createRelationSelectorConfig, RelationSelector } from './relation-selector.js';
|
|
3
3
|
|
|
4
|
-
// Re-export for convenience
|
|
5
|
-
export { createRelationSelectorConfig };
|
|
6
|
-
|
|
7
4
|
/**
|
|
8
5
|
* Single relation input component
|
|
9
6
|
*/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
type LabeledDataProps = {
|
|
2
|
+
label: string | React.ReactNode;
|
|
3
|
+
value: React.ReactNode;
|
|
4
|
+
className?: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @description
|
|
9
|
+
* Used to display a value with a label, like
|
|
10
|
+
*
|
|
11
|
+
* Order Code
|
|
12
|
+
* QWERTY
|
|
13
|
+
*/
|
|
14
|
+
export function LabeledData({ label, value, className }: Readonly<LabeledDataProps>) {
|
|
15
|
+
return (
|
|
16
|
+
<div className="">
|
|
17
|
+
<span className="font-medium text-muted-foreground text-xs">{label}</span>
|
|
18
|
+
<div className={`col-span-2 text-sm ${className}`}>{value}</div>
|
|
19
|
+
</div>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -111,7 +111,7 @@ export function vendureDashboardPlugin(options: VitePluginVendureDashboardOption
|
|
|
111
111
|
: [
|
|
112
112
|
TanStackRouterVite({
|
|
113
113
|
autoCodeSplitting: true,
|
|
114
|
-
routeFileIgnorePattern: '.graphql.ts|components|hooks',
|
|
114
|
+
routeFileIgnorePattern: '.graphql.ts|components|hooks|utils',
|
|
115
115
|
routesDirectory: path.join(packageRoot, 'src/app/routes'),
|
|
116
116
|
generatedRouteTree: path.join(packageRoot, 'src/app/routeTree.gen.ts'),
|
|
117
117
|
}),
|