@vendure/dashboard 3.3.5-master-202506250724 → 3.3.5-master-202506251305

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 (61) hide show
  1. package/dist/plugin/tests/barrel-exports.spec.js +1 -1
  2. package/dist/plugin/vite-plugin-config.js +1 -0
  3. package/dist/plugin/vite-plugin-dashboard-metadata.d.ts +1 -3
  4. package/dist/plugin/vite-plugin-dashboard-metadata.js +1 -8
  5. package/dist/plugin/vite-plugin-tailwind-source.d.ts +7 -0
  6. package/dist/plugin/vite-plugin-tailwind-source.js +49 -0
  7. package/dist/plugin/vite-plugin-vendure-dashboard.js +3 -1
  8. package/package.json +4 -4
  9. package/src/app/routes/_authenticated/_administrators/administrators_.$id.tsx +1 -1
  10. package/src/app/routes/_authenticated/_assets/assets_.$id.tsx +43 -34
  11. package/src/app/routes/_authenticated/_channels/channels_.$id.tsx +1 -1
  12. package/src/app/routes/_authenticated/_collections/collections_.$id.tsx +1 -1
  13. package/src/app/routes/_authenticated/_countries/countries_.$id.tsx +1 -1
  14. package/src/app/routes/_authenticated/_customer-groups/customer-groups_.$id.tsx +1 -1
  15. package/src/app/routes/_authenticated/_customers/customers_.$id.tsx +1 -1
  16. package/src/app/routes/_authenticated/_facets/facets_.$id.tsx +1 -1
  17. package/src/app/routes/_authenticated/_global-settings/global-settings.tsx +1 -1
  18. package/src/app/routes/_authenticated/_orders/orders_.$id.tsx +2 -5
  19. package/src/app/routes/_authenticated/_payment-methods/payment-methods_.$id.tsx +1 -1
  20. package/src/app/routes/_authenticated/_product-variants/product-variants_.$id.tsx +1 -1
  21. package/src/app/routes/_authenticated/_products/components/assign-facet-values-dialog.tsx +98 -0
  22. package/src/app/routes/_authenticated/_products/components/assign-to-channel-dialog.tsx +126 -0
  23. package/src/app/routes/_authenticated/_products/components/product-bulk-actions.tsx +268 -0
  24. package/src/app/routes/_authenticated/_products/products.graphql.ts +64 -0
  25. package/src/app/routes/_authenticated/_products/products.tsx +31 -2
  26. package/src/app/routes/_authenticated/_products/products_.$id.tsx +14 -9
  27. package/src/app/routes/_authenticated/_promotions/promotions_.$id.tsx +1 -1
  28. package/src/app/routes/_authenticated/_roles/roles_.$id.tsx +1 -1
  29. package/src/app/routes/_authenticated/_sellers/sellers_.$id.tsx +1 -1
  30. package/src/app/routes/_authenticated/_shipping-methods/shipping-methods_.$id.tsx +1 -1
  31. package/src/app/routes/_authenticated/_stock-locations/stock-locations_.$id.tsx +1 -1
  32. package/src/app/routes/_authenticated/_tax-categories/tax-categories_.$id.tsx +1 -1
  33. package/src/app/routes/_authenticated/_tax-rates/tax-rates_.$id.tsx +1 -1
  34. package/src/app/routes/_authenticated/_zones/zones_.$id.tsx +1 -1
  35. package/src/app/styles.css +3 -0
  36. package/src/lib/components/data-table/data-table-bulk-action-item.tsx +101 -0
  37. package/src/lib/components/data-table/data-table-bulk-actions.tsx +89 -0
  38. package/src/lib/components/data-table/data-table-filter-badge.tsx +16 -8
  39. package/src/lib/components/data-table/data-table-filter-dialog.tsx +4 -4
  40. package/src/lib/components/data-table/data-table-pagination.tsx +2 -2
  41. package/src/lib/components/data-table/data-table.tsx +50 -31
  42. package/src/lib/components/data-table/human-readable-operator.tsx +3 -3
  43. package/src/lib/components/shared/assigned-facet-values.tsx +1 -5
  44. package/src/lib/components/shared/paginated-list-data-table.tsx +47 -11
  45. package/src/lib/framework/data-table/data-table-extensions.ts +21 -0
  46. package/src/lib/framework/data-table/data-table-types.ts +25 -0
  47. package/src/lib/framework/extension-api/define-dashboard-extension.ts +11 -0
  48. package/src/lib/framework/extension-api/extension-api-types.ts +35 -0
  49. package/src/lib/framework/form-engine/use-generated-form.tsx +2 -5
  50. package/src/lib/framework/layout-engine/page-block-provider.tsx +6 -0
  51. package/src/lib/framework/layout-engine/page-layout.tsx +43 -33
  52. package/src/lib/framework/page/list-page.tsx +6 -8
  53. package/src/lib/framework/registry/registry-types.ts +4 -2
  54. package/src/lib/hooks/use-page-block.tsx +10 -0
  55. package/src/lib/index.ts +8 -1
  56. package/vite/tests/barrel-exports.spec.ts +13 -9
  57. package/vite/vite-plugin-config.ts +1 -0
  58. package/vite/vite-plugin-dashboard-metadata.ts +1 -9
  59. package/vite/vite-plugin-tailwind-source.ts +65 -0
  60. package/vite/vite-plugin-vendure-dashboard.ts +5 -3
  61. /package/src/lib/components/data-table/{data-table-types.ts → types.ts} +0 -0
