@vendure/dashboard 3.3.6-master-202507031127 → 3.3.6-master-202507031620

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 (22) hide show
  1. package/dist/plugin/vite-plugin-vendure-dashboard.js +1 -1
  2. package/package.json +5 -5
  3. package/src/app/routes/_authenticated/_orders/components/add-manual-payment-dialog.tsx +191 -0
  4. package/src/app/routes/_authenticated/_orders/components/edit-order-table.tsx +2 -2
  5. package/src/app/routes/_authenticated/_orders/components/fulfill-order-dialog.tsx +320 -0
  6. package/src/app/routes/_authenticated/_orders/components/fulfillment-details.tsx +173 -0
  7. package/src/app/routes/_authenticated/_orders/components/order-address.tsx +2 -2
  8. package/src/app/routes/_authenticated/_orders/components/order-history/use-order-history.ts +6 -2
  9. package/src/app/routes/_authenticated/_orders/components/order-table-totals.tsx +2 -5
  10. package/src/app/routes/_authenticated/_orders/components/order-tax-summary.tsx +2 -3
  11. package/src/app/routes/_authenticated/_orders/components/payment-details.tsx +14 -29
  12. package/src/app/routes/_authenticated/_orders/orders.graphql.ts +100 -2
  13. package/src/app/routes/_authenticated/_orders/orders_.$id.tsx +58 -2
  14. package/src/app/routes/_authenticated/_orders/utils/order-types.ts +7 -0
  15. package/src/app/routes/_authenticated/_orders/utils/order-utils.ts +77 -0
  16. package/src/lib/components/data-input/relation-input.tsx +0 -3
  17. package/src/lib/components/labeled-data.tsx +21 -0
  18. package/src/lib/components/shared/translatable-form-field.tsx +2 -1
  19. package/src/lib/components/ui/select.tsx +151 -129
  20. package/src/lib/framework/page/detail-page.tsx +26 -20
  21. package/src/lib/graphql/graphql-env.d.ts +1 -1
  22. package/vite/vite-plugin-vendure-dashboard.ts +1 -1
@@ -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-2">
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 className="text-sm">
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: ['OrderHistory', orderId],
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 { orderDetailDocument } from '../orders.graphql.js';
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: OrderFragment;
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 { ResultOf } from 'gql.tada';
5
- import { orderDetailFragment } from '../orders.graphql.js';
4
+ import { Order } from '../utils/order-types.js';
6
5
 
7
- export function OrderTaxSummary({ order }: Readonly<{ order: ResultOf<typeof orderDetailFragment> }>) {
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-2">
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
- <LabeledData
37
- label={<Trans>Payment metadata</Trans>}
38
- value={
39
- <pre className="max-h-96 overflow-auto rounded-md bg-muted p-4 text-sm">
40
- {JSON.stringify(payment.metadata, null, 2)}
41
- </pre>
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 { assetFragment, errorResultFragment } from '@/vdb/graphql/fragments.js';
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, resetForm } = useDetailPage({
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
+ }
@@ -49,6 +49,7 @@ export const TranslatableFormFieldWrapper = <
49
49
  label,
50
50
  description,
51
51
  render,
52
+ renderFormControl,
52
53
  ...props
53
54
  }: TranslatableFormFieldWrapperProps<TFieldValues>) => {
54
55
  return (
@@ -58,7 +59,7 @@ export const TranslatableFormFieldWrapper = <
58
59
  render={renderArgs => (
59
60
  <FormItem>
60
61
  {label && <FormLabel>{label}</FormLabel>}
61
- <FormControl>{render(renderArgs)}</FormControl>
62
+ {renderFormControl ? <FormControl>{render(renderArgs)}</FormControl> : render(renderArgs)}
62
63
  {description && <FormDescription>{description}</FormDescription>}
63
64
  <FormMessage />
64
65
  </FormItem>