@vendure/dashboard 3.5.3-master-202601290259 → 3.5.3

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 (68) hide show
  1. package/dist/vite/utils/plugin-discovery.js +3 -3
  2. package/dist/vite/utils/ui-config.js +15 -1
  3. package/dist/vite/vite-plugin-lingui-babel.d.ts +15 -2
  4. package/dist/vite/vite-plugin-lingui-babel.js +90 -8
  5. package/dist/vite/vite-plugin-translations.js +2 -2
  6. package/dist/vite/vite-plugin-ui-config.d.ts +31 -0
  7. package/package.json +3 -3
  8. package/src/app/routes/_authenticated/_collections/collections_.$id.tsx +22 -3
  9. package/src/app/routes/_authenticated/_customers/customers.graphql.ts +1 -0
  10. package/src/app/routes/_authenticated/_customers/customers.tsx +3 -0
  11. package/src/app/routes/_authenticated/_orders/components/draft-order-status.tsx +48 -0
  12. package/src/app/routes/_authenticated/_orders/components/fulfill-order-dialog.tsx +4 -4
  13. package/src/app/routes/_authenticated/_orders/components/order-detail-shared.tsx +60 -33
  14. package/src/app/routes/_authenticated/_orders/components/order-history/default-order-history-components.tsx +43 -3
  15. package/src/app/routes/_authenticated/_orders/components/order-history/order-history-utils.tsx +19 -3
  16. package/src/app/routes/_authenticated/_orders/components/order-table.tsx +1 -0
  17. package/src/app/routes/_authenticated/_orders/components/refund-order-dialog.tsx +372 -0
  18. package/src/app/routes/_authenticated/_orders/hooks/use-refund-order.ts +345 -0
  19. package/src/app/routes/_authenticated/_orders/orders.graphql.ts +41 -0
  20. package/src/app/routes/_authenticated/_orders/orders_.draft.$id.tsx +22 -6
  21. package/src/app/routes/_authenticated/_orders/utils/order-utils.ts +51 -0
  22. package/src/app/routes/_authenticated/_orders/utils/refund-utils.ts +100 -0
  23. package/src/app/routes/_authenticated/_orders/utils/use-modify-order.ts +1 -1
  24. package/src/app/routes/_authenticated/_payment-methods/payment-methods_.$id.tsx +1 -0
  25. package/src/app/routes/_authenticated/_product-variants/product-variants.graphql.ts +9 -3
  26. package/src/app/routes/_authenticated/_product-variants/product-variants_.$id.tsx +49 -30
  27. package/src/app/routes/_authenticated/_profile/profile.graphql.ts +7 -0
  28. package/src/app/routes/_authenticated/_profile/profile.tsx +25 -1
  29. package/src/app/routes/_authenticated/_promotions/promotions_.$id.tsx +1 -0
  30. package/src/app/routes/_authenticated/_shipping-methods/shipping-methods_.$id.tsx +1 -0
  31. package/src/i18n/common-strings.ts +7 -0
  32. package/src/i18n/locales/ar.po +915 -663
  33. package/src/i18n/locales/bg.po +1818 -46
  34. package/src/i18n/locales/cs.po +865 -666
  35. package/src/i18n/locales/de.po +865 -666
  36. package/src/i18n/locales/en.po +914 -662
  37. package/src/i18n/locales/es.po +865 -666
  38. package/src/i18n/locales/fa.po +865 -666
  39. package/src/i18n/locales/fr.po +865 -666
  40. package/src/i18n/locales/he.po +865 -666
  41. package/src/i18n/locales/hr.po +865 -666
  42. package/src/i18n/locales/it.po +865 -666
  43. package/src/i18n/locales/ja.po +865 -666
  44. package/src/i18n/locales/nb.po +865 -666
  45. package/src/i18n/locales/ne.po +865 -666
  46. package/src/i18n/locales/pl.po +865 -666
  47. package/src/i18n/locales/pt_BR.po +865 -666
  48. package/src/i18n/locales/pt_PT.po +865 -666
  49. package/src/i18n/locales/ru.po +865 -666
  50. package/src/i18n/locales/sv.po +865 -666
  51. package/src/i18n/locales/tr.po +865 -666
  52. package/src/i18n/locales/uk.po +865 -666
  53. package/src/i18n/locales/zh_Hans.po +865 -666
  54. package/src/i18n/locales/zh_Hant.po +865 -666
  55. package/src/lib/components/data-input/index.ts +1 -0
  56. package/src/lib/components/data-table/use-generated-columns.tsx +9 -2
  57. package/src/lib/components/shared/paginated-list-data-table.tsx +6 -2
  58. package/src/lib/components/ui/alert.tsx +2 -0
  59. package/src/lib/framework/extension-api/input-component-extensions.tsx +2 -0
  60. package/src/lib/framework/form-engine/form-schema-tools.ts +4 -1
  61. package/src/lib/framework/page/detail-page-route-loader.tsx +6 -4
  62. package/src/lib/framework/page/detail-page.tsx +22 -37
  63. package/src/lib/framework/page/list-page.stories.tsx +41 -2
  64. package/src/lib/framework/page/list-page.tsx +8 -0
  65. package/src/lib/graphql/graphql-env.d.ts +30 -13
  66. package/src/lib/hooks/use-dynamic-translations.ts +7 -0
  67. package/src/lib/hooks/use-job-queue-polling.ts +160 -0
  68. package/src/lib/virtual.d.ts +5 -0