@@ -0,0 +1,268 @@
1
+ import { useMutation } from '@tanstack/react-query';
2
+ import { CopyIcon, LayersIcon, TagIcon, TrashIcon } from 'lucide-react';
3
+ import { useState } from 'react';
4
+ import { toast } from 'sonner';
5
+
6
+ import { DataTableBulkActionItem } from '@/components/data-table/data-table-bulk-action-item.js';
7
+ import { BulkActionComponent } from '@/framework/data-table/data-table-types.js';
8
+ import { api } from '@/graphql/api.js';
9
+ import { ResultOf } from '@/graphql/graphql.js';
10
+ import { useChannel, usePaginatedList } from '@/index.js';
11
+ import { Trans, useLingui } from '@/lib/trans.js';
12
+
13
+ import { Permission } from '@vendure/common/lib/generated-types';
14
+ import {
15
+ deleteProductsDocument,
16
+ duplicateEntityDocument,
17
+ removeProductsFromChannelDocument,
18
+ } from '../products.graphql.js';
19
+ import { AssignFacetValuesDialog } from './assign-facet-values-dialog.js';
20
+ import { AssignToChannelDialog } from './assign-to-channel-dialog.js';
21
+
22
+ export const DeleteProductsBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
23
+ const { refetchPaginatedList } = usePaginatedList();
24
+ const { i18n } = useLingui();
25
+ const { mutate } = useMutation({
26
+ mutationFn: api.mutate(deleteProductsDocument),
27
+ onSuccess: (result: ResultOf<typeof deleteProductsDocument>) => {
28
+ let deleted = 0;
29
+ const errors: string[] = [];
30
+ for (const item of result.deleteProducts) {
31
+ if (item.result === 'DELETED') {
32
+ deleted++;
33
+ } else if (item.message) {
34
+ errors.push(item.message);
35
+ }
36
+ }
37
+ if (0 < deleted) {
38
+ toast.success(i18n.t(`Deleted ${deleted} products`));
39
+ }
40
+ if (0 < errors.length) {
41
+ toast.error(i18n.t(`Failed to delete ${errors.length} products`));
42
+ }
43
+ refetchPaginatedList();
44
+ table.resetRowSelection();
45
+ },
46
+ onError: () => {
47
+ toast.error(`Failed to delete ${selection.length} products`);
48
+ },
49
+ });
50
+ return (
51
+ <DataTableBulkActionItem
52
+ requiresPermission={[Permission.DeleteCatalog, Permission.DeleteProduct]}
53
+ onClick={() => mutate({ ids: selection.map(s => s.id) })}
54
+ label={<Trans>Delete</Trans>}
55
+ confirmationText={<Trans>Are you sure you want to delete {selection.length} products?</Trans>}
56
+ icon={TrashIcon}
57
+ className="text-destructive"
58
+ />
59
+ );
60
+ };
61
+
62
+ export const AssignProductsToChannelBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
63
+ const { refetchPaginatedList } = usePaginatedList();
64
+ const { channels, selectedChannel } = useChannel();
65
+ const [dialogOpen, setDialogOpen] = useState(false);
66
+
67
+ if (channels.length < 2) {
68
+ return null;
69
+ }
70
+
71
+ const handleSuccess = () => {
72
+ refetchPaginatedList();
73
+ table.resetRowSelection();
74
+ };
75
+
76
+ return (
77
+ <>
78
+ <DataTableBulkActionItem
79
+ requiresPermission={[Permission.UpdateCatalog, Permission.UpdateProduct]}
80
+ onClick={() => setDialogOpen(true)}
81
+ label={<Trans>Assign to channel</Trans>}
82
+ icon={LayersIcon}
83
+ />
84
+ <AssignToChannelDialog
85
+ open={dialogOpen}
86
+ onOpenChange={setDialogOpen}
87
+ productIds={selection.map(s => s.id)}
88
+ onSuccess={handleSuccess}
89
+ />
90
+ </>
91
+ );
92
+ };
93
+
94
+ export const RemoveProductsFromChannelBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
95
+ const { refetchPaginatedList } = usePaginatedList();
96
+ const { selectedChannel } = useChannel();
97
+ const { i18n } = useLingui();
98
+ const { mutate } = useMutation({
99
+ mutationFn: api.mutate(removeProductsFromChannelDocument),
100
+ onSuccess: () => {
101
+ toast.success(i18n.t(`Successfully removed ${selection.length} products from channel`));
102
+ refetchPaginatedList();
103
+ table.resetRowSelection();
104
+ },
105
+ onError: error => {
106
+ toast.error(`Failed to remove ${selection.length} products from channel: ${error.message}`);
107
+ },
108
+ });
109
+
110
+ if (!selectedChannel) {
111
+ return null;
112
+ }
113
+
114
+ const handleRemove = () => {
115
+ mutate({
116
+ input: {
117
+ productIds: selection.map(s => s.id),
118
+ channelId: selectedChannel.id,
119
+ },
120
+ });
121
+ };
122
+
123
+ return (
124
+ <DataTableBulkActionItem
125
+ requiresPermission={[Permission.UpdateCatalog, Permission.UpdateProduct]}
126
+ onClick={handleRemove}
127
+ label={<Trans>Remove from current channel</Trans>}
128
+ confirmationText={
129
+ <Trans>
130
+ Are you sure you want to remove {selection.length} products from the current channel?
131
+ </Trans>
132
+ }
133
+ icon={LayersIcon}
134
+ className="text-warning"
135
+ />
136
+ );
137
+ };
138
+
139
+ export const AssignFacetValuesToProductsBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
140
+ const { refetchPaginatedList } = usePaginatedList();
141
+ const [dialogOpen, setDialogOpen] = useState(false);
142
+
143
+ const handleSuccess = () => {
144
+ refetchPaginatedList();
145
+ table.resetRowSelection();
146
+ };
147
+
148
+ return (
149
+ <>
150
+ <DataTableBulkActionItem
151
+ requiresPermission={[Permission.UpdateCatalog, Permission.UpdateProduct]}
152
+ onClick={() => setDialogOpen(true)}
153
+ label={<Trans>Edit facet values</Trans>}
154
+ icon={TagIcon}
155
+ />
156
+ <AssignFacetValuesDialog
157
+ open={dialogOpen}
158
+ onOpenChange={setDialogOpen}
159
+ productIds={selection.map(s => s.id)}
160
+ onSuccess={handleSuccess}
161
+ />
162
+ </>
163
+ );
164
+ };
165
+
166
+ export const DuplicateProductsBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
167
+ const { refetchPaginatedList } = usePaginatedList();
168
+ const { i18n } = useLingui();
169
+ const [isDuplicating, setIsDuplicating] = useState(false);
170
+ const [progress, setProgress] = useState({ completed: 0, total: 0 });
171
+
172
+ const { mutateAsync } = useMutation({
173
+ mutationFn: api.mutate(duplicateEntityDocument),
174
+ });
175
+
176
+ const handleDuplicate = async () => {
177
+ if (isDuplicating) return;
178
+
179
+ setIsDuplicating(true);
180
+ setProgress({ completed: 0, total: selection.length });
181
+
182
+ const results = {
183
+ success: 0,
184
+ failed: 0,
185
+ errors: [] as string[],
186
+ };
187
+
188
+ try {
189
+ // Process products sequentially to avoid overwhelming the server
190
+ for (let i = 0; i < selection.length; i++) {
191
+ const product = selection[i];
192
+
193
+ try {
194
+ const result = await mutateAsync({
195
+ input: {
196
+ entityName: 'Product',
197
+ entityId: product.id,
198
+ duplicatorInput: {
199
+ code: 'product-duplicator',
200
+ arguments: [
201
+ {
202
+ name: 'includeVariants',
203
+ value: 'true',
204
+ },
205
+ ],
206
+ },
207
+ },
208
+ });
209
+
210
+ if ('newEntityId' in result.duplicateEntity) {
211
+ results.success++;
212
+ } else {
213
+ results.failed++;
214
+ const errorMsg =
215
+ result.duplicateEntity.message ||
216
+ result.duplicateEntity.duplicationError ||
217
+ 'Unknown error';
218
+ results.errors.push(`Product ${product.name || product.id}: ${errorMsg}`);
219
+ }
220
+ } catch (error) {
221
+ results.failed++;
222
+ results.errors.push(
223
+ `Product ${product.name || product.id}: ${error instanceof Error ? error.message : 'Unknown error'}`,
224
+ );
225
+ }
226
+
227
+ setProgress({ completed: i + 1, total: selection.length });
228
+ }
229
+
230
+ // Show results
231
+ if (results.success > 0) {
232
+ toast.success(i18n.t(`Successfully duplicated ${results.success} products`));
233
+ }
234
+ if (results.failed > 0) {
235
+ const errorMessage =
236
+ results.errors.length > 3
237
+ ? `${results.errors.slice(0, 3).join(', ')}... and ${results.errors.length - 3} more`
238
+ : results.errors.join(', ');
239
+ toast.error(`Failed to duplicate ${results.failed} products: ${errorMessage}`);
240
+ }
241
+
242
+ if (results.success > 0) {
243
+ refetchPaginatedList();
244
+ table.resetRowSelection();
245
+ }
246
+ } finally {
247
+ setIsDuplicating(false);
248
+ setProgress({ completed: 0, total: 0 });
249
+ }
250
+ };
251
+
252
+ return (
253
+ <DataTableBulkActionItem
254
+ requiresPermission={[Permission.UpdateCatalog, Permission.UpdateProduct]}
255
+ onClick={handleDuplicate}
256
+ label={
257
+ isDuplicating ? (
258
+ <Trans>
259
+ Duplicating... ({progress.completed}/{progress.total})
260
+ </Trans>
261
+ ) : (
262
+ <Trans>Duplicate</Trans>
263
+ )
264
+ }
265
+ icon={CopyIcon}
266
+ />
267
+ );
268
+ };
@@ -119,3 +119,67 @@ export const deleteProductDocument = graphql(`
119
119
  }
120
120
  }
121
121
  `);