@@ -323,9 +323,9 @@ export async function findVendurePluginFiles({ outputPath, vendureConfigPath, lo
323
323
  const globStart = Date.now();
324
324
  const files = await glob(patterns, {
325
325
  ignore: [
326
- // Skip nested node_modules (transitive deps) but not .pnpm directory.
327
- // [!.] ensures .pnpm paths are kept since pnpm stores all packages there.
328
- '**/node_modules/[!.pnpm]*/**/node_modules/**',
326
+ // Skip nested node_modules (transitive deps) but not .pnpm or .bun directories.
327
+ // [!.] excludes paths starting with . since pnpm and bun store packages there.
328
+ '**/node_modules/[!.]*/**/node_modules/**',
329
329
  '**/*.spec.js',
330
330
  '**/*.test.js',
331
331
  ],
@@ -1,7 +1,7 @@
1
1
  import { ADMIN_API_PATH, DEFAULT_AUTH_TOKEN_HEADER_KEY, DEFAULT_CHANNEL_TOKEN_KEY, } from '@vendure/common/lib/shared-constants';
2
2
  import { defaultAvailableLanguages, defaultAvailableLocales, defaultLanguage, defaultLocale, } from '../constants.js';
3
3
  export function getUiConfig(config, pluginOptions) {
4
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w;
4
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x;
5
5
  const { authOptions, apiOptions } = config;
6
6
  // Merge API configuration with defaults
7
7
  const api = {
@@ -23,8 +23,22 @@ export function getUiConfig(config, pluginOptions) {
23
23
  ? pluginOptions.i18n.availableLocales
24
24
  : defaultAvailableLocales,
25
25
  };
26
+ // Merge orders configuration with defaults
27
+ // Default labels are identifiers that get translated via getTranslatedRefundReason()
28
+ const orders = {
29
+ refundReasons: ((_x = pluginOptions.orders) === null || _x === void 0 ? void 0 : _x.refundReasons) && pluginOptions.orders.refundReasons.length > 0
30
+ ? pluginOptions.orders.refundReasons
31
+ : [
32
+ { value: 'customer-request', label: 'CustomerRequest' },
33
+ { value: 'not-available', label: 'NotAvailable' },
34
+ { value: 'damaged-shipping', label: 'DamagedInShipping' },
35
+ { value: 'wrong-item', label: 'WrongItem' },
36
+ { value: 'other', label: 'Other' },
37
+ ],
38
+ };
26
39
  return {
27
40
  api,
28
41
  i18n,
42
+ orders,
29
43
  };
30
44
  }
@@ -1,4 +1,14 @@
1
1
  import type { Plugin } from 'vite';
2
+ /**
3
+ * Options for the linguiBabelPlugin.
4
+ */
5
+ export interface LinguiBabelPluginOptions {
6
+ /**
7
+ * For testing: manually specify package paths that should have Lingui macros transformed.
8
+ * In production, these are automatically discovered from the VendureConfig plugins.
9
+ */
10
+ additionalPackagePaths?: string[];
11
+ }
2
12
  /**
3
13
  * @description
4
14
  * A custom Vite plugin that transforms Lingui macros in files using Babel instead of SWC.
@@ -17,11 +27,14 @@ import type { Plugin } from 'vite';
17
27
  * - `@vendure/dashboard/src` files (in node_modules for external projects)
18
28
  * - `packages/dashboard/src` files (in monorepo development)
19
29
  * - User's dashboard extension files (e.g., custom plugins using Lingui)
30
+ * - Third-party npm packages that provide dashboard extensions (discovered automatically)
20
31
  *
21
32
  * Files NOT processed:
22
- * - Other node_modules packages (they shouldn't contain Lingui macros)
33
+ * - Files that don't contain Lingui macro imports (fast check via string matching)
34
+ * - Non-JS/TS files
35
+ * - node_modules packages that are not discovered as Vendure plugins
23
36
  *
24
37
  * @see https://github.com/vendurehq/vendure/issues/3929
25
38
  * @see https://github.com/lingui/swc-plugin/issues/179
26
39
  */
27
- export declare function linguiBabelPlugin(): Plugin;
40
+ export declare function linguiBabelPlugin(options?: LinguiBabelPluginOptions): Plugin;
@@ -1,4 +1,5 @@
1
1
  import * as babel from '@babel/core';
2
+ import { getConfigLoaderApi } from './vite-plugin-config-loader.js';
2
3
  /**
3
4
  * @description
4
5
  * A custom Vite plugin that transforms Lingui macros in files using Babel instead of SWC.
@@ -17,19 +18,40 @@ import * as babel from '@babel/core';
17
18
  * - `@vendure/dashboard/src` files (in node_modules for external projects)
18
19
  * - `packages/dashboard/src` files (in monorepo development)
19
20
  * - User's dashboard extension files (e.g., custom plugins using Lingui)
21
+ * - Third-party npm packages that provide dashboard extensions (discovered automatically)
20
22
  *
21
23
  * Files NOT processed:
22
- * - Other node_modules packages (they shouldn't contain Lingui macros)
24
+ * - Files that don't contain Lingui macro imports (fast check via string matching)
25
+ * - Non-JS/TS files
26
+ * - node_modules packages that are not discovered as Vendure plugins
23
27
  *
24
28
  * @see https://github.com/vendurehq/vendure/issues/3929
25
29
  * @see https://github.com/lingui/swc-plugin/issues/179
26
30
  */
27
- export function linguiBabelPlugin() {
31
+ export function linguiBabelPlugin(options) {
32
+ var _a;
33
+ // Paths of npm packages that should have Lingui macros transformed.
34
+ // This is populated from plugin discovery when transform is first called.
35
+ const allowedNodeModulesPackages = new Set((_a = options === null || options === void 0 ? void 0 : options.additionalPackagePaths) !== null && _a !== void 0 ? _a : []);
36
+ // API reference to the config loader plugin (set in configResolved)
37
+ let configLoaderApi;
38
+ // Cached result from config loader (set on first transform that needs it)
39
+ let configResult;
28
40
  return {
29
41
  name: 'vendure:lingui-babel',
30
42
  // Run BEFORE @vitejs/plugin-react so the macros are already transformed
31
43
  // when the react plugin processes the file
32
44
  enforce: 'pre',
45
+ configResolved({ plugins }) {
46
+ // Get reference to the config loader API.
47
+ // This doesn't load the config yet - that happens lazily in transform.
48
+ try {
49
+ configLoaderApi = getConfigLoaderApi(plugins);
50
+ }
51
+ catch (_a) {
52
+ // configLoaderPlugin not available (e.g., plugin used standalone for testing)
53
+ }
54
+ },
33
55
  async transform(code, id) {
34
56
  // Strip query params for path matching (Vite adds ?v=xxx for cache busting)
35
57
  const cleanId = id.split('?')[0];
@@ -42,15 +64,42 @@ export function linguiBabelPlugin() {
42
64
  if (!code.includes('@lingui/') || !code.includes('/macro')) {
43
65
  return null;
44
66
  }
45
- // Skip node_modules files EXCEPT for @vendure/dashboard source
46
- // This ensures:
47
- // 1. Dashboard source files get transformed (both in monorepo and external projects)
48
- // 2. User's extension files get transformed (not in node_modules)
49
- // 3. Other node_modules packages are left alone
67
+ // Check if this file should be transformed
50
68
  if (cleanId.includes('node_modules')) {
69
+ // Always allow @vendure/dashboard source files
51
70
  const isVendureDashboard = cleanId.includes('@vendure/dashboard/src') || cleanId.includes('packages/dashboard/src');
52
71
  if (!isVendureDashboard) {
53
- return null;
72
+ // Load discovered plugins on first need (lazy loading with caching)
73
+ if (configLoaderApi && !configResult) {
74
+ try {
75
+ configResult = await configLoaderApi.getVendureConfig();
76
+ // Extract package paths from discovered npm plugins
77
+ for (const plugin of configResult.pluginInfo) {
78
+ if (!plugin.sourcePluginPath && plugin.pluginPath.includes('node_modules')) {
79
+ const packagePath = extractPackagePath(plugin.pluginPath);
80
+ if (packagePath) {
81
+ allowedNodeModulesPackages.add(packagePath);
82
+ }
83
+ }
84
+ }
85
+ }
86
+ catch (error) {
87
+ // Log but continue - will use only manually specified paths
88
+ // eslint-disable-next-line no-console
89
+ console.warn('[vendure:lingui-babel] Failed to load plugin config:', error);
90
+ }
91
+ }
92
+ // Check if this is from a discovered Vendure plugin package
93
+ let isDiscoveredPlugin = false;
94
+ for (const pkgPath of allowedNodeModulesPackages) {
95
+ if (cleanId.includes(pkgPath)) {
96
+ isDiscoveredPlugin = true;
97
+ break;
98
+ }
99
+ }
100
+ if (!isDiscoveredPlugin) {
101
+ return null;
102
+ }
54
103
  }
55
104
  }
56
105
  try {
@@ -84,3 +133,36 @@ export function linguiBabelPlugin() {
84
133
  },
85
134
  };
86
135
  }
136
+ /**
137
+ * Extracts the npm package name from a full file path.
138
+ *
139
+ * Examples:
140
+ * - /path/to/node_modules/@vendure-ee/plugin/dist/index.js -> @vendure-ee/plugin
141
+ * - /path/to/node_modules/some-plugin/lib/index.js -> some-plugin
142
+ * - /path/to/node_modules/.pnpm/@vendure-ee+plugin@1.0.0/node_modules/@vendure-ee/plugin/dist/index.js -> @vendure-ee/plugin
143
+ */
144
+ function extractPackagePath(filePath) {
145
+ // Normalize path separators
146
+ const normalizedPath = filePath.replace(/\\/g, '/');
147
+ // Find the last occurrence of node_modules (handles pnpm structure)
148
+ const lastNodeModulesIndex = normalizedPath.lastIndexOf('node_modules/');
149
+ if (lastNodeModulesIndex === -1) {
150
+ return undefined;
151
+ }
152
+ const afterNodeModules = normalizedPath.slice(lastNodeModulesIndex + 'node_modules/'.length);
153
+ // Handle scoped packages (@scope/package)
154
+ if (afterNodeModules.startsWith('@')) {
155
+ const parts = afterNodeModules.split('/');
156
+ if (parts.length >= 2) {
157
+ return `${parts[0]}/${parts[1]}`;
158
+ }
159
+ }
160
+ else {
161
+ // Unscoped package
162
+ const parts = afterNodeModules.split('/');
163
+ if (parts.length >= 1) {
164
+ return parts[0];
165
+ }
166
+ }
167
+ return undefined;
168
+ }
@@ -91,8 +91,8 @@ async function getPluginTranslations(pluginInfo) {
91
91
  const poPatterns = path.join(dashboardPath, '**/*.po');
92
92
  const translations = await glob(poPatterns, {
93
93
  ignore: [
94
- // Standard test & doc files
95
- '**/node_modules/**/node_modules/**',
94
+ // Skip nested node_modules (transitive deps) but not .pnpm or .bun directories.
95
+ '**/node_modules/[!.]*/**/node_modules/**',
96
96
  '**/*.spec.js',
97
97
  '**/*.test.js',
98
98
  ],
@@ -103,6 +103,27 @@ export interface I18nConfig {
103
103
  */
104
104
  availableLocales?: string[];
105
105
  }
106
+ /**
107
+ * @description
108
+ * Options used by the {@link vendureDashboardPlugin} to configure order-related
109
+ * Dashboard UI behaviour.
110
+ *
111
+ * @docsCategory vite-plugin
112
+ * @docsPage vendureDashboardPlugin
113
+ * @since 3.4.0
114
+ */
115
+ export interface OrdersConfig {
116
+ /**
117
+ * @description
118
+ * An array of refund reasons to display in the refund order dialog.
119
+ * Each reason has a `value` (used as the identifier) and a `label` (displayed to the user).
120
+ * If not provided, default reasons will be used.
121
+ */
122
+ refundReasons?: Array<{
123
+ value: string;
124
+ label: string;
125
+ }>;
126
+ }
106
127
  /**
107
128
  * @description
108
129
  * Options used by the {@link vendureDashboardPlugin} to configure aspects of the
@@ -123,6 +144,11 @@ export interface UiConfigPluginOptions {
123
144
  * Configuration for internationalization settings
124
145
  */
125
146
  i18n?: I18nConfig;
147
+ /**
148
+ * @description
149
+ * Configuration for order-related settings
150
+ */
151
+ orders?: OrdersConfig;
126
152
  }
127
153
  /**
128
154
  * @description
@@ -141,6 +167,11 @@ export interface ResolvedUiConfig {
141
167
  * Note: defaultLocale remains optional as it can be undefined.
142
168
  */
143
169
  i18n: Required<Omit<I18nConfig, 'defaultLocale'>> & Pick<I18nConfig, 'defaultLocale'>;
170
+ /**
171
+ * @description
172
+ * Order-related settings with all defaults applied
173
+ */
174
+ orders: Required<OrdersConfig>;
144
175
  }
145
176
  /**
146
177
  * This Vite plugin scans the configured plugins for any dashboard extensions and dynamically
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vendure/dashboard",
3
3
  "private": false,
4
- "version": "3.5.3-master-202601290259",
4
+ "version": "3.5.3",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",
@@ -157,8 +157,8 @@
157
157
  "@storybook/addon-vitest": "^10.0.0-beta.9",
158
158
  "@storybook/react-vite": "^10.0.0-beta.9",
159
159
  "@types/node": "^22.13.4",
160
- "@vendure/common": "^3.5.3-master-202601290259",
161
- "@vendure/core": "^3.5.3-master-202601290259",
160
+ "@vendure/common": "3.5.3",
161
+ "@vendure/core": "3.5.3",
162
162
  "@vitest/browser": "^3.2.4",
163
163
  "@vitest/coverage-v8": "^3.2.4",
164
164
  "eslint": "^9.19.0",
@@ -22,7 +22,9 @@ import {
22
22
  } from '@/vdb/framework/layout-engine/page-layout.js';
23
23
  import { detailPageRouteLoader } from '@/vdb/framework/page/detail-page-route-loader.js';
24
24
  import { useDetailPage } from '@/vdb/framework/page/use-detail-page.js';
25
+ import { useJobQueuePolling } from '@/vdb/hooks/use-job-queue-polling.js';
25
26
  import { Trans, useLingui } from '@lingui/react/macro';
27
+ import { useQueryClient } from '@tanstack/react-query';
26
28
  import { createFileRoute, useNavigate } from '@tanstack/react-router';
27
29
  import { toast } from 'sonner';
28
30
  import {
@@ -54,6 +56,12 @@ function CollectionDetailPage() {
54
56
  const navigate = useNavigate();
55
57
  const creatingNewEntity = params.id === NEW_ENTITY_PATH;
56
58
  const { t } = useLingui();
59
+ const queryClient = useQueryClient();
60
+
61
+ const { isPolling: pendingFilterApplication, startPolling } = useJobQueuePolling(
62
+ 'apply-collection-filters',
63
+ () => queryClient.invalidateQueries({ queryKey: ['PaginatedListDataTable'] }),
64
+ );
57
65
 
58
66
  const { form, submitHandler, entity, isPending, resetForm } = useDetailPage({
59
67
  pageId,
@@ -79,6 +87,7 @@ function CollectionDetailPage() {
79
87
  name: translation.name,
80
88
  slug: translation.slug,
81
89
  description: translation.description,
90
+ customFields: (translation as any).customFields,
82
91
  })),
83
92
  filters: entity.filters.map(f => ({
84
93
  code: f.code,
@@ -90,12 +99,20 @@ function CollectionDetailPage() {
90
99
  },
91
100
  params: { id: params.id },
92
101
  onSuccess: async data => {
102
+ const filtersWereDirty =
103
+ form.getFieldState('inheritFilters').isDirty || form.getFieldState('filters').isDirty;
93
104
  toast(
94
105
  creatingNewEntity ? t`Successfully created collection` : t`Successfully updated collection`,
95
106
  );
96
107
  resetForm();
108
+ if (filtersWereDirty) {
109
+ startPolling();
110
+ }
97
111
  if (creatingNewEntity) {
98
- await navigate({ to: `../$id`, params: { id: data.id } });
112
+ await navigate({
113
+ to: `../$id`,
114
+ params: { id: data.id },
115
+ });
99
116
  }
100
117
  },
101
118
  onError: err => {
@@ -106,7 +123,9 @@ function CollectionDetailPage() {
106
123
  });
107
124
 
108
125
  const shouldPreviewContents =
109
- form.getFieldState('inheritFilters').isDirty || form.getFieldState('filters').isDirty;
126
+ form.getFieldState('inheritFilters').isDirty ||
127
+ form.getFieldState('filters').isDirty ||
128
+ pendingFilterApplication;
110
129
 
111
130
  const currentFiltersValue = form.watch('filters');
112
131
  const currentInheritFiltersValue = form.watch('inheritFilters');
@@ -220,7 +239,7 @@ function CollectionDetailPage() {
220
239
  </FormItem>
221
240
  </PageBlock>
222
241
  <PageBlock column="main" blockId="contents" title={<Trans>Contents</Trans>}>
223
- {shouldPreviewContents || creatingNewEntity ? (
242
+ {pendingFilterApplication || shouldPreviewContents || creatingNewEntity ? (
224
243
  <CollectionContentsPreviewTable
225
244
  parentId={entity?.parent?.id}
226
245
  filters={currentFiltersValue ?? []}
@@ -10,6 +10,7 @@ export const customerListDocument = graphql(`
10
10
  firstName
11
11
  lastName
12
12
  emailAddress
13
+ phoneNumber
13
14
  groups {
14
15
  id
15
16
  name
@@ -30,6 +30,9 @@ function CustomerListPage() {
30
30
  emailAddress: {
31
31
  contains: searchTerm,
32
32
  },
33
+ phoneNumber: {
34
+ contains: searchTerm,
35
+ },
33
36
  };
34
37
  }}
35
38
  transformVariables={variables => {
@@ -0,0 +1,48 @@
1
+ import { Alert, AlertDescription, AlertTitle } from '@/vdb/components/ui/alert.js';
2
+ import { Trans, useLingui } from '@lingui/react/macro';
3
+ import { AlertTriangle, CheckCircle } from 'lucide-react';
4
+
5
+ export type DraftOrderStatusProps = Readonly<{
6
+ hasCustomer: boolean;
7
+ hasLines: boolean;
8
+ hasShippingMethod: boolean;
9
+ isDraftState: boolean;
10
+ }>;
11
+
12
+ export function DraftOrderStatus({
13
+ hasCustomer,
14
+ hasLines,
15
+ hasShippingMethod,
16
+ isDraftState,
17
+ }: DraftOrderStatusProps) {
18
+ const { t } = useLingui();
19
+ const isCompleteDraftDisabled = !hasCustomer || !hasLines || !hasShippingMethod || !isDraftState;
20
+
21
+ let completeDraftDisabledReason: string | null = null;
22
+ if (!hasCustomer) {
23
+ completeDraftDisabledReason = t`Select a customer to continue`;
24
+ } else if (!hasLines) {
25
+ completeDraftDisabledReason = t`Add at least one item to the order`;
26
+ } else if (!hasShippingMethod) {
27
+ completeDraftDisabledReason = t`Set a shipping address and select a shipping method`;
28
+ } else if (!isDraftState) {
29
+ completeDraftDisabledReason = t`Only draft orders can be completed`;
30
+ }
31
+
32
+ const Icon = isCompleteDraftDisabled ? AlertTriangle : CheckCircle;
33
+ const title = isCompleteDraftDisabled ? (
34
+ <Trans>Order draft isn't ready to be completed</Trans>
35
+ ) : (
36
+ <Trans>Order draft is ready to be completed</Trans>
37
+ );
38
+
39
+ return (
40
+ <Alert variant={isCompleteDraftDisabled ? 'destructive' : 'default'}>
41
+ <Icon className={isCompleteDraftDisabled ? '' : 'stroke-success'} />
42
+ <AlertTitle className={isCompleteDraftDisabled ? '' : 'text-success'}>{title}</AlertTitle>
43
+ {completeDraftDisabledReason ? (
44
+ <AlertDescription>{completeDraftDisabledReason}</AlertDescription>
45
+ ) : null}
46
+ </Alert>
47
+ );
48
+ }
@@ -218,8 +218,8 @@ export function FulfillOrderDialog({ order, onSuccess }: Readonly<FulfillOrderDi
218
218
  >
219
219
  <Trans>Fulfill order</Trans>
220
220
  </Button>
221
- <Dialog open={open}>
222
- <DialogContent className="sm:max-w-[600px]">
221
+ <Dialog open={open} onOpenChange={setOpen}>
222
+ <DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-hidden flex flex-col">
223
223
  <DialogHeader>
224
224
  <DialogTitle>
225
225
  <Trans>Fulfill order</Trans>
@@ -234,9 +234,9 @@ export function FulfillOrderDialog({ order, onSuccess }: Readonly<FulfillOrderDi
234
234
  e.stopPropagation();
235
235
  form.handleSubmit(handleSubmit)(e);
236
236
  }}
237
- className="space-y-4"
237
+ className="space-y-4 flex-1 overflow-hidden flex flex-col"
238
238
  >
239
- <div className="space-y-4">
239
+ <div className="space-y-4 flex-1 overflow-y-auto">
240
240
  <div className="font-medium">
241
241
  <Trans>Order lines</Trans>
242
242
  </div>
@@ -19,15 +19,17 @@ import { Trans, useLingui } from '@lingui/react/macro';
19
19
  import { useMutation, useQueryClient } from '@tanstack/react-query';
20
20
  import { Link, useNavigate } from '@tanstack/react-router';
21
21
  import { ResultOf } from 'gql.tada';
22
- import { Pencil, User } from 'lucide-react';
23
- import { useMemo } from 'react';
22
+ import { Pencil, RotateCcw, User } from 'lucide-react';
23
+ import { useCallback, useMemo, useRef } from 'react';
24
24
  import { toast } from 'sonner';
25
+
25
26
  import {
26
27
  orderDetailDocument,
27
28
  setOrderCustomFieldsDocument,
28
29
  transitionOrderToStateDocument,
29
30
  } from '../orders.graphql.js';
30
- import { canAddFulfillment, shouldShowAddManualPaymentButton } from '../utils/order-utils.js';
31
+ import { canAddFulfillment, canRefundOrder, shouldShowAddManualPaymentButton } from '../utils/order-utils.js';
32
+
31
33
  import { AddManualPaymentDialog } from './add-manual-payment-dialog.js';
32
34
  import { FulfillOrderDialog } from './fulfill-order-dialog.js';
33
35
  import { FulfillmentDetails } from './fulfillment-details.js';
@@ -37,6 +39,7 @@ import { orderHistoryQueryKey } from './order-history/use-order-history.js';
37
39
  import { OrderTable } from './order-table.js';
38
40
  import { OrderTaxSummary } from './order-tax-summary.js';
39
41
  import { PaymentDetails } from './payment-details.js';
42
+ import { RefundOrderDialog, RefundOrderDialogRef } from './refund-order-dialog.js';
40
43
  import { getTypeForState, StateTransitionControl } from './state-transition-control.js';
41
44
  import { useTransitionOrderToState } from './use-transition-order-to-state.js';
42
45
 
@@ -101,6 +104,15 @@ export function OrderDetailShared({
101
104
  });
102
105
 
103
106
  const customFieldConfig = useCustomFieldConfig('Order');
107
+ const refundDialogRef = useRef<RefundOrderDialogRef>(null);
108
+
109
+ const refreshOrderAndHistory = useCallback(async () => {
110
+ if (entity) {
111
+ const queryKey = getDetailQueryOptions(orderDetailDocument, { id: entity.id }).queryKey;
112
+ await queryClient.invalidateQueries({ queryKey });
113
+ void queryClient.refetchQueries({ queryKey: orderHistoryQueryKey(entity.id) });
114
+ }
115
+ }, [entity, queryClient]);
104
116
 
105
117
  const stateTransitionActions = useMemo(() => {
106
118
  if (!entity) {
@@ -116,17 +128,14 @@ export function OrderDetailShared({
116
128
  description: transitionError,
117
129
  });
118
130
  } else {
119
- refreshOrderAndHistory();
131
+ void refreshOrderAndHistory();
120
132
  }
121
133
  },
122
134
  }));
123
- }, [entity, transitionToState, t]);
135
+ }, [entity, transitionToState, t, refreshOrderAndHistory]);
124
136
 
125
- if (!entity) {
126
- return null;
127
- }
128
-
129
- const handleModifyClick = async () => {
137
+ const handleModifyClick = useCallback(async () => {
138
+ if (!entity) return;
130
139
  try {
131
140
  await transitionOrderToStateMutation.mutateAsync({
132
141
  id: entity.id,
@@ -140,19 +149,38 @@ export function OrderDetailShared({
140
149
  description: error instanceof Error ? error.message : 'Unknown error',
141
150
  });
142
151
  }
143
- };
152
+ }, [entity, transitionOrderToStateMutation, queryClient, navigate, t]);
153
+
154
+ const ModifyMenuItem = useCallback(
155
+ () => (
156
+ <DropdownMenuItem onClick={handleModifyClick}>
157
+ <Pencil className="w-4 h-4" />
158
+ <Trans>Modify</Trans>
159
+ </DropdownMenuItem>
160
+ ),
161
+ [handleModifyClick],
162
+ );
163
+
164
+ const RefundMenuItem = useCallback(
165
+ () => (
166
+ <PermissionGuard requires={['UpdateOrder']}>
167
+ <DropdownMenuItem onClick={() => refundDialogRef.current?.open()}>
168
+ <RotateCcw className="w-4 h-4" />
169
+ <Trans>Refund & Cancel</Trans>
170
+ </DropdownMenuItem>
171
+ </PermissionGuard>
172
+ ),
173
+ [],
174
+ );
175
+
176
+ if (!entity) {
177
+ return null;
178
+ }
144
179
 
145
180
  const nextStates = entity.nextStates;
146
181
  const showAddPaymentButton = shouldShowAddManualPaymentButton(entity);
147
182
  const showFulfillButton = canAddFulfillment(entity);
148
-
149
- async function refreshOrderAndHistory() {
150
- if (entity) {
151
- const queryKey = getDetailQueryOptions(orderDetailDocument, { id: entity.id }).queryKey;
152
- await queryClient.invalidateQueries({ queryKey });
153
- queryClient.refetchQueries({ queryKey: orderHistoryQueryKey(entity.id) });
154
- }
155
- }
183
+ const showRefundOption = canRefundOrder(entity);
156
184
 
157
185
  return (
158
186
  <Page pageId={pageId} form={form} submitHandler={submitHandler} entity={entity}>
@@ -160,18 +188,8 @@ export function OrderDetailShared({
160
188
  <PageActionBar>
161
189
  <PageActionBarRight
162
190
  dropdownMenuItems={[
163
- ...(nextStates.includes('Modifying')
164
- ? [
165
- {
166
- component: () => (
167
- <DropdownMenuItem onClick={handleModifyClick}>
168
- <Pencil className="w-4 h-4" />
169
- <Trans>Modify</Trans>
170
- </DropdownMenuItem>
171
- ),
172
- },
173
- ]
174
- : []),
191
+ ...(nextStates.includes('Modifying') ? [{ component: ModifyMenuItem }] : []),
192
+ ...(showRefundOption ? [{ component: RefundMenuItem }] : []),
175
193
  ]}
176
194
  >
177
195
  {showAddPaymentButton && (
@@ -189,13 +207,22 @@ export function OrderDetailShared({
189
207
  <FulfillOrderDialog
190
208
  order={entity}
191
209
  onSuccess={() => {
192
- refreshOrderAndHistory();
210
+ void refreshOrderAndHistory();
193
211
  }}
194
212
  />
195
213
  </PermissionGuard>
196
214
  )}
197
215
  </PageActionBarRight>
198
216
  </PageActionBar>
217
+ {showRefundOption && (
218
+ <RefundOrderDialog
219
+ ref={refundDialogRef}
220
+ order={entity}
221
+ onSuccess={() => {
222
+ void refreshOrderAndHistory();
223
+ }}
224
+ />
225
+ )}
199
226
  <PageLayout>
200
227
  {/* Main Column Blocks */}
201
228
  {beforeOrderTable?.(entity)}
@@ -288,7 +315,7 @@ export function OrderDetailShared({
288
315
  fulfillment={fulfillment}
289
316
  onSuccess={() => {
290
317
  refreshEntity();
291
- queryClient.refetchQueries({
318
+ void queryClient.refetchQueries({
292
319
  queryKey: orderHistoryQueryKey(entity.id),
293
320
  });
294
321
  }}