122
+
123
+ export const deleteProductsDocument = graphql(`
124
+ mutation DeleteProducts($ids: [ID!]!) {
125
+ deleteProducts(ids: $ids) {
126
+ result
127
+ message
128
+ }
129
+ }
130
+ `);
131
+
132
+ export const assignProductsToChannelDocument = graphql(`
133
+ mutation AssignProductsToChannel($input: AssignProductsToChannelInput!) {
134
+ assignProductsToChannel(input: $input) {
135
+ id
136
+ channels {
137
+ id
138
+ code
139
+ }
140
+ }
141
+ }
142
+ `);
143
+
144
+ export const removeProductsFromChannelDocument = graphql(`
145
+ mutation RemoveProductsFromChannel($input: RemoveProductsFromChannelInput!) {
146
+ removeProductsFromChannel(input: $input) {
147
+ id
148
+ channels {
149
+ id
150
+ code
151
+ }
152
+ }
153
+ }
154
+ `);
155
+
156
+ export const updateProductsDocument = graphql(`
157
+ mutation UpdateProducts($input: [UpdateProductInput!]!) {
158
+ updateProducts(input: $input) {
159
+ id
160
+ name
161
+ facetValues {
162
+ id
163
+ name
164
+ code
165
+ }
166
+ }
167
+ }
168
+ `);
169
+
170
+ export const duplicateEntityDocument = graphql(`
171
+ mutation DuplicateEntity($input: DuplicateEntityInput!) {
172
+ duplicateEntity(input: $input) {
173
+ ... on DuplicateEntitySuccess {
174
+ newEntityId
175
+ }
176
+ ... on ErrorResult {
177
+ errorCode
178
+ message
179
+ }
180
+ ... on DuplicateEntityError {
181
+ duplicationError
182
+ }
183
+ }
184
+ }
185
+ `);
@@ -1,11 +1,18 @@
1
1
  import { DetailPageButton } from '@/components/shared/detail-page-button.js';
2
2
  import { PermissionGuard } from '@/components/shared/permission-guard.js';
3
3
  import { Button } from '@/components/ui/button.js';
4
- import { PageActionBar, PageActionBarRight } from '@/framework/layout-engine/page-layout.js';
4
+ import { PageActionBarRight } from '@/framework/layout-engine/page-layout.js';
5
5
  import { ListPage } from '@/framework/page/list-page.js';
6
6
  import { Trans } from '@/lib/trans.js';
7
7
  import { createFileRoute, Link } from '@tanstack/react-router';
8
- import { PlusIcon, TrashIcon } from 'lucide-react';
8
+ import { PlusIcon } from 'lucide-react';
9
+ import {
10
+ AssignFacetValuesToProductsBulkAction,
11
+ AssignProductsToChannelBulkAction,
12
+ DeleteProductsBulkAction,
13
+ DuplicateProductsBulkAction,
14
+ RemoveProductsFromChannelBulkAction,
15
+ } from './components/product-bulk-actions.js';
9
16
  import { deleteProductDocument, productListDocument } from './products.graphql.js';
10
17
 
11
18
  export const Route = createFileRoute('/_authenticated/_products/products')({
@@ -32,6 +39,28 @@ function ProductListPage() {
32
39
  };
33
40
  }}
34
41
  route={Route}
42
+ bulkActions={[
43
+ {
44
+ component: AssignProductsToChannelBulkAction,
45
+ order: 100,
46
+ },
47
+ {
48
+ component: RemoveProductsFromChannelBulkAction,
49
+ order: 200,
50
+ },
51
+ {
52
+ component: AssignFacetValuesToProductsBulkAction,
53
+ order: 300,
54
+ },
55
+ {
56
+ component: DuplicateProductsBulkAction,
57
+ order: 400,
58
+ },
59
+ {
60
+ component: DeleteProductsBulkAction,
61
+ order: 500,
62
+ },
63
+ ]}
35
64
  >
36
65
  <PageActionBarRight>
37
66
  <PermissionGuard requires={['CreateProduct', 'CreateCatalog']}>
@@ -24,12 +24,12 @@ import { detailPageRouteLoader } from '@/framework/page/detail-page-route-loader
24
24
  import { useDetailPage } from '@/framework/page/use-detail-page.js';
25
25
  import { Trans, useLingui } from '@/lib/trans.js';
26
26
  import { createFileRoute, useNavigate } from '@tanstack/react-router';
27
+ import { useRef } from 'react';
27
28
  import { toast } from 'sonner';
29
+ import { AddProductVariantDialog } from './components/add-product-variant-dialog.js';
28
30
  import { CreateProductVariantsDialog } from './components/create-product-variants-dialog.js';
29
31
  import { ProductVariantsTable } from './components/product-variants-table.js';
30
- import { AddProductVariantDialog } from './components/add-product-variant-dialog.js';
31
32
  import { createProductDocument, productDetailDocument, updateProductDocument } from './products.graphql.js';
32
- import { useRef } from 'react';
33
33
 
34
34
  export const Route = createFileRoute('/_authenticated/_products/products_/$id')({
35
35
  component: ProductDetailPage,
@@ -50,9 +50,11 @@ function ProductDetailPage() {
50
50
  const navigate = useNavigate();
51
51
  const creatingNewEntity = params.id === NEW_ENTITY_PATH;
52
52
  const { i18n } = useLingui();
53
- const refreshRef = useRef<() => void>(() => {});
53
+ const refreshRef = useRef<() => void>(() => {
54
+ });
54
55
 
55
56
  const { form, submitHandler, entity, isPending, refreshEntity, resetForm } = useDetailPage({
57
+ entityName: 'Product',
56
58
  queryDocument: productDetailDocument,
57
59
  createDocument: createProductDocument,
58
60
  updateDocument: updateProductDocument,
@@ -88,9 +90,9 @@ function ProductDetailPage() {
88
90
  });
89
91
  },
90
92
  });
91
-
93
+
92
94
  return (
93
- <Page pageId="product-detail" entity={entity} form={form} submitHandler={submitHandler}>
95
+ <Page pageId="product-detail" form={form} submitHandler={submitHandler} entity={entity}>
94
96
  <PageTitle>{creatingNewEntity ? <Trans>New product</Trans> : (entity?.name ?? '')}</PageTitle>
95
97
  <PageActionBar>
96
98
  <PageActionBarRight>
@@ -142,10 +144,13 @@ function ProductDetailPage() {
142
144
  <CustomFieldsPageBlock column="main" entityType="Product" control={form.control} />
143
145
  {entity && entity.variantList.totalItems > 0 && (
144
146
  <PageBlock column="main" blockId="product-variants-table">
145
- <ProductVariantsTable productId={params.id} registerRefresher={refresher => {
146
- refreshRef.current = refresher;
147
- }} />
148
- <div className="mt-4">
147
+ <ProductVariantsTable
148
+ productId={params.id}
149
+ registerRefresher={refresher => {
150
+ refreshRef.current = refresher;
151
+ }}
152
+ />
153
+ <div className="mt-4">
149
154
  <AddProductVariantDialog
150
155
  productId={params.id}
151
156
  onSuccess={() => {
@@ -114,7 +114,7 @@ function PromotionDetailPage() {
114
114
  });
115
115
 
116
116
  return (
117
- <Page pageId="promotion-detail" form={form} submitHandler={submitHandler}>
117
+ <Page pageId="promotion-detail" form={form} submitHandler={submitHandler} entity={entity}>
118
118
  <PageTitle>{creatingNewEntity ? <Trans>New promotion</Trans> : (entity?.name ?? '')}</PageTitle>
119
119
  <PageActionBar>
120
120
  <PageActionBarRight>
@@ -71,7 +71,7 @@ function RoleDetailPage() {
71
71
  });
72
72
 
73
73
  return (
74
- <Page pageId="role-detail" form={form} submitHandler={submitHandler}>
74
+ <Page pageId="role-detail" form={form} submitHandler={submitHandler} entity={entity}>
75
75
  <PageTitle>{creatingNewEntity ? <Trans>New role</Trans> : (entity?.description ?? '')}</PageTitle>
76
76
  <PageActionBar>
77
77
  <PageActionBarRight>
@@ -65,7 +65,7 @@ function SellerDetailPage() {
65
65
  });
66
66
 
67
67
  return (
68
- <Page pageId="seller-detail" form={form} submitHandler={submitHandler}>
68
+ <Page pageId="seller-detail" form={form} submitHandler={submitHandler} entity={entity}>
69
69
  <PageTitle>{creatingNewEntity ? <Trans>New seller</Trans> : (entity?.name ?? '')}</PageTitle>
70
70
  <PageActionBar>
71
71
  <PageActionBarRight>
@@ -94,7 +94,7 @@ function ShippingMethodDetailPage() {
94
94
  });
95
95
 
96
96
  return (
97
- <Page pageId="shipping-method-detail" form={form} submitHandler={submitHandler}>
97
+ <Page pageId="shipping-method-detail" form={form} submitHandler={submitHandler} entity={entity}>
98
98
  <PageTitle>
99
99
  {creatingNewEntity ? <Trans>New shipping method</Trans> : (entity?.name ?? '')}
100
100
  </PageTitle>
@@ -74,7 +74,7 @@ function StockLocationDetailPage() {
74
74
  });
75
75
 
76
76
  return (
77
- <Page pageId="stock-location-detail" form={form} submitHandler={submitHandler}>
77
+ <Page pageId="stock-location-detail" form={form} submitHandler={submitHandler} entity={entity}>
78
78
  <PageTitle>
79
79
  {creatingNewEntity ? <Trans>New stock location</Trans> : (entity?.name ?? '')}
80
80
  </PageTitle>
@@ -73,7 +73,7 @@ function TaxCategoryDetailPage() {
73
73
  });
74
74
 
75
75
  return (
76
- <Page pageId="tax-category-detail" form={form} submitHandler={submitHandler}>
76
+ <Page pageId="tax-category-detail" form={form} submitHandler={submitHandler} entity={entity}>
77
77
  <PageTitle>
78
78
  {creatingNewEntity ? <Trans>New tax category</Trans> : (entity?.name ?? '')}
79
79
  </PageTitle>
@@ -77,7 +77,7 @@ function TaxRateDetailPage() {
77
77
  });
78
78
 
79
79
  return (
80
- <Page pageId="tax-rate-detail" form={form} submitHandler={submitHandler}>
80
+ <Page pageId="tax-rate-detail" form={form} submitHandler={submitHandler} entity={entity}>
81
81
  <PageTitle>{creatingNewEntity ? <Trans>New tax rate</Trans> : (entity?.name ?? '')}</PageTitle>
82
82
  <PageActionBar>
83
83
  <PageActionBarRight>
@@ -66,7 +66,7 @@ function ZoneDetailPage() {
66
66
  });
67
67
 
68
68
  return (
69
- <Page pageId="zone-detail" form={form} submitHandler={submitHandler}>
69
+ <Page pageId="zone-detail" form={form} submitHandler={submitHandler} entity={entity}>
70
70
  <PageTitle>{creatingNewEntity ? <Trans>New zone</Trans> : (entity?.name ?? '')}</PageTitle>
71
71
  <PageActionBar>
72
72
  <PageActionBarRight>
@@ -4,6 +4,8 @@
4
4
 
5
5
  @custom-variant dark (&:is(.dark *));
6
6
 
7
+ /* @source rules from extensions will be added here by the dashboardTailwindSourcePlugin */
8
+
7
9
  /*
8
10
  * Important: This is not an actual import. We are pre-processing this CSS file
9
11
  * to inject the theme variables into the CSS. This import will be replaced
@@ -64,6 +66,7 @@
64
66
  * {
65
67
  @apply border-border outline-ring/50;
66
68
  }
69
+
67
70
  body {
68
71
  @apply bg-background text-foreground;
69
72
  }
@@ -0,0 +1,101 @@
1
+ import { usePermissions } from '@/hooks/use-permissions.js';
2
+ import { Trans } from '@/lib/trans.js';
3
+ import { cn } from '@/lib/utils.js';
4
+ import { LucideIcon } from 'lucide-react';
5
+ import { useState } from 'react';
6
+ import {
7
+ AlertDialog,
8
+ AlertDialogAction,
9
+ AlertDialogCancel,
10
+ AlertDialogContent,
11
+ AlertDialogDescription,
12
+ AlertDialogFooter,
13
+ AlertDialogHeader,
14
+ AlertDialogTitle,
15
+ AlertDialogTrigger,
16
+ } from '../ui/alert-dialog.js';
17
+ import { DropdownMenuItem } from '../ui/dropdown-menu.js';
18
+
19
+ export interface DataTableBulkActionItemProps {
20
+ label: React.ReactNode;
21
+ icon?: LucideIcon;
22
+ confirmationText?: React.ReactNode;
23
+ onClick: () => void;
24
+ className?: string;
25
+ requiresPermission?: string[];
26
+ }
27
+
28
+ export function DataTableBulkActionItem({
29
+ label,
30
+ icon: Icon,
31
+ confirmationText,
32
+ className,
33
+ onClick,
34
+ requiresPermission,
35
+ }: DataTableBulkActionItemProps) {
36
+ const [isOpen, setIsOpen] = useState(false);
37
+ const { hasPermissions } = usePermissions();
38
+ const userHasPermission = hasPermissions(requiresPermission ?? []);
39
+
40
+ const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
41
+ e.preventDefault();
42
+ e.stopPropagation();
43
+ if (!userHasPermission) {
44
+ return;
45
+ }
46
+ if (confirmationText) {
47
+ setIsOpen(true);
48
+ } else {
49
+ onClick?.();
50
+ }
51
+ };
52
+
53
+ const handleConfirm = () => {
54
+ setIsOpen(false);
55
+ onClick?.();
56
+ };
57
+
58
+ const handleCancel = () => {
59
+ setIsOpen(false);
60
+ };
61
+
62
+ if (confirmationText) {
63
+ return (
64
+ <AlertDialog open={isOpen} onOpenChange={setIsOpen}>
65
+ <AlertDialogTrigger asChild>
66
+ <DropdownMenuItem onClick={handleClick} disabled={!userHasPermission}>
67
+ {Icon && <Icon className={cn('mr-1 h-4 w-4', className)} />}
68
+ <span className={cn('text-sm', className)}>
69
+ <Trans>{label}</Trans>
70
+ </span>
71
+ </DropdownMenuItem>
72
+ </AlertDialogTrigger>
73
+ <AlertDialogContent>
74
+ <AlertDialogHeader>
75
+ <AlertDialogTitle>
76
+ <Trans>Confirm Action</Trans>
77
+ </AlertDialogTitle>
78
+ <AlertDialogDescription>{confirmationText}</AlertDialogDescription>
79
+ </AlertDialogHeader>
80
+ <AlertDialogFooter>
81
+ <AlertDialogCancel onClick={handleCancel}>
82
+ <Trans>Cancel</Trans>
83
+ </AlertDialogCancel>
84
+ <AlertDialogAction onClick={handleConfirm}>
85
+ <Trans>Continue</Trans>
86
+ </AlertDialogAction>
87
+ </AlertDialogFooter>
88
+ </AlertDialogContent>
89
+ </AlertDialog>
90
+ );
91
+ }
92
+
93
+ return (
94
+ <DropdownMenuItem onClick={handleClick}>
95
+ {Icon && <Icon className={cn('mr-1 h-4 w-4', className)} />}
96
+ <span className={cn('text-sm', className)}>
97
+ <Trans>{label}</Trans>
98
+ </span>
99
+ </DropdownMenuItem>
100
+ );
101
+ }